@world-engines/create-project 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +46 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +77 -0
- package/dist/default-view/app-v3.d.ts +1 -0
- package/dist/default-view/app-v3.js +83 -0
- package/dist/default-view/app.d.ts +1 -0
- package/dist/default-view/app.js +122 -0
- package/dist/default-view/bridge.d.ts +1 -0
- package/dist/default-view/bridge.js +112 -0
- package/dist/default-view/config.d.ts +1 -0
- package/dist/default-view/config.js +8 -0
- package/dist/default-view/interaction-demo.d.ts +6 -0
- package/dist/default-view/interaction-demo.js +129 -0
- package/dist/default-view/panel.d.ts +1 -0
- package/dist/default-view/panel.js +311 -0
- package/dist/default-view/styles.d.ts +2 -0
- package/dist/default-view/styles.js +125 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/initialize.d.ts +52 -0
- package/dist/initialize.js +776 -0
- package/dist/npm-environment.d.ts +2 -0
- package/dist/npm-environment.js +11 -0
- package/dist/template.d.ts +8 -0
- package/dist/template.js +128 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
WorldEngine 官方作者工具许可证
|
|
2
|
+
|
|
3
|
+
版权所有 (c) 2026 Nixdorfer。保留所有权利。
|
|
4
|
+
|
|
5
|
+
本仓库的源代码、构建材料及附带资源(统称“本作品”)不是开源软件,
|
|
6
|
+
不适用任何 OSI 认证的开源许可证。
|
|
7
|
+
|
|
8
|
+
一、官方作者工具分发许可
|
|
9
|
+
|
|
10
|
+
版权所有者可以通过其官方网站、npm registry、签名安装包或其他官方渠道,
|
|
11
|
+
复制并分发由本作品构建的 WorldEngine 作者工具及其必要运行资源
|
|
12
|
+
(统称“官方作者工具”)。仅版权所有者或其书面指定的发布者享有此分发权。
|
|
13
|
+
|
|
14
|
+
二、作者用户许可
|
|
15
|
+
|
|
16
|
+
从官方渠道取得官方作者工具的作者用户,可以:
|
|
17
|
+
|
|
18
|
+
1. 安装和运行官方作者工具;
|
|
19
|
+
2. 使用官方作者工具创作、编辑、预览、导入、导出和提交其有权处理的内容;
|
|
20
|
+
3. 为上述使用目的制作合理必要的本地备份副本。
|
|
21
|
+
|
|
22
|
+
三、未授予的权利
|
|
23
|
+
|
|
24
|
+
除第二条明确允许的行为外,本许可证不授予作者用户或其他第三方以下权利:
|
|
25
|
+
|
|
26
|
+
1. 修改、改编、反编译、反汇编或制作官方作者工具的派生作品;
|
|
27
|
+
2. 复制、镜像、转发、再上传、出售、出租、再分发或再许可官方作者工具;
|
|
28
|
+
3. 使用本作品的源代码、构建材料或任何部分开发、提供或训练其他产品或服务;
|
|
29
|
+
4. 删除或规避版权、许可、签名、访问控制或其他权利管理信息。
|
|
30
|
+
|
|
31
|
+
法律强制允许且合同不得排除的权利不受上述限制影响。
|
|
32
|
+
|
|
33
|
+
四、源代码查看
|
|
34
|
+
|
|
35
|
+
第三方可以在已获合法访问权限的范围内查看本作品,用于审查、安全研究或学习参考;
|
|
36
|
+
查看不授予运行、复制、修改、分发、再许可或用于其他产品与服务的权利。
|
|
37
|
+
|
|
38
|
+
五、无担保与责任限制
|
|
39
|
+
|
|
40
|
+
本作品与官方作者工具均按“现状”提供,不附带任何明示或暗示的担保。
|
|
41
|
+
在适用法律允许的最大范围内,版权所有者不对因访问或使用本作品或官方作者工具
|
|
42
|
+
造成的任何损失承担责任。
|
|
43
|
+
|
|
44
|
+
六、终止
|
|
45
|
+
|
|
46
|
+
违反本许可证将立即终止相应的访问与使用权;终止不影响版权所有者已经产生的权利和救济。
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { initializeLocalAuthorProject } from "./index.js";
|
|
5
|
+
function argumentError(message) {
|
|
6
|
+
return Object.assign(new Error(message), { code: "E_ARGUMENT_INVALID" });
|
|
7
|
+
}
|
|
8
|
+
function parseArguments(argv) {
|
|
9
|
+
let target;
|
|
10
|
+
let bootstrapFile;
|
|
11
|
+
let installDependencies = true;
|
|
12
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
13
|
+
const argument = argv[index];
|
|
14
|
+
if (argument === "--no-install") {
|
|
15
|
+
installDependencies = false;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (argument === "--target" || argument === "--bootstrap-file") {
|
|
19
|
+
const value = argv[index + 1];
|
|
20
|
+
if (value === undefined || value.startsWith("--"))
|
|
21
|
+
throw argumentError(`${argument} 需要一个路径`);
|
|
22
|
+
if (argument === "--target") {
|
|
23
|
+
if (target !== undefined)
|
|
24
|
+
throw argumentError("target 只能指定一次");
|
|
25
|
+
target = value;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
if (bootstrapFile !== undefined)
|
|
29
|
+
throw argumentError("--bootstrap-file 只能指定一次");
|
|
30
|
+
bootstrapFile = value;
|
|
31
|
+
}
|
|
32
|
+
index += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!argument.startsWith("--") && target === undefined) {
|
|
36
|
+
target = argument;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
throw argumentError(`未知参数:${argument}`);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
...(target === undefined ? {} : { target }),
|
|
43
|
+
...(bootstrapFile === undefined ? {} : { bootstrapFile }),
|
|
44
|
+
installDependencies,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export async function runCreateProjectCli(argv, io = {
|
|
48
|
+
stdout: (text) => process.stdout.write(text),
|
|
49
|
+
stderr: (text) => process.stderr.write(text),
|
|
50
|
+
}) {
|
|
51
|
+
try {
|
|
52
|
+
const options = parseArguments(argv);
|
|
53
|
+
if (options.target === undefined) {
|
|
54
|
+
io.stderr("用法:create-worldengine-project <empty-directory> [--bootstrap-file <cmd>] [--no-install]\n");
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
const receipt = await initializeLocalAuthorProject({
|
|
58
|
+
target: resolve(options.target),
|
|
59
|
+
install_dependencies: options.installDependencies,
|
|
60
|
+
...(options.bootstrapFile === undefined ? {} : { bootstrap_file: resolve(options.bootstrapFile) }),
|
|
61
|
+
});
|
|
62
|
+
io.stdout(`${JSON.stringify(receipt)}\n`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
const code = error instanceof Error && "code" in error ? String(error.code) : "E_INITIALIZATION_FAILED";
|
|
67
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
68
|
+
io.stderr(`${code}: ${message}\n`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const invokedPath = process.argv[1];
|
|
73
|
+
if (invokedPath !== undefined && resolve(invokedPath).toLocaleLowerCase("en-US") === resolve(fileURLToPath(import.meta.url)).toLocaleLowerCase("en-US")) {
|
|
74
|
+
void runCreateProjectCli(process.argv.slice(2)).then((exitCode) => {
|
|
75
|
+
process.exitCode = exitCode;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DEFAULT_CHAT_APP_V3: string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const DEFAULT_CHAT_APP_V3 = String.raw `import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
3
|
+
import { applyMessageDeleted, applyMessagePage, isPreviewModelUnavailable, loadMessages, mergeMessages, messageIdFromEvent, publicError, readTheme, requireChatPlay, resolvedThemeFromEvent, type ChatMessage, type ChatPlayConversationBridge } from "./chatplay";
|
|
4
|
+
import { PlayerPanel, type PlayerPanelKind } from "./player-panel";
|
|
5
|
+
|
|
6
|
+
type IconName = "add" | "send" | "model" | "worldline" | "progress" | "assets" | "resources" | "state" | "actions" | "search";
|
|
7
|
+
type PendingTurn = { readonly turnId: string; readonly epoch: number; readonly streamId?: string };
|
|
8
|
+
type EarlyTurnSignal = { readonly streamId?: string; readonly terminal?: "assistant" | "done" | "error"; readonly error?: unknown };
|
|
9
|
+
const actions: readonly { kind: PlayerPanelKind; label: string; icon: IconName }[] = [
|
|
10
|
+
{ kind: "model", label: "切换模型", icon: "model" }, { kind: "worldline", label: "世界线", icon: "worldline" },
|
|
11
|
+
{ kind: "progress", label: "剧情进度", icon: "progress" }, { kind: "assets", label: "资源", icon: "assets" },
|
|
12
|
+
{ kind: "resources", label: "已解锁", icon: "resources" }, { kind: "state", label: "角色状态", icon: "state" },
|
|
13
|
+
{ kind: "actions", label: "互动选择", icon: "actions" }, { kind: "search", label: "历史搜索", icon: "search" },
|
|
14
|
+
];
|
|
15
|
+
function Icon({ name }: { name: IconName }) {
|
|
16
|
+
const paths: Record<IconName, React.ReactNode> = {
|
|
17
|
+
add: <path d="M12 5v14M5 12h14" />, send: <path d="m4 5 16 7-16 7 3-7-3-7Zm3 7h13" />,
|
|
18
|
+
model: <><rect x="4" y="5" width="16" height="14" rx="3" /><path d="M8 10h.01M12 10h.01M16 10h.01M8 14h8" /></>,
|
|
19
|
+
worldline: <><circle cx="6" cy="6" r="2" /><circle cx="18" cy="18" r="2" /><circle cx="18" cy="6" r="2" /><path d="M8 6h5a5 5 0 0 1 5 5v5M8 6c5 0 3 12 8 12" /></>,
|
|
20
|
+
progress: <><path d="M5 19V9m7 10V5m7 14v-7M3 19h18" /></>, assets: <><rect x="4" y="5" width="16" height="14" rx="2" /><circle cx="9" cy="10" r="1.5" /><path d="m5 17 5-5 3 3 2-2 4 4" /></>,
|
|
21
|
+
resources: <><path d="M12 3 19 7v10l-7 4-7-4V7l7-4Z" /><path d="m5 7 7 4 7-4M12 11v10" /></>, state: <><circle cx="12" cy="12" r="8" /><path d="M12 8v4l3 2M5 5l2 2m10-2-2 2" /></>,
|
|
22
|
+
actions: <><path d="M5 7h14M5 12h14M5 17h9" /><path d="m15 15 3 2-3 2" /></>, search: <><circle cx="10.5" cy="10.5" r="5.5" /><path d="m15 15 4 4" /></>,
|
|
23
|
+
};
|
|
24
|
+
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>;
|
|
25
|
+
}
|
|
26
|
+
function nearBottom(node: HTMLElement | null) { return !node || node.scrollHeight - node.scrollTop - node.clientHeight < 72; }
|
|
27
|
+
function stringField(payload: unknown, field: string): string | null { return payload && typeof payload === "object" && typeof (payload as Record<string, unknown>)[field] === "string" ? (payload as Record<string, string>)[field] : null; }
|
|
28
|
+
function turnIdFrom(payload: unknown): string | null { return stringField(payload, "turnId") ?? (payload && typeof payload === "object" ? stringField((payload as { meta?: unknown }).meta, "turnId") : null); }
|
|
29
|
+
function isAssistantTurn(message: ChatMessage, turnId: string): boolean { return message.role === "assistant" && turnIdFrom(message.meta) === turnId; }
|
|
30
|
+
function assistantTurnIdFrom(payload: unknown): string | null { return stringField(payload, "role") === "assistant" ? turnIdFrom(payload) : null; }
|
|
31
|
+
|
|
32
|
+
export function App() {
|
|
33
|
+
const [bridge, setBridge] = useState<ChatPlayConversationBridge | null>(null); const [messages, setMessages] = useState<readonly ChatMessage[]>([]); const [hasEarlier, setHasEarlier] = useState(false);
|
|
34
|
+
const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const [pendingTurn, setPendingTurn] = useState<PendingTurn | null>(null); const [composing, setComposing] = useState(false); const [title, setTitle] = useState("故事对话"); const [theme, setTheme] = useState<"light" | "dark">("dark"); const [error, setError] = useState<string | null>(null); const [menuOpen, setMenuOpen] = useState(false); const [panel, setPanel] = useState<PlayerPanelKind | null>(null);
|
|
35
|
+
const transcript = useRef<HTMLElement>(null); const input = useRef<HTMLTextAreaElement>(null); const menu = useRef<HTMLDivElement>(null); const bridgeRef = useRef<ChatPlayConversationBridge | null>(null); const pendingTurnRef = useRef<PendingTurn | null>(null); const earlySignals = useRef(new Map<string, EarlyTurnSignal>()); const acceptingEpoch = useRef<number | null>(null); const generation = useRef(0); const worldlineEpoch = useRef(0); const requestSerial = useRef(0); const errorVersion = useRef(0); const errorKind = useRef<"read" | "other">("other"); const deletedIds = useRef(new Set<string>()); const inFlight = useRef(false); const themeVersion = useRef(0);
|
|
36
|
+
const showError = useCallback((message: string, kind: "read" | "other" = "other") => { errorVersion.current += 1; errorKind.current = kind; setError(message); }, []);
|
|
37
|
+
const clearErrorIfUnchanged = useCallback((version: number) => { if (errorVersion.current === version) setError(null); }, []);
|
|
38
|
+
const clearReadErrorIfUnchanged = useCallback((version: number) => { if (errorVersion.current === version && errorKind.current === "read") setError(null); }, []);
|
|
39
|
+
const clearPendingTurn = useCallback((turnId?: string | null, streamId?: string | null) => { const pending = pendingTurnRef.current; const matchesLocalTurnStream = pending !== null && pending.streamId === undefined && streamId === pending.turnId; if (!pending || pending.epoch !== worldlineEpoch.current || turnId !== pending.turnId && (streamId === null || streamId === undefined || streamId !== pending.streamId && !matchesLocalTurnStream)) return; pendingTurnRef.current = null; setPendingTurn(null); }, []);
|
|
40
|
+
const rememberEarlySignal = useCallback((turnId: string | null, signal: EarlyTurnSignal) => { if (!turnId || !inFlight.current || acceptingEpoch.current !== worldlineEpoch.current) return; const earlier = earlySignals.current.get(turnId); earlySignals.current.set(turnId, { ...earlier, ...signal }); }, []);
|
|
41
|
+
const resizeInput = useCallback(() => { const node = input.current; if (!node) return; node.style.height = "auto"; const computed = window.getComputedStyle(node); const border = (Number.parseFloat(computed.borderTopWidth) || 0) + (Number.parseFloat(computed.borderBottomWidth) || 0); const maxContent = 160 - border; node.style.height = Math.min(node.scrollHeight + border, 160) + "px"; node.style.overflowY = node.scrollHeight > maxContent ? "auto" : "hidden"; }, []);
|
|
42
|
+
useLayoutEffect(() => { resizeInput(); }, [draft, resizeInput]);
|
|
43
|
+
const refreshMessages = useCallback(async (api: ChatPlayConversationBridge, preservePosition = true, stamp = generation.current) => {
|
|
44
|
+
const shouldStick = !preservePosition || nearBottom(transcript.current); const request = ++requestSerial.current; const errorsBeforeRequest = errorVersion.current;
|
|
45
|
+
try { const next = await loadMessages(api); if (stamp !== generation.current || request !== requestSerial.current) return; const pending = pendingTurnRef.current; if (pending && pending.epoch === worldlineEpoch.current && next.some((message) => isAssistantTurn(message, pending.turnId))) clearPendingTurn(pending.turnId); setMessages((current) => applyMessagePage({ request: request - 1, deletedIds: deletedIds.current, messages: current }, request, next).messages); setHasEarlier(next.length >= 80); clearReadErrorIfUnchanged(errorsBeforeRequest); if (shouldStick) requestAnimationFrame(() => transcript.current?.scrollTo({ top: transcript.current.scrollHeight })); }
|
|
46
|
+
catch (reason) { if (stamp !== generation.current || request !== requestSerial.current) return; showError(isPreviewModelUnavailable(reason) ? "当前预览未连接模型" : publicError(reason, "读取消息失败") ?? "读取消息失败", "read"); }
|
|
47
|
+
}, [clearPendingTurn, clearReadErrorIfUnchanged, showError]);
|
|
48
|
+
const refreshTheme = useCallback(async (api: ChatPlayConversationBridge, payload?: unknown, stamp = generation.current) => {
|
|
49
|
+
const eventTheme = resolvedThemeFromEvent(payload); if (eventTheme) { themeVersion.current += 1; if (stamp === generation.current) setTheme(eventTheme); return; }
|
|
50
|
+
const version = themeVersion.current; try { const snapshot = await readTheme(api); if (stamp === generation.current && version === themeVersion.current) setTheme(snapshot.resolved); } catch { /* 主题读取不能阻断消息。 */ }
|
|
51
|
+
}, []);
|
|
52
|
+
const resetWorldline = useCallback(() => { const api = bridgeRef.current; if (!api) return; worldlineEpoch.current += 1; earlySignals.current.clear(); acceptingEpoch.current = null; pendingTurnRef.current = null; setPendingTurn(null); errorVersion.current += 1; setError(null); deletedIds.current.clear(); requestSerial.current += 1; setMessages([]); setHasEarlier(false); void refreshMessages(api, false, generation.current); }, [refreshMessages]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const stamp = ++generation.current; let active = true; let stop = () => {};
|
|
55
|
+
try {
|
|
56
|
+
const api = requireChatPlay(); const refresh = () => { void refreshMessages(api, true, stamp); };
|
|
57
|
+
void api.ready.then(() => {
|
|
58
|
+
if (!active || stamp !== generation.current) return; bridgeRef.current = api; setBridge(api);
|
|
59
|
+
const remove = (payload: unknown) => { const id = messageIdFromEvent(payload); if (id) { deletedIds.current.add(id); setMessages((current) => applyMessageDeleted({ request: requestSerial.current, deletedIds: deletedIds.current, messages: current }, id).messages); } refresh(); };
|
|
60
|
+
const noteStreamStart = (payload: unknown) => { const pending = pendingTurnRef.current; const turnId = turnIdFrom(payload); const streamId = stringField(payload, "streamId"); rememberEarlySignal(turnId, { streamId: streamId ?? undefined }); if (!pending || pending.epoch !== worldlineEpoch.current || turnId !== pending.turnId || !streamId) return; const next = { ...pending, streamId }; pendingTurnRef.current = next; setPendingTurn(next); };
|
|
61
|
+
const noteAssistant = (payload: unknown) => { const turnId = assistantTurnIdFrom(payload); rememberEarlySignal(turnId, { terminal: "assistant" }); if (turnId) clearPendingTurn(turnId); refresh(); };
|
|
62
|
+
const endStream = (payload: unknown) => { const turnId = turnIdFrom(payload); const streamId = stringField(payload, "streamId"); rememberEarlySignal(turnId, { streamId: streamId ?? undefined, terminal: "done" }); clearPendingTurn(turnId, streamId); refresh(); };
|
|
63
|
+
const streamError = (payload: unknown) => { const epoch = worldlineEpoch.current; const turnId = turnIdFrom(payload); const streamId = stringField(payload, "streamId"); rememberEarlySignal(turnId, { streamId: streamId ?? undefined, terminal: "error", error: payload }); const pending = pendingTurnRef.current; const belongsToPending = Boolean(pending && pending.epoch === epoch && (turnId === pending.turnId || streamId !== null && (streamId === pending.streamId || pending.streamId === undefined && streamId === pending.turnId))); queueMicrotask(() => { if (!active || stamp !== generation.current || epoch !== worldlineEpoch.current) return; if (belongsToPending) clearPendingTurn(turnId, streamId); if (!turnId && !streamId || belongsToPending) showError(isPreviewModelUnavailable(payload) ? "当前预览未连接模型" : publicError(payload, "生成出错") ?? "生成出错"); }); };
|
|
64
|
+
const stops = [api.on("themeChanged", (payload) => { void refreshTheme(api, payload, stamp); }), api.on("messageAdded", noteAssistant), api.on("messageUpdated", noteAssistant), api.on("messageDeleted", remove), api.on("streamStart", noteStreamStart), api.on("streamDelta", refresh), api.on("streamDone", endStream), api.on("streamError", streamError), api.on("worldlineActivated", resetWorldline), api.on("worldlineForked", resetWorldline), api.on("localeChanged", () => { void api.getLocale().then((locale) => { if (active && stamp === generation.current) document.documentElement.lang = locale; }).catch(() => {}); })];
|
|
65
|
+
stop = () => stops.forEach((unsubscribe) => unsubscribe()); void refreshMessages(api, false, stamp); void refreshTheme(api, undefined, stamp);
|
|
66
|
+
void api.getScenarioMeta().then((meta) => { if (active && stamp === generation.current) setTitle(meta.title && meta.title !== meta.scriptId ? meta.title : "故事对话"); }).catch(() => {});
|
|
67
|
+
void api.getLocale().then((locale) => { if (active && stamp === generation.current) document.documentElement.lang = locale; }).catch(() => {});
|
|
68
|
+
}).catch((reason) => { if (active) showError(publicError(reason, "视图未连接") ?? "视图未连接"); });
|
|
69
|
+
} catch (reason) { showError(publicError(reason, "视图未连接") ?? "视图未连接"); }
|
|
70
|
+
return () => { active = false; bridgeRef.current = null; earlySignals.current.clear(); acceptingEpoch.current = null; pendingTurnRef.current = null; setPendingTurn(null); generation.current += 1; stop(); };
|
|
71
|
+
}, [clearPendingTurn, refreshMessages, refreshTheme, rememberEarlySignal, resetWorldline, showError]);
|
|
72
|
+
useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { setMenuOpen(false); setPanel(null); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []);
|
|
73
|
+
const loadEarlier = useCallback(async () => { if (!bridge || !messages.length) return; const stamp = generation.current; const epoch = worldlineEpoch.current; const request = ++requestSerial.current; const errorsBeforeRequest = errorVersion.current; const node = transcript.current; const top = node?.scrollTop ?? 0; const height = node?.scrollHeight ?? 0; try { const older = await loadMessages(bridge, messages[0].id); if (stamp !== generation.current || epoch !== worldlineEpoch.current || request !== requestSerial.current) return; setMessages((current) => mergeMessages(current, older.filter((message) => !deletedIds.current.has(message.id)))); setHasEarlier(older.length >= 80); clearReadErrorIfUnchanged(errorsBeforeRequest); requestAnimationFrame(() => { if (node && epoch === worldlineEpoch.current) node.scrollTop = top + node.scrollHeight - height; }); } catch (reason) { if (stamp === generation.current && epoch === worldlineEpoch.current && request === requestSerial.current) showError(publicError(reason, "读取更早消息失败") ?? "读取更早消息失败", "read"); } }, [bridge, clearReadErrorIfUnchanged, messages, showError]);
|
|
74
|
+
const submit = useCallback(async () => { if (!bridge || inFlight.current || !draft.trim()) return; const stamp = generation.current; const epoch = worldlineEpoch.current; const errorsBeforeSend = errorVersion.current; inFlight.current = true; acceptingEpoch.current = epoch; earlySignals.current.clear(); setSending(true); try { const accepted = await bridge.sendUserMessage(draft.trim()); if (stamp !== generation.current || epoch !== worldlineEpoch.current) return; const early = earlySignals.current.get(accepted.turnId); const nextPending = { turnId: accepted.turnId, epoch, ...(early?.streamId ? { streamId: early.streamId } : {}) }; pendingTurnRef.current = nextPending; setPendingTurn(nextPending); setDraft(""); clearErrorIfUnchanged(errorsBeforeSend); if (early?.terminal === "assistant" || early?.terminal === "done") clearPendingTurn(accepted.turnId, early.streamId); if (early?.terminal === "error") { clearPendingTurn(accepted.turnId, early.streamId); showError(isPreviewModelUnavailable(early.error) ? "当前预览未连接模型" : publicError(early.error, "生成出错") ?? "生成出错"); } await refreshMessages(bridge, false, stamp); } catch (reason) { if (stamp === generation.current && epoch === worldlineEpoch.current) showError(isPreviewModelUnavailable(reason) ? "当前预览未连接模型" : publicError(reason, "发送失败,草稿已保留") ?? "发送失败,草稿已保留"); } finally { earlySignals.current.clear(); acceptingEpoch.current = null; inFlight.current = false; setSending(false); } }, [bridge, clearErrorIfUnchanged, clearPendingTurn, draft, refreshMessages, showError]);
|
|
75
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => { const native = event.nativeEvent; if (event.key !== "Enter" || event.shiftKey || composing || native.isComposing || native.keyCode === 229 || window.matchMedia("(max-width: 720px)").matches) return; event.preventDefault(); void submit(); };
|
|
76
|
+
return <main className="chat-app" data-theme={theme}>
|
|
77
|
+
<header className="chat-header"><p className="chat-scene-title">{title}</p></header>{error && <p className="chat-error" role="alert">{error}</p>}{pendingTurn && <p className="chat-status" role="status">故事正在回应…</p>}
|
|
78
|
+
<section className="chat-transcript" ref={transcript} aria-label="对话记录">{hasEarlier && <button className="chat-retry" type="button" onClick={() => void loadEarlier()}>读取更早消息</button>}{messages.length ? messages.map((message) => <article className="chat-message" data-role={message.role} key={message.id}><p className="chat-message-body">{message.text}</p></article>) : <div className="chat-empty"><h2>对话从这里开始</h2><p>写下你的第一句话,故事会按当前剧本继续。</p></div>}</section>
|
|
79
|
+
<div className="chat-composer"><div className="chat-fab-row" ref={menu}><button className="chat-fab chat-fab-add" type="button" aria-expanded={menuOpen} aria-label="更多玩家功能" onClick={() => setMenuOpen((open) => !open)}><Icon name="add" /></button>{menuOpen && <div className="chat-fab-menu" aria-expanded="true">{actions.map((action) => <button className="chat-fab-option" type="button" key={action.kind} onClick={() => { setPanel(action.kind); setMenuOpen(false); }}><span className="chat-fab-option-icon"><Icon name={action.icon} /></span><span className="chat-fab-option-label">{action.label}</span></button>)}</div>}</div><textarea ref={input} className="chat-input" rows={1} value={draft} placeholder="写下你的回复…" aria-label="发送消息" onChange={(event) => setDraft(event.target.value)} onCompositionStart={() => setComposing(true)} onCompositionEnd={() => setComposing(false)} onKeyDown={onKeyDown} disabled={!bridge || sending} /><button className="chat-fab chat-fab-send" type="button" aria-label="发送消息" onClick={() => { void submit(); }} disabled={!bridge || sending || !draft.trim()}><Icon name="send" /></button></div>
|
|
80
|
+
{panel && bridge && <div className="chat-panel-overlay" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setPanel(null); }}><PlayerPanel kind={panel} bridge={bridge} messages={messages} onWorldlineChange={resetWorldline} onClose={() => setPanel(null)} /></div>}
|
|
81
|
+
</main>;
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DEFAULT_CHAT_APP: string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export const DEFAULT_CHAT_APP = String.raw `import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
3
|
+
import { applyMessageDeleted, applyMessagePage, emitConfiguredAction, isPreviewModelUnavailable, loadMessages, mergeMessages, messageIdFromEvent, optionalRead, publicError, readableError, readTheme, requireChatPlay, resolvedThemeFromEvent, sendMessage, type ChatMessage, type ChatPlayConversationBridge } from "./chatplay";
|
|
4
|
+
import { viewConfig } from "./view-config";
|
|
5
|
+
|
|
6
|
+
type Asset = Awaited<ReturnType<ChatPlayConversationBridge["listAssets"]>>[number];
|
|
7
|
+
type ResourceGroup = Awaited<ReturnType<ChatPlayConversationBridge["getOwnedResources"]>>[number];
|
|
8
|
+
type MobilePanel = "chat" | "story" | "details";
|
|
9
|
+
|
|
10
|
+
const icon = (name: "menu" | "info" | "send" | "back") => ({ menu: <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16" /></svg>, info: <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8" /><path d="M12 11v5m0-8h.01" /></svg>, send: <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m4 5 16 7-16 7 3-7-3-7Zm3 7h13" /></svg>, back: <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14 6-6 6 6 6" /></svg> }[name]);
|
|
11
|
+
|
|
12
|
+
function formatDate(ts: number, locale: string) { return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(new Date(ts)); }
|
|
13
|
+
function isPreviewable(asset: Asset) { return asset.mime.startsWith("image/") || asset.mime.startsWith("audio/") || asset.mime.startsWith("video/"); }
|
|
14
|
+
function stateValue(value: unknown): unknown { if (value && typeof value === "object" && "unlocked" in value) return (value as { unlocked: boolean }).unlocked ? "已解锁" : "未解锁"; if (value && typeof value === "object" && "value" in value) return (value as { value: unknown }).value; return value; }
|
|
15
|
+
function optionalError(result: unknown): string | undefined { return result && typeof result === "object" && "error" in result && typeof (result as { error?: unknown }).error === "string" ? (result as { error: string }).error : undefined; }
|
|
16
|
+
|
|
17
|
+
export function App() {
|
|
18
|
+
const [bridge, setBridge] = useState<ChatPlayConversationBridge | null>(null);
|
|
19
|
+
const [messages, setMessages] = useState<readonly ChatMessage[]>([]);
|
|
20
|
+
const [hasEarlier, setHasEarlier] = useState(false);
|
|
21
|
+
const [draft, setDraft] = useState("");
|
|
22
|
+
const [sending, setSending] = useState(false);
|
|
23
|
+
const [composing, setComposing] = useState(false);
|
|
24
|
+
const [error, setError] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null);
|
|
25
|
+
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
|
26
|
+
const [locale, setLocale] = useState("zh-CN");
|
|
27
|
+
const [title, setTitle] = useState("故事对话");
|
|
28
|
+
const [progress, setProgress] = useState<number | null>(null);
|
|
29
|
+
const [assets, setAssets] = useState<readonly Asset[] | null>(null);
|
|
30
|
+
const [assetsEnabled, setAssetsEnabled] = useState(false);
|
|
31
|
+
const [resources, setResources] = useState<readonly ResourceGroup[] | null>(null);
|
|
32
|
+
const [stateKeys, setStateKeys] = useState<readonly string[]>([]);
|
|
33
|
+
const [stateEntries, setStateEntries] = useState<readonly { key: string; value: unknown }[]>([]);
|
|
34
|
+
const [unavailable, setUnavailable] = useState<readonly string[]>([]);
|
|
35
|
+
const [stateError, setStateError] = useState<string | null>(null); const themeVersion = useRef(0);
|
|
36
|
+
const [stateEnabled, setStateEnabled] = useState(viewConfig.variables.length > 0 || viewConfig.unlocks.length > 0);
|
|
37
|
+
const [mobilePanel, setMobilePanel] = useState<MobilePanel>("chat");
|
|
38
|
+
const [assetUrls, setAssetUrls] = useState<Record<string, string>>({});
|
|
39
|
+
const transcript = useRef<HTMLElement>(null); const mainRef = useRef<HTMLElement>(null); const storyRef = useRef<HTMLElement>(null); const detailsRef = useRef<HTMLElement>(null); const generation = useRef(0); const messageRequest = useRef(0); const sendInFlight = useRef(false); const deletedIds = useRef(new Set<string>());
|
|
40
|
+
|
|
41
|
+
const refreshMessages = useCallback(async (current: ChatPlayConversationBridge, preservePosition = true, stamp = generation.current) => {
|
|
42
|
+
try {
|
|
43
|
+
const request = ++messageRequest.current; const nextMessages = await loadMessages(current);
|
|
44
|
+
if (stamp !== generation.current || request !== messageRequest.current) return;
|
|
45
|
+
setMessages((existing) => applyMessagePage({ request: request - 1, deletedIds: deletedIds.current, messages: existing }, request, nextMessages).messages); setHasEarlier(nextMessages.length >= 80);
|
|
46
|
+
if (!preservePosition) requestAnimationFrame(() => transcript.current?.scrollTo({ top: transcript.current.scrollHeight }));
|
|
47
|
+
} catch (reason) { if (stamp === generation.current) { if (isPreviewModelUnavailable(reason)) { setError(null); setActionNotice("视图桥已连接;当前为界面预览,未连接模型。"); } else { setActionNotice(null); setError(publicError(reason, "读取消息失败") ?? "读取消息失败"); } } }
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const refreshDetails = useCallback(async (current: ChatPlayConversationBridge, stamp = generation.current) => {
|
|
51
|
+
try {
|
|
52
|
+
const capabilities = current.context()?.capabilities; const supportsAssets = capabilities?.supportsAssets === true; const supportsCustomState = (capabilities?.customStateMaxBytes ?? 0) > 0;
|
|
53
|
+
const [scenario, nextProgress, nextAssets, nextResources, nextKeys, nextLocale] = await Promise.all([
|
|
54
|
+
optionalRead(() => current.getScenarioMeta()), optionalRead(() => current.getProgress()), supportsAssets ? optionalRead(() => current.listAssets()) : optionalRead(async () => [] as Asset[]), optionalRead(() => current.getOwnedResources()), supportsCustomState ? optionalRead(() => current.customState.keys()) : optionalRead(async () => [] as string[]), optionalRead(() => current.getLocale()),
|
|
55
|
+
]);
|
|
56
|
+
const keys = nextKeys.value ?? [];
|
|
57
|
+
const entryResults = await Promise.all(keys.slice(0, 12).map(async (key) => ({ key, result: await optionalRead(() => current.customState.get(key)) })));
|
|
58
|
+
const configuredEntries = await Promise.all([...viewConfig.variables.map(async (item) => ({ key: item.label, result: await optionalRead(() => current.getVariable(item.name)) })), ...viewConfig.unlocks.map(async (item) => ({ key: item.label, result: await optionalRead(() => current.getUnlock(item.target, item.id)) }))]);
|
|
59
|
+
if (stamp !== generation.current) return;
|
|
60
|
+
if (scenario.value?.title) setTitle(scenario.value.title);
|
|
61
|
+
const stateResults = [...entryResults, ...configuredEntries]; const assetsError = optionalError(nextAssets); const resourcesError = optionalError(nextResources); const keysError = optionalError(nextKeys); setProgress(typeof nextProgress.value === "number" && Number.isFinite(nextProgress.value) ? Math.max(0, Math.min(1, nextProgress.value)) : null); setAssets(assetsError ? null : nextAssets.value ?? []); setResources(resourcesError ? null : nextResources.value ?? []); setStateKeys(keys); setStateEntries(stateResults.filter((entry) => optionalError(entry.result) === undefined).map((entry) => ({ key: entry.key, value: stateValue(entry.result.value) }))); setStateError(stateResults.some((entry) => optionalError(entry.result) !== undefined) || keysError ? publicError(keysError ?? new Error("state"), "状态暂不可用") ?? null : null); setStateEnabled(supportsCustomState || configuredEntries.length > 0); if (nextLocale.value) setLocale(nextLocale.value); setUnavailable([...new Set([scenario, nextProgress, nextAssets, nextResources, nextKeys, nextLocale].flatMap((item) => { const error = optionalError(item); return error ? [publicError(error, "读取故事信息失败")].filter((value): value is string => value !== null) : []; }))]);
|
|
62
|
+
} catch (reason) { setError(readableError(reason, "读取故事信息失败")); }
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const refreshTheme = useCallback(async (current: ChatPlayConversationBridge, payload?: unknown, stamp = generation.current) => {
|
|
66
|
+
const eventTheme = resolvedThemeFromEvent(payload); if (eventTheme) { themeVersion.current += 1; if (stamp === generation.current) setTheme(eventTheme); return; }
|
|
67
|
+
const requestVersion = themeVersion.current; try { const next = await readTheme(current); if (stamp === generation.current && requestVersion === themeVersion.current) setTheme(next.resolved); } catch (reason) { if (stamp === generation.current) setError(readableError(reason, "读取主题失败")); }
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const stamp = ++generation.current; let active = true; let stop = () => {};
|
|
72
|
+
try {
|
|
73
|
+
const current = requireChatPlay();
|
|
74
|
+
void current.ready.then(async () => {
|
|
75
|
+
if (!active) return;
|
|
76
|
+
setBridge(current); const capabilities = current.context()?.capabilities; setAssetsEnabled(capabilities?.supportsAssets === true);
|
|
77
|
+
const refreshMessage = () => { void refreshMessages(current, true, stamp); }; const refreshDetail = () => { void refreshDetails(current, stamp); };
|
|
78
|
+
const messageStops = [current.on("messageAdded", refreshMessage), current.on("messageUpdated", refreshMessage), current.on("streamStart", refreshMessage), current.on("streamDelta", refreshMessage), current.on("streamDone", refreshMessage)]; const streamErrorStop = current.on("streamError", (payload) => { if (isPreviewModelUnavailable(payload)) { setError(null); setActionNotice("视图桥已连接;当前为界面预览,未连接模型。"); } else { const notice = publicError(payload, "生成暂不可用"); if (notice) { setActionNotice(null); setError(notice); } else { setError(null); setActionNotice("当前宿主未开放生成能力。"); } } });
|
|
79
|
+
const deletedStop = current.on("messageDeleted", (payload) => { const id = messageIdFromEvent(payload); if (id) { deletedIds.current.add(id); setMessages((existing) => applyMessageDeleted({ request: messageRequest.current, deletedIds: deletedIds.current, messages: existing }, id).messages); } void refreshMessages(current, true, stamp); });
|
|
80
|
+
const detailStops = [current.on("resourceUnlocked", refreshDetail), current.on("stateChanged", refreshDetail), current.on("localeChanged", refreshDetail), current.on("tmwDelivery", refreshDetail)];
|
|
81
|
+
const themeStop = current.on("themeChanged", (payload) => { void refreshTheme(current, payload, stamp); });
|
|
82
|
+
await Promise.all([refreshMessages(current, false, stamp), refreshDetails(current, stamp), refreshTheme(current, undefined, stamp)]);
|
|
83
|
+
const unsubscribe = () => [...messageStops, streamErrorStop, deletedStop, ...detailStops, themeStop].forEach((end) => end());
|
|
84
|
+
if (active && stamp === generation.current) stop = unsubscribe; else unsubscribe();
|
|
85
|
+
}).catch((reason) => active && setError(readableError(reason, "ChatPlay SDK 未就绪")));
|
|
86
|
+
} catch (reason) { setError(readableError(reason, "ChatPlay SDK 不可用")); }
|
|
87
|
+
return () => { active = false; generation.current += 1; stop(); };
|
|
88
|
+
}, [refreshDetails, refreshMessages, refreshTheme]);
|
|
89
|
+
useEffect(() => { const storyDrawer = window.matchMedia("(max-width: 880px)"); const detailsDrawer = window.matchMedia("(max-width: 1080px)"); const activeOverlay = () => mobilePanel === "story" && storyDrawer.matches ? storyRef.current : mobilePanel === "details" && detailsDrawer.matches ? detailsRef.current : null; const main = mainRef.current; const syncDrawer = () => { const overlay = activeOverlay(); if (main) { if (overlay) main.setAttribute("inert", ""); else main.removeAttribute("inert"); } if (overlay) overlay.querySelector<HTMLElement>(".chat-overlay-close")?.focus(); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && activeOverlay()) setMobilePanel("chat"); }; syncDrawer(); storyDrawer.addEventListener("change", syncDrawer); detailsDrawer.addEventListener("change", syncDrawer); window.addEventListener("keydown", onKeyDown); return () => { storyDrawer.removeEventListener("change", syncDrawer); detailsDrawer.removeEventListener("change", syncDrawer); window.removeEventListener("keydown", onKeyDown); main?.removeAttribute("inert"); }; }, [mobilePanel]);
|
|
90
|
+
|
|
91
|
+
const loadEarlier = useCallback(async () => {
|
|
92
|
+
if (!bridge || messages.length === 0) return;
|
|
93
|
+
try { const earlier = await loadMessages(bridge, messages[0].id); setMessages((current) => mergeMessages(current, earlier.filter((message) => !deletedIds.current.has(message.id)))); setHasEarlier(earlier.length >= 80); } catch (reason) { setError(readableError(reason, "读取更早消息失败")); }
|
|
94
|
+
}, [bridge, messages]);
|
|
95
|
+
|
|
96
|
+
const submit = useCallback(async () => {
|
|
97
|
+
if (!bridge || sending || sendInFlight.current || !draft.trim()) return;
|
|
98
|
+
sendInFlight.current = true; setSending(true);
|
|
99
|
+
try { await sendMessage(bridge, draft); setDraft(""); await refreshMessages(bridge, false); } catch (reason) { if (isPreviewModelUnavailable(reason)) { setError(null); setActionNotice("视图桥已连接;当前为界面预览,未连接模型。"); } else { setActionNotice(null); setError(publicError(reason, "发送失败,草稿已保留") ?? "发送失败,草稿已保留"); } } finally { sendInFlight.current = false; setSending(false); }
|
|
100
|
+
}, [bridge, draft, refreshMessages, sending]);
|
|
101
|
+
|
|
102
|
+
const resolveAsset = useCallback(async (asset: Asset) => {
|
|
103
|
+
if (!bridge || assetUrls[asset.id]) return;
|
|
104
|
+
try { const url = await bridge.getAssetUrl(asset.id); if (!/^(blob:|https?:)/.test(url)) throw new Error("宿主返回了不受信任的资源地址"); setAssetUrls((current) => ({ ...current, [asset.id]: url })); } catch (reason) { setError(readableError(reason, "资源预览不可用")); }
|
|
105
|
+
}, [assetUrls, bridge]);
|
|
106
|
+
|
|
107
|
+
const onSubmit = (event: FormEvent) => { event.preventDefault(); void submit(); };
|
|
108
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => { if (event.key === "Enter" && !event.shiftKey && !composing) { event.preventDefault(); void submit(); } };
|
|
109
|
+
const runAction = (action: (typeof viewConfig.actions)[number]) => { if (!bridge) return; try { emitConfiguredAction(bridge, action.eventType, action.payload, crypto.randomUUID()); setError(null); setActionNotice("互动请求已提交,等待故事结果。"); } catch (reason) { setActionNotice(null); setError(readableError(reason, "互动暂不可用")); } };
|
|
110
|
+
const canRetry = error !== null && !error.startsWith("请先在作者工具登录");
|
|
111
|
+
return <div className="chat-app" data-theme={theme}>
|
|
112
|
+
<aside ref={storyRef} role="dialog" aria-modal="true" aria-label="故事信息" className={"chat-sidebar" + (mobilePanel === "story" ? " is-open" : "")}><button className="chat-icon-button chat-overlay-close" type="button" aria-label="关闭故事" onClick={() => setMobilePanel("chat")}>{icon("back")}</button><div className="chat-brand">WorldEngine</div><div className="chat-scene-title">{title}</div>{progress !== null && <div className="chat-progress"><span style={{ width: (progress * 100) + "%" }} />{Math.round(progress * 100)}%</div>}<section className="chat-panel chat-status"><h2>故事状态</h2><p>{bridge ? "视图已连接" : "正在连接…"}</p>{unavailable.map((notice) => <p key={notice}>{notice}</p>)}</section>{resources !== null && resources.length > 0 && <section className="chat-panel chat-resources"><h2>已解锁</h2><ul className="chat-list">{resources.flatMap((group) => group.resources).map((resource) => <li className="chat-list-item" key={resource.uri}>{resource.type}</li>)}</ul></section>}{stateEnabled && <section className="chat-panel"><h2>状态</h2>{stateError ? <p>{stateError}</p> : stateEntries.map((entry) => <p key={entry.key}>{entry.key}:{String(entry.value)}</p>)}</section>}{viewConfig.actions.map((action) => <button key={action.eventType} className="chat-button" type="button" onClick={() => runAction(action)}>{action.label}</button>)}</aside>
|
|
113
|
+
<main ref={mainRef} className="chat-main"><header className="chat-header"><button className="chat-icon-button" type="button" aria-label="故事信息" onClick={() => setMobilePanel("story")}>{icon("menu")}</button><div className="chat-header-copy"><h1>{title}</h1><p>{bridge ? "当前存档" : "连接中"}</p></div><button className="chat-icon-button" type="button" aria-label="详情" onClick={() => setMobilePanel("details")}>{icon("info")}</button></header>
|
|
114
|
+
{error && <div className="chat-error" role="alert">{error}{canRetry && <button className="chat-retry" type="button" onClick={() => bridge && void refreshMessages(bridge)}>刷新消息</button>}</div>}{actionNotice && <p className="chat-status" role="status">{actionNotice}</p>}
|
|
115
|
+
<section className="chat-transcript" ref={transcript} aria-label="对话记录">{hasEarlier && <button className="chat-button chat-retry" type="button" onClick={() => void loadEarlier()}>读取更早消息</button>}{messages.length === 0 ? <div className="chat-empty"><svg className="chat-empty-illustration" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5h14v10H9l-4 4V5Z" /></svg><h2>对话从这里开始</h2><p>写下你的第一句话,故事会按当前剧本继续。</p></div> : messages.map((message) => <article key={message.id} className="chat-message" data-role={message.role}><p className="chat-message-body">{message.text}</p></article>)}</section>
|
|
116
|
+
<form className="chat-composer" onSubmit={onSubmit}><textarea className="chat-input" value={draft} onChange={(event) => setDraft(event.target.value)} onCompositionStart={() => setComposing(true)} onCompositionEnd={() => setComposing(false)} onKeyDown={onKeyDown} placeholder="写下你的回复…" aria-label="发送消息" rows={1} disabled={!bridge || sending} /><button className="chat-button" type="button" aria-label="发送消息" onClick={() => { void submit(); }} disabled={!bridge || sending || !draft.trim()}>{sending ? "发送中" : icon("send")}</button></form>
|
|
117
|
+
</main>
|
|
118
|
+
<aside ref={detailsRef} role="dialog" aria-modal="true" aria-label="详情" className={"chat-details" + (mobilePanel === "details" ? " is-open" : "")}><button className="chat-icon-button chat-overlay-close" type="button" aria-label="关闭详情" onClick={() => setMobilePanel("chat")}>{icon("back")}</button>{assetsEnabled && <section className="chat-panel"><h2>资源</h2>{assets === null ? <p>资源暂不可用</p> : assets.length ? <ul>{assets.map((asset) => <li className="chat-resource-item" key={asset.id}>{isPreviewable(asset) && <button className="chat-icon-button" type="button" aria-label={"预览 " + asset.filename} onClick={() => void resolveAsset(asset)}>预览</button>}<span>{asset.filename}</span>{assetUrls[asset.id] && (asset.mime.startsWith("image/") ? <img className="chat-asset-preview" src={assetUrls[asset.id]} alt={asset.filename} /> : asset.mime.startsWith("audio/") ? <audio className="chat-asset-preview" controls src={assetUrls[asset.id]} /> : <video className="chat-asset-preview" controls src={assetUrls[asset.id]} />)}</li>)}</ul> : <p className="chat-empty">当前没有可展示资源</p>}</section>}{stateEnabled && <section className="chat-panel"><h2>状态</h2>{stateError ? <p>{stateError}</p> : stateEntries.length ? <ul>{stateEntries.map((entry) => <li key={entry.key}><strong>{entry.key}</strong><span>{typeof entry.value === "string" || typeof entry.value === "number" || typeof entry.value === "boolean" ? String(entry.value) : entry.value === null ? "null" : "已读取"}</span></li>)}</ul> : <p className="chat-empty">没有可读状态</p>}</section>}<details className="chat-panel"><summary>运行信息</summary><p>{bridge ? "Runtime " + bridge.runtimeVersion().major + "." + bridge.runtimeVersion().patch : "不可用"}</p></details></aside>
|
|
119
|
+
<nav className="chat-mobile-nav" aria-label="视图导航">{(["story", "chat", "details"] as const).map((panel) => <button key={panel} className={"chat-mobile-nav-item" + (mobilePanel === panel ? " is-active" : "")} type="button" onClick={() => setMobilePanel(panel)}>{panel === "story" ? "故事" : panel === "chat" ? "对话" : "详情"}</button>)}</nav>
|
|
120
|
+
</div>;
|
|
121
|
+
}
|
|
122
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DEFAULT_CHAT_BRIDGE: string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export const DEFAULT_CHAT_BRIDGE = String.raw `import type { ChatPlayApi } from "@world-engines/view-sdk";
|
|
2
|
+
|
|
3
|
+
export type ChatMessage = Awaited<ReturnType<ChatPlayApi["listMessages"]>>[number];
|
|
4
|
+
export type ThemeSnapshot = Awaited<ReturnType<ChatPlayApi["getTheme"]>>;
|
|
5
|
+
export type ChatPlayConversationBridge = Pick<ChatPlayApi, "ready" | "context" | "listMessages" | "sendUserMessage" | "listAssets" | "getAssetUrl" | "loadCustomState" | "listCustomStateKeys" | "customState" | "events" | "getScenarioMeta" | "getProgress" | "getVariable" | "getUnlock" | "getOwnedResources" | "getLocale" | "getTheme" | "runtimeVersion" | "worldline" | "openModelSettings" | "on">;
|
|
6
|
+
|
|
7
|
+
export function readableError(reason: unknown, fallback: string): string {
|
|
8
|
+
return typeof reason === "string" ? reason : reason instanceof Error && reason.message ? reason.message : fallback;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function errorCode(reason: unknown): string {
|
|
12
|
+
const direct = reason && typeof reason === "object" && "code" in reason ? (reason as { code?: unknown }).code : undefined;
|
|
13
|
+
if (typeof direct === "string" && direct) return direct.toUpperCase();
|
|
14
|
+
const match = readableError(reason, "").match(/\b(?:E_[A-Z0-9_]+|E[A-Z][A-Z0-9_]*|UNAUTHENTICATED|UNAUTHORIZED)\b/iu);
|
|
15
|
+
return match ? match[0].toUpperCase() : "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function publicError(reason: unknown, fallback: string): string | null {
|
|
19
|
+
const code = errorCode(reason);
|
|
20
|
+
if (/^(E_CAPABILITY_UNAVAILABLE|ENOTSUP)$/u.test(code)) return null;
|
|
21
|
+
if (code === "E_PREVIEW_MODEL_UNAVAILABLE") return "当前预览暂未接入模型,暂时无法继续对话。";
|
|
22
|
+
if (/^(E_AUTH_REQUIRED|EAUTH|ELOGIN|UNAUTHENTICATED|UNAUTHORIZED)$/u.test(code)) return "请先登录后重试。";
|
|
23
|
+
if (code === "EPERM") return "当前游戏未开放此功能。";
|
|
24
|
+
if (code === "EINVAL") return "请求内容无效,请重新操作。";
|
|
25
|
+
if (/^(MODEL_DOWN|MODEL_UNAVAILABLE|PROVIDER_UNAVAILABLE|E_MODEL_DOWN|E_MODEL_UNAVAILABLE|E_PROVIDER_UNAVAILABLE)$/u.test(code)) return "模型服务暂不可用,请稍后再试。";
|
|
26
|
+
if (/^(ETIMEDOUT|ETIMEOUT|E_TIMEOUT|ECONNRESET|ECONNREFUSED|ENETUNREACH|E_NETWORK)$/u.test(code)) return "连接暂时不可用,请稍后再试。";
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isPreviewModelUnavailable(reason: unknown): boolean {
|
|
31
|
+
return Boolean(reason && typeof reason === "object" && "code" in reason && (reason as { code?: unknown }).code === "E_PREVIEW_MODEL_UNAVAILABLE") || /\bE_PREVIEW_MODEL_UNAVAILABLE\b/u.test(readableError(reason, ""));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function requireChatPlay(): ChatPlayConversationBridge {
|
|
35
|
+
if (window.ChatPlay === undefined) throw new Error("ChatPlay SDK 未由宿主注入");
|
|
36
|
+
return window.ChatPlay;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function initializeBridge(bridge: ChatPlayConversationBridge): Promise<void> {
|
|
40
|
+
await bridge.ready;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadMessages(bridge: ChatPlayConversationBridge, before?: string): Promise<readonly ChatMessage[]> {
|
|
44
|
+
await initializeBridge(bridge);
|
|
45
|
+
const messages = await bridge.listMessages(before === undefined ? { limit: 80 } : { before, limit: 80 });
|
|
46
|
+
return [...messages].sort((left, right) => left.ts - right.ts || left.id.localeCompare(right.id));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function mergeMessages(current: readonly ChatMessage[], incoming: readonly ChatMessage[]): readonly ChatMessage[] {
|
|
50
|
+
return [...new Map([...current, ...incoming].map((message) => [message.id, message])).values()]
|
|
51
|
+
.sort((left, right) => left.ts - right.ts || left.id.localeCompare(right.id));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MessageSnapshot {
|
|
55
|
+
readonly request: number;
|
|
56
|
+
readonly deletedIds: ReadonlySet<string>;
|
|
57
|
+
readonly messages: readonly ChatMessage[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function applyMessagePage(previous: MessageSnapshot, request: number, incoming: readonly ChatMessage[]): MessageSnapshot {
|
|
61
|
+
if (request < previous.request) return previous;
|
|
62
|
+
const messages = mergeMessages(previous.messages, incoming.filter((message) => !previous.deletedIds.has(message.id)));
|
|
63
|
+
return { ...previous, request, messages };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function applyMessageDeleted(previous: MessageSnapshot, id: string): MessageSnapshot {
|
|
67
|
+
const deletedIds = new Set(previous.deletedIds); deletedIds.add(id);
|
|
68
|
+
return { ...previous, deletedIds, messages: previous.messages.filter((message) => message.id !== id) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function messageIdFromEvent(payload: unknown): string | null {
|
|
72
|
+
if (!payload || typeof payload !== "object") return null;
|
|
73
|
+
const id = (payload as { id?: unknown; messageId?: unknown }).id ?? (payload as { messageId?: unknown }).messageId;
|
|
74
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function resolvedThemeFromEvent(payload: unknown): "light" | "dark" | null {
|
|
78
|
+
if (!payload || typeof payload !== "object") return null;
|
|
79
|
+
const resolved = (payload as { resolved?: unknown }).resolved;
|
|
80
|
+
return resolved === "light" || resolved === "dark" ? resolved : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function emitConfiguredAction(bridge: ChatPlayConversationBridge, eventType: string, payload: Readonly<Record<string, null | boolean | number | string>>, interactionId: string) {
|
|
84
|
+
return bridge.events.emit(eventType, payload, interactionId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function sendMessage(bridge: ChatPlayConversationBridge, text: string): Promise<void> {
|
|
88
|
+
const trimmed = text.trim();
|
|
89
|
+
if (!trimmed) return;
|
|
90
|
+
await initializeBridge(bridge);
|
|
91
|
+
await bridge.sendUserMessage(trimmed);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function readTheme(bridge: ChatPlayConversationBridge): Promise<ThemeSnapshot> {
|
|
95
|
+
await initializeBridge(bridge);
|
|
96
|
+
return bridge.getTheme();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function optionalRead<T>(work: () => Promise<T>): Promise<{ value?: T; error?: string }> {
|
|
100
|
+
try { return { value: await work() }; }
|
|
101
|
+
catch (reason) { return { error: readableError(reason, "此信息暂不可用") }; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function subscribeToChanges(bridge: ChatPlayConversationBridge, refresh: () => void, refreshTheme: () => void): Promise<() => void> {
|
|
105
|
+
await initializeBridge(bridge);
|
|
106
|
+
const subscribe = bridge.on as unknown as (event: string, listener: () => void) => () => void;
|
|
107
|
+
const stops = ["messageAdded", "messageUpdated", "messageDeleted", "streamStart", "streamDelta", "streamDone", "streamError", "resourceUnlocked", "stateChanged", "tmwDelivery"]
|
|
108
|
+
.map((event) => subscribe(event, refresh));
|
|
109
|
+
stops.push(bridge.on("themeChanged", refreshTheme), bridge.on("localeChanged", refresh));
|
|
110
|
+
return () => stops.forEach((stop) => stop());
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DEFAULT_CHAT_VIEW_CONFIG: string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const DEFAULT_CHAT_VIEW_CONFIG = String.raw `/** 只有明确列出的数据才会读取;actions 必须在当前作者 manifest 声明。
|
|
2
|
+
* 可选、可执行的互动/状态示范在 examples/interaction-demo/,默认空白项目不自动启用。 */
|
|
3
|
+
export const viewConfig = {
|
|
4
|
+
variables: [] as readonly { name: string; label: string }[],
|
|
5
|
+
unlocks: [] as readonly { target: "node" | "edge"; id: string; label: string }[],
|
|
6
|
+
actions: [] as readonly { eventType: string; label: string; payload: Readonly<Record<string, null | boolean | number | string>> }[],
|
|
7
|
+
};
|
|
8
|
+
`;
|