Compare commits

..

3 Commits

Author SHA1 Message Date
hypercross c10b088fb1 feat: add Journal server documentation to DocDialog
Introduce a new documentation set for Journal server functionality,
including a dropdown selector in the DocDialog to switch between
"directives" and "journal" documentation modes.
2026-07-07 23:57:31 +08:00
hypercross 1ef41f04fc feat: localize journal components to Chinese 2026-07-07 23:51:35 +08:00
hypercross 9a41f541e9 feat: improve server startup logs and simplify journal input labels
- Add logic to detect the best LAN IPv4 address for easier network
  access
- Display both localhost and LAN access URLs in the CLI
- Remove file path from journal input autocomplete labels
2026-07-07 23:48:04 +08:00
18 changed files with 361 additions and 73 deletions

View File

@ -4,6 +4,7 @@ import { readdirSync, statSync, readFileSync, existsSync } from "fs";
import { createReadStream } from "fs"; import { createReadStream } from "fs";
import { join, resolve, extname, sep, relative, dirname } from "path"; import { join, resolve, extname, sep, relative, dirname } from "path";
import { watch } from "chokidar"; import { watch } from "chokidar";
import { networkInterfaces } from "os";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js"; import { createJournalServer } from "../journal.js";
import { import {
@ -49,6 +50,35 @@ function getMimeType(filePath: string): string {
return MIME_TYPES[ext] || "application/octet-stream"; return MIME_TYPES[ext] || "application/octet-stream";
} }
/**
* Get the best network IP for display (prefer LAN IPv4, fallback to localhost).
*/
function getBestIP(): string {
const interfaces = networkInterfaces();
let best: string | null = null;
for (const [, addrs] of Object.entries(interfaces)) {
if (!addrs) continue;
for (const addr of addrs) {
if (addr.family !== "IPv4" || addr.internal) continue;
// Prefer a 192.168.x.x or 10.x.x.x address
if (addr.address.startsWith("192.168.")) {
best = addr.address;
break;
}
if (
!best &&
(addr.address.startsWith("10.") || addr.address.startsWith("172."))
) {
best = addr.address;
}
}
if (best?.startsWith("192.168.")) break;
}
return best || "localhost";
}
/** /**
* .md * .md
*/ */
@ -320,11 +350,16 @@ export function createContentServer(
}); });
server.listen(port, host, () => { server.listen(port, host, () => {
const displayHost = host === "0.0.0.0" ? "localhost" : host; const bestIP = getBestIP();
const displayHost = host === "0.0.0.0" ? bestIP : host;
console.log(`\n开发服务器已启动http://${displayHost}:${port}`); console.log(`\n开发服务器已启动http://${displayHost}:${port}`);
console.log(`本机访问http://localhost:${port}`);
if (bestIP !== "localhost") {
console.log(`局域网访问http://${bestIP}:${port}`);
}
console.log(`内容目录:${contentDir}`); console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`); console.log(`静态资源目录:${distPath}`);
console.log(`Journal 连接ws://${displayHost}:${port}\n`); console.log(`Journal 连接ws://${bestIP}:${port}\n`);
}); });
return { return {

View File

@ -7,6 +7,10 @@ import {
onMount, onMount,
} from "solid-js"; } from "solid-js";
import { docEntries, type DocEntry } from "./doc-data"; import { docEntries, type DocEntry } from "./doc-data";
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
type DocSet = "directives" | "journal";
type AnyEntry = DocEntry | JournalDocEntry;
export interface DocDialogProps { export interface DocDialogProps {
isOpen: boolean; isOpen: boolean;
@ -14,17 +18,42 @@ export interface DocDialogProps {
} }
const DocDialog: Component<DocDialogProps> = (props) => { const DocDialog: Component<DocDialogProps> = (props) => {
const [docSet, setDocSet] = createSignal<DocSet>("directives");
const [selectedTag, setSelectedTag] = createSignal(docEntries[0]?.tag ?? ""); const [selectedTag, setSelectedTag] = createSignal(docEntries[0]?.tag ?? "");
const [dropdownOpen, setDropdownOpen] = createSignal(false);
let dropdownRef!: HTMLDivElement;
const currentEntries = () =>
docSet() === "directives"
? (docEntries as AnyEntry[])
: (journalDocEntries as AnyEntry[]);
const selectedEntry = () => const selectedEntry = () =>
docEntries.find((e) => e.tag === selectedTag()) ?? docEntries[0]; currentEntries().find((e) => e.tag === selectedTag()) ??
currentEntries()[0];
const docSetLabel = () =>
docSet() === "directives" ? "指令组件文档" : "Journal 服务器文档";
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") props.onClose(); if (e.key === "Escape") props.onClose();
}; };
const switchDocSet = (set: DocSet) => {
setDocSet(set);
setDropdownOpen(false);
const entries = set === "directives" ? docEntries : journalDocEntries;
setSelectedTag(entries[0]?.tag ?? "");
};
onMount(() => { onMount(() => {
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
// Close dropdown on outside click
document.addEventListener("click", (e) => {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
setDropdownOpen(false);
}
});
}); });
onCleanup(() => { onCleanup(() => {
@ -41,7 +70,47 @@ const DocDialog: Component<DocDialogProps> = (props) => {
> >
<div class="bg-white rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden"> <div class="bg-white rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0"> <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
<h2 class="text-lg font-bold text-gray-900"></h2> <div ref={dropdownRef} class="relative">
<button
onClick={() => setDropdownOpen((v) => !v)}
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
>
{docSetLabel()}
<span class="text-gray-400 text-sm"></span>
</button>
<Show when={dropdownOpen()}>
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-50 py-1">
<button
onClick={() => switchDocSet("directives")}
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
docSet() === "directives"
? "bg-blue-50 text-blue-700"
: "text-gray-700"
}`}
>
<span>📖</span>
<span></span>
<Show when={docSet() === "directives"}>
<span class="ml-auto text-blue-500"></span>
</Show>
</button>
<button
onClick={() => switchDocSet("journal")}
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
docSet() === "journal"
? "bg-blue-50 text-blue-700"
: "text-gray-700"
}`}
>
<span>📋</span>
<span>Journal </span>
<Show when={docSet() === "journal"}>
<span class="ml-auto text-blue-500"></span>
</Show>
</button>
</div>
</Show>
</div>
<button <button
onClick={props.onClose} onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1" class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
@ -53,7 +122,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
<div class="flex flex-1 min-h-0"> <div class="flex flex-1 min-h-0">
<nav class="w-48 shrink-0 border-r border-gray-200 overflow-y-auto p-3 bg-gray-50"> <nav class="w-48 shrink-0 border-r border-gray-200 overflow-y-auto p-3 bg-gray-50">
<For each={docEntries}> <For each={currentEntries()}>
{(entry) => ( {(entry) => (
<button <button
onClick={() => setSelectedTag(entry.tag)} onClick={() => setSelectedTag(entry.tag)}
@ -64,7 +133,12 @@ const DocDialog: Component<DocDialogProps> = (props) => {
}`} }`}
> >
<span class="mr-2">{entry.icon}</span> <span class="mr-2">{entry.icon}</span>
<span class="font-mono text-xs">{`:${entry.tag}`}</span> <Show
when={docSet() === "directives"}
fallback={<span class="text-xs">{entry.title}</span>}
>
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
</Show>
</button> </button>
)} )}
</For> </For>
@ -83,7 +157,7 @@ const DocDialog: Component<DocDialogProps> = (props) => {
}; };
/** Single entry documentation content */ /** Single entry documentation content */
const DocContent: Component<{ entry: DocEntry }> = (props) => { const DocContent: Component<{ entry: AnyEntry }> = (props) => {
const e = props.entry; const e = props.entry;
return ( return (

View File

@ -0,0 +1,57 @@
import yaml from "js-yaml";
export interface JournalDocEntry {
tag: string;
icon: string;
title: string;
description: string;
syntax: string;
props: { name: string; type: string; default?: string; desc: string }[];
body: string;
}
function parseFrontmatter(raw: string): Record<string, unknown> | null {
const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return null;
try {
return yaml.load(match[1]) as Record<string, unknown>;
} catch {
return null;
}
}
function getBody(raw: string): string {
const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return match ? match[1] : raw;
}
function parseEntry(raw: string): JournalDocEntry | null {
const fm = parseFrontmatter(raw);
if (!fm) return null;
return {
tag: fm.tag as string,
icon: fm.icon as string,
title: fm.title as string,
description: fm.description as string,
syntax: fm.syntax as string,
props: (fm.props as JournalDocEntry["props"]) ?? [],
body: getBody(raw),
};
}
import journalGmRaw from "../doc-entries/journal-gm.md";
import journalPlayerRaw from "../doc-entries/journal-player.md";
const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw];
let _entries: JournalDocEntry[] | null = null;
function loadJournalDocEntries(): JournalDocEntry[] {
if (_entries) return _entries;
_entries = rawDocuments.map(parseEntry).filter(Boolean) as JournalDocEntry[];
return _entries;
}
export const journalDocEntries = loadJournalDocEntries();

View File

@ -68,7 +68,7 @@ export const ComposePanel: Component = () => {
disabled={sending()} disabled={sending()}
class="ml-auto bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50" class="ml-auto bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
> >
{sending() ? "..." : "Send"} {sending() ? "..." : "发送"}
</button> </button>
</div> </div>

View File

@ -31,7 +31,7 @@ export const ConnectionBar: Component = () => {
}; };
const handleDeleteSession = (id: string) => { const handleDeleteSession = (id: string) => {
if (confirm(`Delete session "${id}"? This cannot be undone.`)) { if (confirm(`确定要删除会话 "${id}"?此操作不可撤销。`)) {
deleteSession(id); deleteSession(id);
} }
}; };
@ -41,7 +41,7 @@ export const ConnectionBar: Component = () => {
await hydrateFromServer(id); await hydrateFromServer(id);
setSessionId(id); setSessionId(id);
} catch (e) { } catch (e) {
setError("Failed to load session"); setError("无法加载会话");
} }
}; };
@ -54,7 +54,7 @@ export const ConnectionBar: Component = () => {
{/* Status row */} {/* Status row */}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-500" /> <span class="w-2 h-2 rounded-full bg-green-500" />
<span class="text-xs text-gray-500">connected</span> <span class="text-xs text-gray-500"></span>
<Show when={stream.sessionId}> <Show when={stream.sessionId}>
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded"> <span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
{stream.sessionName || stream.sessionId} {stream.sessionName || stream.sessionId}
@ -62,12 +62,12 @@ export const ConnectionBar: Component = () => {
</Show> </Show>
<div class="flex-1" /> <div class="flex-1" />
<Show when={stream.myName !== "gm"}> <Show when={stream.myName !== "gm"}>
<span class="text-xs text-gray-400">Playing as {stream.myName}</span> <span class="text-xs text-gray-400"> {stream.myName}</span>
</Show> </Show>
<button <button
onClick={handleDisconnect} onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500" class="text-xs text-gray-400 hover:text-red-500"
title="Disconnect" title="断开连接"
> >
</button> </button>
@ -77,12 +77,12 @@ export const ConnectionBar: Component = () => {
<Show when={stream.myName === "gm"}> <Show when={stream.myName === "gm"}>
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-xs text-gray-500">Sessions:</span> <span class="text-xs text-gray-500"></span>
<button <button
onClick={() => setShowNewSession((v) => !v)} onClick={() => setShowNewSession((v) => !v)}
class="text-xs text-blue-600 hover:underline" class="text-xs text-blue-600 hover:underline"
> >
+ new +
</button> </button>
</div> </div>
@ -92,7 +92,7 @@ export const ConnectionBar: Component = () => {
type="text" type="text"
value={newSessionName()} value={newSessionName()}
onInput={(e) => setNewSessionName(e.currentTarget.value)} onInput={(e) => setNewSessionName(e.currentTarget.value)}
placeholder="Session name" placeholder="会话名称"
class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs" class="flex-1 border border-gray-300 rounded px-2 py-0.5 text-xs"
onKeyDown={(e) => e.key === "Enter" && handleCreateSession()} onKeyDown={(e) => e.key === "Enter" && handleCreateSession()}
/> />
@ -100,7 +100,7 @@ export const ConnectionBar: Component = () => {
onClick={handleCreateSession} onClick={handleCreateSession}
class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700" class="bg-green-600 text-white rounded px-2 py-0.5 text-xs hover:bg-green-700"
> >
Create Create Create
</button> </button>
</div> </div>
</Show> </Show>
@ -126,7 +126,7 @@ export const ConnectionBar: Component = () => {
handleDeleteSession(id); handleDeleteSession(id);
}} }}
class="text-gray-400 hover:text-red-500 text-xs" class="text-gray-400 hover:text-red-500 text-xs"
title="Delete session" title="删除会话"
> >
</button> </button>

View File

@ -53,7 +53,7 @@ const SchemaFields: Component<{
const shape = unwrapShape(props.schema); const shape = unwrapShape(props.schema);
if (!shape) { if (!shape) {
return <p class="text-xs text-gray-400">No fields</p>; return <p class="text-xs text-gray-400"></p>;
} }
return ( return (
@ -98,7 +98,7 @@ const SchemaFields: Component<{
checked={isSet()} checked={isSet()}
onChange={toggleOptional} onChange={toggleOptional}
class="mt-1.5" class="mt-1.5"
title={`Include ${key}`} title={`包含 ${key}`}
/> />
</Show> </Show>
<div class={`flex-1 ${isOptional && !isSet() ? "opacity-40" : ""}`}> <div class={`flex-1 ${isOptional && !isSet() ? "opacity-40" : ""}`}>
@ -204,7 +204,7 @@ const FieldInput: Component<{
}} }}
disabled={props.disabled} disabled={props.disabled}
class={inputClass} class={inputClass}
placeholder="comma-separated" placeholder="逗号分隔"
/> />
</Show> </Show>
</div> </div>

View File

@ -44,20 +44,19 @@ function parseInput(raw: string): ParsedInput {
if (raw.startsWith("/roll ")) { if (raw.startsWith("/roll ")) {
const notation = raw.slice("/roll ".length).trim(); const notation = raw.slice("/roll ".length).trim();
if (!notation) if (!notation)
return { type: "roll", payload: {}, error: "Dice notation required" }; return { type: "roll", payload: {}, error: "需要骰子表达式" };
return { type: "roll", payload: { notation, label: notation } }; return { type: "roll", payload: { notation, label: notation } };
} }
if (raw.startsWith("/spark ")) { if (raw.startsWith("/spark ")) {
const key = raw.slice("/spark ".length).trim(); const key = raw.slice("/spark ".length).trim();
if (!key) if (!key) return { type: "spark", payload: {}, error: "需要种子表键名" };
return { type: "spark", payload: {}, error: "Spark table key required" };
return { type: "spark", payload: { key } }; return { type: "spark", payload: { key } };
} }
if (raw.startsWith("/link ")) { if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim(); const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "link", payload: {}, error: "Path required" }; if (!arg) return { type: "link", payload: {}, error: "需要路径" };
const hashIdx = arg.indexOf("#"); const hashIdx = arg.indexOf("#");
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx); const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
const section = const section =
@ -67,7 +66,7 @@ function parseInput(raw: string): ParsedInput {
// /roll, /spark, or /link with no space — need to complete, don't send // /roll, /spark, or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/spark" || raw === "/link") { if (raw === "/roll" || raw === "/spark" || raw === "/link") {
return { type: "chat", payload: {}, error: "Complete the command" }; return { type: "chat", payload: {}, error: "请补全命令" };
} }
return { type: "chat", payload: { text: raw } }; return { type: "chat", payload: { text: raw } };
@ -168,7 +167,7 @@ export const JournalInput: Component = () => {
textareaRef?.focus(); textareaRef?.focus();
return; return;
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to roll spark table"); setError(e instanceof Error ? e.message : "种子表掷骰失败");
setSending(false); setSending(false);
return; return;
} }
@ -216,7 +215,7 @@ export const JournalInput: Component = () => {
); );
return matches.length > 0 return matches.length > 0
? matches ? matches
: [{ label: "Unknown command", kind: "no-results", insertText: "" }]; : [{ label: "未知命令", kind: "no-results", insertText: "" }];
} }
// After /roll — show dice suggestions // After /roll — show dice suggestions
@ -230,7 +229,7 @@ export const JournalInput: Component = () => {
) )
.slice(0, 8); .slice(0, 8);
if (matches.length === 0) { if (matches.length === 0) {
return [{ label: "No dice found", kind: "no-results", insertText: "" }]; return [{ label: "未找到骰子", kind: "no-results", insertText: "" }];
} }
return matches.map((d) => ({ return matches.map((d) => ({
label: d.notation, label: d.notation,
@ -252,14 +251,14 @@ export const JournalInput: Component = () => {
if (matches.length === 0) { if (matches.length === 0) {
return [ return [
{ {
label: "No spark tables found", label: "未找到种子表",
kind: "no-results", kind: "no-results",
insertText: "", insertText: "",
}, },
]; ];
} }
return matches.map((s) => ({ return matches.map((s) => ({
label: `${s.filePath} § ${s.slug} (${s.notation})`, label: `${s.slug} (${s.notation})`,
kind: "value" as const, kind: "value" as const,
insertText: `/spark ${s.slug}`, insertText: `/spark ${s.slug}`,
})); }));
@ -276,9 +275,7 @@ export const JournalInput: Component = () => {
) )
.slice(0, 8); .slice(0, 8);
if (matches.length === 0) { if (matches.length === 0) {
return [ return [{ label: "未找到链接", kind: "no-results", insertText: "" }];
{ label: "No links found", kind: "no-results", insertText: "" },
];
} }
return matches.map((l) => { return matches.map((l) => {
const insert = l.section const insert = l.section
@ -459,7 +456,7 @@ export const JournalInput: Component = () => {
ref={completionsRef} ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic" class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
> >
Loading completions
</div> </div>
</Match> </Match>
<Match when={comp.state.status === "error"}> <Match when={comp.state.status === "error"}>
@ -467,7 +464,7 @@ export const JournalInput: Component = () => {
ref={completionsRef} ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500" class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
> >
{(comp.state as any).message || "Failed to load completions"} {(comp.state as any).message || "无法加载补全数据"}
</div> </div>
</Match> </Match>
<Match <Match
@ -486,8 +483,7 @@ export const JournalInput: Component = () => {
when={!isObserver()} when={!isObserver()}
fallback={ fallback={
<div class="px-3 py-4 text-xs text-gray-400 italic text-center"> <div class="px-3 py-4 text-xs text-gray-400 italic text-center">
You are observing. Open this panel on another device to join as GM
or player.
</div> </div>
} }
> >
@ -498,8 +494,8 @@ export const JournalInput: Component = () => {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
isGm() isGm()
? "Type a message, or /roll, /spark, or /link..." ? "输入消息,或使用 /roll、/spark、/link 命令..."
: "Type a message..." : "输入消息..."
} }
rows={2} rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
@ -520,7 +516,7 @@ export const JournalInput: Component = () => {
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
transition-colors" transition-colors"
> >
Send
</button> </button>
</div> </div>
</div> </div>

View File

@ -68,9 +68,9 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
<button <button
onClick={() => setShowInvite(true)} onClick={() => setShowInvite(true)}
class="text-xs text-gray-400 hover:text-blue-600 px-1.5 py-0.5 rounded border border-dashed border-gray-300 hover:border-blue-400 whitespace-nowrap shrink-0" class="text-xs text-gray-400 hover:text-blue-600 px-1.5 py-0.5 rounded border border-dashed border-gray-300 hover:border-blue-400 whitespace-nowrap shrink-0"
title="Invite a player" title="邀请玩家"
> >
+ invite +
</button> </button>
</div> </div>
</Show> </Show>
@ -161,9 +161,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
<Show <Show
when={stream.connected && stream.sessionId} when={stream.connected && stream.sessionId}
fallback={ fallback={
<h2 class="text-sm font-semibold text-gray-700 truncate"> <h2 class="text-sm font-semibold text-gray-700 truncate"></h2>
Disconnected
</h2>
} }
> >
<div class="min-w-0"> <div class="min-w-0">
@ -221,7 +219,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
onInput={(e) => onInput={(e) =>
setNewSessionName(e.currentTarget.value) setNewSessionName(e.currentTarget.value)
} }
placeholder="Session name" placeholder="会话名称"
class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-xs" class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-xs"
autofocus autofocus
onKeyDown={(e) => { onKeyDown={(e) => {
@ -242,7 +240,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
onClick={() => setShowNewInput(true)} onClick={() => setShowNewInput(true)}
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100" class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
> >
+ New session +
</button> </button>
</Show> </Show>
</div> </div>
@ -257,7 +255,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
<button <button
onClick={handleDisconnect} onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500 px-1" class="text-xs text-gray-400 hover:text-red-500 px-1"
title="Disconnect" title="断开连接"
> >
</button> </button>
@ -265,7 +263,7 @@ const JournalHeader: Component<JournalHeaderProps> = (props) => {
<button <button
onClick={props.onClose} onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm px-1" class="text-gray-400 hover:text-gray-600 text-sm px-1"
title="Hide panel" title="隐藏面板"
> >
</button> </button>
@ -332,16 +330,16 @@ const ConnectDialog: Component = () => {
when={!autoJoined() || error()} when={!autoJoined() || error()}
fallback={ fallback={
<p class="text-sm text-gray-500 text-center"> <p class="text-sm text-gray-500 text-center">
{connecting() ? "Joining session…" : "Joined!"} {connecting() ? "正在加入会话…" : "已加入!"}
</p> </p>
} }
> >
<div class="w-full max-w-sm space-y-3"> <div class="w-full max-w-sm space-y-3">
<p class="text-sm text-gray-600 text-center"> <p class="text-sm text-gray-600 text-center">
Enter your name and role to join the session.
<br /> <br />
<span class="text-xs text-gray-400"> <span class="text-xs text-gray-400">
Connecting to {window.location.host} {window.location.host}
</span> </span>
</p> </p>
<input <input
@ -349,7 +347,7 @@ const ConnectDialog: Component = () => {
value={playerName()} value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)} onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()} onKeyDown={(e) => e.key === "Enter" && handleConnect()}
placeholder="Your name" placeholder="你的名字"
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
autofocus autofocus
/> />
@ -364,7 +362,7 @@ const ConnectDialog: Component = () => {
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400" : "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
}`} }`}
> >
{r === "gm" ? "GM" : r === "player" ? "Player" : "Observer"} {r === "gm" ? "主持人" : r === "player" ? "玩家" : "观察者"}
</button> </button>
))} ))}
</div> </div>
@ -374,7 +372,7 @@ const ConnectDialog: Component = () => {
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
hover:bg-blue-700 disabled:opacity-50" hover:bg-blue-700 disabled:opacity-50"
> >
{connecting() ? "Connecting..." : "Join"} {connecting() ? "连接中..." : "加入"}
</button> </button>
<Show when={error()}> <Show when={error()}>
<p class="text-red-500 text-sm text-center">{error()}</p> <p class="text-red-500 text-sm text-center">{error()}</p>
@ -424,7 +422,7 @@ const InviteDialog: Component<InviteDialogProps> = (props) => {
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/20"> <div class="absolute inset-0 z-50 flex items-center justify-center bg-black/20">
<div class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3"> <div class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800">Invite Player</h3> <h3 class="text-sm font-semibold text-gray-800"></h3>
<button <button
onClick={props.onClose} onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm" class="text-gray-400 hover:text-gray-600 text-sm"
@ -433,13 +431,13 @@ const InviteDialog: Component<InviteDialogProps> = (props) => {
</button> </button>
</div> </div>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
Enter a player name and share the link. They will join as a player.
</p> </p>
<input <input
type="text" type="text"
value={playerName()} value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)} onInput={(e) => setPlayerName(e.currentTarget.value)}
placeholder="Player name" placeholder="玩家名字"
class="w-full border border-gray-300 rounded px-2 py-1 text-xs" class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
autofocus autofocus
/> />
@ -459,7 +457,7 @@ const InviteDialog: Component<InviteDialogProps> = (props) => {
: "bg-blue-600 text-white hover:bg-blue-700" : "bg-blue-600 text-white hover:bg-blue-700"
}`} }`}
> >
{copied() ? "Copied!" : "Copy"} {copied() ? "已复制!" : "复制"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -45,7 +45,7 @@ export const StreamMessageCard: Component<{
<button <button
onClick={() => revertLatest()} onClick={() => revertLatest()}
class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none" class="text-xs text-gray-300 hover:text-red-500 ml-auto px-0.5 leading-none"
title="Revert this message" title="撤回此消息"
> >
× ×
</button> </button>
@ -87,7 +87,7 @@ const RevealedCard: Component<{ msg: StreamMessageType }> = (props) => {
return ( return (
<div class="bg-gray-100 rounded-lg border border-gray-200 opacity-50 px-2.5 py-1.5"> <div class="bg-gray-100 rounded-lg border border-gray-200 opacity-50 px-2.5 py-1.5">
<span class="text-xs text-gray-400 line-through"> <span class="text-xs text-gray-400 line-through">
{props.msg.sender} reverted {props.msg.sender}
</span> </span>
</div> </div>
); );

View File

@ -30,8 +30,8 @@ export const StreamView: Component = () => {
<Show when={messages().length === 0}> <Show when={messages().length === 0}>
<p class="text-center text-gray-400 text-xs py-8"> <p class="text-center text-gray-400 text-xs py-8">
{stream.connected {stream.connected
? "No messages yet. Start by posting something." ? "暂无消息,发送点什么开始吧。"
: "Connect to a session to see messages."} : "连接会话以查看消息。"}
</p> </p>
</Show> </Show>
</div> </div>

View File

@ -19,7 +19,7 @@ export type ChatPayload = z.infer<typeof schema>;
registerMessageType<ChatPayload>({ registerMessageType<ChatPayload>({
type: "chat", type: "chat",
label: "Chat", label: "聊天",
emitters: ["gm", "player"], emitters: ["gm", "player"],
schema, schema,
defaultPayload: () => ({ text: "" }), defaultPayload: () => ({ text: "" }),

View File

@ -18,7 +18,7 @@ export type IntentPayload = z.infer<typeof intentSchema>;
registerMessageType<IntentPayload>({ registerMessageType<IntentPayload>({
type: "intent", type: "intent",
label: "Declare Intent", label: "声明意图",
emitters: ["player"], emitters: ["player"],
schema: intentSchema, schema: intentSchema,
defaultPayload: () => ({ description: "" }), defaultPayload: () => ({ description: "" }),
@ -26,7 +26,7 @@ registerMessageType<IntentPayload>({
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-lg">🙋</span> <span class="text-lg">🙋</span>
<span class="text-sm font-medium text-yellow-700">Intent</span> <span class="text-sm font-medium text-yellow-700"></span>
</div> </div>
<p class="text-sm">{p.description}</p> <p class="text-sm">{p.description}</p>
{p.context && Object.keys(p.context).length > 0 && ( {p.context && Object.keys(p.context).length > 0 && (
@ -52,14 +52,14 @@ export type ResolutionPayload = z.infer<typeof resolutionSchema>;
registerMessageType<ResolutionPayload>({ registerMessageType<ResolutionPayload>({
type: "resolution", type: "resolution",
label: "Resolve Intent", label: "裁决意图",
emitters: ["gm"], emitters: ["gm"],
schema: resolutionSchema, schema: resolutionSchema,
render: (p) => ( render: (p) => (
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-lg"></span> <span class="text-lg"></span>
<span class="text-sm font-medium text-green-700">Resolution</span> <span class="text-sm font-medium text-green-700"></span>
<span class="text-xs text-gray-400">re: {p.intentId}</span> <span class="text-xs text-gray-400">re: {p.intentId}</span>
</div> </div>
<p class="text-sm">{p.outcome}</p> <p class="text-sm">{p.outcome}</p>

View File

@ -84,7 +84,7 @@ const RevealLink: Component<LinkPayload> = (p) => {
registerMessageType<LinkPayload>({ registerMessageType<LinkPayload>({
type: "link", type: "link",
label: "Link Article", label: "链接文章",
emitters: ["gm"], emitters: ["gm"],
schema, schema,
defaultPayload: () => ({ path: "/" }), defaultPayload: () => ({ path: "/" }),

View File

@ -43,7 +43,7 @@ export function resolveRollPayload(raw: {
registerMessageType<RollPayload>({ registerMessageType<RollPayload>({
type: "roll", type: "roll",
label: "Roll Dice", label: "掷骰",
emitters: ["gm"], emitters: ["gm"],
schema, schema,
defaultPayload: () => ({ defaultPayload: () => ({

View File

@ -117,7 +117,7 @@ export async function resolveSparkPayload(raw: {
registerMessageType<SparkPayload>({ registerMessageType<SparkPayload>({
type: "spark", type: "spark",
label: "Spark Table", label: "种子表",
emitters: ["gm"], emitters: ["gm"],
schema, schema,
defaultPayload: () => ({ defaultPayload: () => ({

View File

@ -0,0 +1,62 @@
---
tag: journal-gm
icon: 🎭
title: 主持人指南
description: 作为 GM 创建会话、管理玩家、使用动态表单和消息类型。
syntax: '点击导航栏 📋 图标打开 Journal 面板'
props:
- name: 会话管理
type: —
desc: 创建、切换、删除会话;通过邀请链接添加玩家
- name: 消息类型
type: —
desc: 发送叙述、掷骰、表单等多种消息类型
- name: 动态表单
type: —
desc: 创建自定义表单收集玩家输入
---
## 快速开始
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
2. 输入你的名字,选择"主持人"角色,点击"加入"
3. 连接成功后,你可以创建新会话或选择已有会话
## 会话管理
作为 GM你可以管理多个会话
- **创建会话**:点击标题下方的会话名称 → 下拉菜单 → "+ 新建会话"
- **切换会话**:点击会话名称,从下拉列表中选择
- **邀请玩家**:点击玩家标签旁的 "+ 邀请",输入玩家名字,复制链接发送给玩家
玩家通过邀请链接加入后会自动以玩家身份连接。
## 消息类型
Journal 支持多种消息类型,通过输入框上方的标签切换:
- **叙述**:普通的文字描述,用于推进剧情
- **掷骰**:发送掷骰结果,可附带公式
- **表单**:创建动态表单,收集玩家选择或输入
## 动态表单
表单消息允许你创建交互式问卷:
- 添加文本输入、选择框、复选框等字段
- 玩家提交后GM 可以看到汇总结果
- 适用于投票、决策、角色创建等场景
## 撤回消息
GM 可以撤回自己发送的最新消息,点击消息旁的撤回按钮即可。
## 连接状态
- 🟢 绿色:已连接
- 🟡 黄色闪烁:连接中
- 🔴 红色:连接错误
- ⚪ 灰色:未连接
断开连接可点击标题栏的 ⏻ 按钮。

View File

@ -0,0 +1,66 @@
---
tag: journal-player
icon: 🎲
title: 玩家指南
description: 作为玩家加入 GM 的会话,查看消息、提交表单、参与互动。
syntax: '通过 GM 发送的邀请链接加入'
props:
- name: 加入方式
type: —
desc: 点击邀请链接自动加入,或手动输入名字和角色
- name: 查看消息
type: —
desc: 实时查看 GM 和其他玩家的消息
- name: 提交表单
type: —
desc: 填写 GM 发送的动态表单并提交
---
## 加入会话
有两种方式加入 GM 的会话:
### 通过邀请链接(推荐)
GM 会发送一个包含 `?session=xxx&player=你的名字&autojoin=1` 的链接。
点击链接即可自动加入,无需手动输入任何信息。
### 手动加入
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
2. 输入你的名字
3. 选择"玩家"角色
4. 点击"加入"
## 查看消息
连接成功后,你可以在面板中看到:
- GM 发送的叙述消息
- 掷骰结果
- 其他玩家的消息
- 动态表单
消息实时更新,无需刷新页面。
## 填写表单
当 GM 发送动态表单时:
1. 表单会显示在消息流中
2. 根据表单类型填写(文本、选择、复选框等)
3. 点击提交按钮发送你的回答
GM 可以看到所有玩家的提交结果。
## 角色标识
- 你的名字旁会显示蓝色"player"标签
- 观察者显示灰色"observer"标签
- GM 不显示角色标签
## 注意事项
- 玩家无法创建或切换会话
- 玩家无法撤回消息
- 断开连接后重新加入会保留之前的消息记录

View File

@ -28,7 +28,7 @@ function tableToCSV(headers: string[], rows: string[][]): string {
} }
/** Spark table: first column header is a pure dice formula (d6, d20, etc.) */ /** Spark table: first column header is a pure dice formula (d6, d20, etc.) */
const SPARK_DICE_RE = /^d\d+$/i; const SPARK_DICE_RE = /^\d*d\d+$/i;
export default function markedTable(): MarkedExtension { export default function markedTable(): MarkedExtension {
return { return {