From f37388761ba37a852b5502ab803f4f5d1a1a5416 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Tue, 1 Sep 2026 00:52:05 +0800 Subject: [PATCH] Keep creation as a living DAG and archive playable products separately. Opening accept no longer locks the graph; session.json stays creation-only while play starts from an instance snapshot. Sidebar products nest run saves, and play reading fills the feed at a denser type size. Co-authored-by: Cursor --- src/runtime/phase-machine.ts | 28 +-- src/runtime/phase-runtime.ts | 70 +++++-- src/server/agent-view.ts | 4 +- src/server/book-handlers.ts | 40 +++- src/server/session-manager.ts | 305 +++++++++++++++++------------ src/server/skill-catalog.ts | 2 +- src/skills/creation-flow.ts | 18 +- src/skills/opening-seal.ts | 21 +- src/skills/play-turn.ts | 2 + src/skills/worker-declaration.ts | 16 +- src/types/run-snapshot.ts | 5 + tests/creation-flow.test.ts | 18 ++ tests/opening-seal.test.ts | 7 + tests/phase-machine.test.ts | 34 ++-- tests/phase-runtime.test.ts | 39 ++++ tests/run-snapshot.test.ts | 37 +++- tests/worker-declaration.test.ts | 12 ++ web/agent-ui.js | 78 ++++++-- web/app.js | 316 +++++++++++++++++++++++++------ web/index.html | 6 +- web/present-shells.js | 40 ++-- web/styles.css | 135 +++++++++++-- 22 files changed, 930 insertions(+), 303 deletions(-) diff --git a/src/runtime/phase-machine.ts b/src/runtime/phase-machine.ts index fe4d00f..bceb005 100644 --- a/src/runtime/phase-machine.ts +++ b/src/runtime/phase-machine.ts @@ -31,7 +31,6 @@ import { normalizeQuestions, } from "../skills/question-protocol.js"; import { - CREATION_SEALED_WAITING_MESSAGE, SLOT_CREATION_SEALED_BY_OPENING, isOpeningSealArtifact, } from "../skills/opening-seal.js"; @@ -785,21 +784,6 @@ export function applyEvent( }; } - if (session.slots[SLOT_CREATION_SEALED_BY_OPENING]) { - return { - session: waiting(touch({ ...next, resumeContext: undefined }), { - kind: "input", - message: CREATION_SEALED_WAITING_MESSAGE, - }), - effects: [ - { - type: "emit_message", - message: "创作已收口并保存。请切换到「游玩」开始。", - }, - ], - }; - } - // 常规定稿用户输入 → 等总管下一步 return { session: touch(running({ ...next, resumeContext: undefined })), @@ -1178,14 +1162,14 @@ export function applyEvent( pendingDecision: undefined, currentWorkerId: undefined, }; - // 开场白终节点:选定后落库、关 DAG,不再问「下一步想写什么」。 + // 开场白终节点:落库开场,再回到分层图。创作流程保留,产物靠「保存」拆出。 if (isOpeningSealArtifact(artifact)) { return { - session: waiting(cleared, { - kind: "input", - message: CREATION_SEALED_WAITING_MESSAGE, - }), - effects: [{ type: "seal_creation_opening" }], + session: touch(running(cleared)), + effects: [ + { type: "seal_creation_opening" }, + { type: "propose_next_creation_step" }, + ], }; } if (inferLifecycleStage(session) === "play") { diff --git a/src/runtime/phase-runtime.ts b/src/runtime/phase-runtime.ts index c16c80d..a2310c4 100644 --- a/src/runtime/phase-runtime.ts +++ b/src/runtime/phase-runtime.ts @@ -85,6 +85,7 @@ import { isInstanceStep, findStepByRef, findModuleByName, + hasAcceptedCloserStep, parseCreationFlow, DESIGN_FLOW_WORKER_ID, DESIGN_STEP_WORKER_ID, @@ -114,9 +115,8 @@ import { SLOT_ASKED_QUESTIONS, } from "../skills/question-protocol.js"; import { parseWorkerSetYaml } from "../skills/worker-set-parse.js"; +import { defaultPlaySlots } from "../skills/play-slots.js"; import { - closeCreationFlowRaw, - CREATION_SEALED_WAITING_MESSAGE, mergeOpeningTablePatch, OPENING_CURRENT_VARS_TAG, OPENING_INITIAL_VARS_TAG, @@ -427,6 +427,23 @@ export class PhaseRuntime { }; } + /** 收口已验收但未落印时补封印,并保证有可上场的运行规格 */ + prepareEnterPlay(): void { + if ( + this.hasAcceptedCloser() && + !this.session.slots[SLOT_CREATION_SEALED_BY_OPENING] + ) { + this.sealCreationOpening(); + return; + } + if ( + this.session.slots[SLOT_CREATION_SEALED_BY_OPENING] || + this.session.slots.designInstanceReady + ) { + this.ensurePlaySpecReady(); + } + } + getAvailableSkills(): SkillIndexEntry[] { return this.availableSkills; } @@ -1402,10 +1419,6 @@ export class PhaseRuntime { * 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。 */ private async awaitCreationStepPick(): Promise { - if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) { - this.onMessage(CREATION_SEALED_WAITING_MESSAGE); - return; - } this.syncSlotsToBlackboard(this.session); const flow = parseCreationFlow( this.blackboard.getContentByTag(CREATION_FLOW_TAG), @@ -1474,7 +1487,7 @@ export class PhaseRuntime { } /** - * 选定开场白后:把候选落成 输出.开场白 / 初值表,关闭 DAG。 + * 选定开场白后:把候选落成 输出.开场白 / 初值表。不关 DAG,创作流程继续。 */ private sealCreationOpening(): void { const selectedRaw = this.session.slots[SLOT_OPENING_SELECTED_INDEX]; @@ -1515,17 +1528,6 @@ export class PhaseRuntime { } } - const closed = closeCreationFlowRaw( - this.blackboard.getContentByTag(CREATION_FLOW_TAG), - ); - if (closed) { - this.blackboard.write({ - tag: CREATION_FLOW_TAG, - content: closed, - source: "runtime", - }); - } - const n = payload?.candidates.length ?? (openingText ? 1 : 0); const which = n > 1 && payload @@ -1534,7 +1536,7 @@ export class PhaseRuntime { ? "开场白" : "开场"; this.onMessage( - `[创作收口] 已选定${which},工作流计划已关闭。可切换到「游玩」。`, + `[创作] 已选定${which}。可保存为产物后开玩,或继续补节点。`, ); this.session = { @@ -1542,10 +1544,37 @@ export class PhaseRuntime { slots: { ...this.session.slots, [SLOT_CREATION_SEALED_BY_OPENING]: true, - [CREATION_FLOW_TAG]: closed ?? this.session.slots[CREATION_FLOW_TAG], [OPENING_OUTPUT_TAG]: openingText || this.session.slots[OPENING_OUTPUT_TAG], }, }; + this.ensurePlaySpecReady(); + } + + private hasAcceptedCloser(): boolean { + const flow = parseCreationFlow( + this.blackboard.getContentByTag(CREATION_FLOW_TAG), + ); + return hasAcceptedCloserStep(flow, this.readAcceptedStepIds()); + } + + /** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */ + private ensurePlaySpecReady(): void { + const finalTag = this.blackboard.getContentByTag(WORKER_SET_FINAL_TAG)?.trim(); + const draft = this.blackboard.getContentByTag(WORKER_SET_DRAFT_TAG)?.trim(); + if (!finalTag) { + const spec = draft?.trim() + ? draft + : JSON.stringify({ play_slots: defaultPlaySlots() }); + this.writeWorkerTagContent(WORKER_SET_FINAL_TAG, spec, "opening-setup"); + } + if (this.session.slots.designInstanceReady) return; + this.session = { + ...this.session, + slots: { + ...this.session.slots, + designInstanceReady: true, + }, + }; } /** 再编排:解开开场收口,让 design-flow 能在收口前插入节点 */ @@ -2667,7 +2696,6 @@ export class PhaseRuntime { /** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */ private async maybeRunMainAgent(effects: PhaseEffect[]): Promise { if (inferLifecycleStage(this.session) === "play") return; - if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return; if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) { await this.runMainAgent(); } diff --git a/src/server/agent-view.ts b/src/server/agent-view.ts index 94be5d2..39434e1 100644 --- a/src/server/agent-view.ts +++ b/src/server/agent-view.ts @@ -400,8 +400,8 @@ export function buildFocus( actorType: "user", actorLabel: "你", action: - reason.message && /创作已收口/.test(reason.message) - ? "创作已收口" + reason.message && /创作已收口|可保存为产物/.test(reason.message) + ? "可保存产物" : "补充说明", detail: reason.message?.slice(0, 120), }; diff --git a/src/server/book-handlers.ts b/src/server/book-handlers.ts index 04008a1..c332197 100644 --- a/src/server/book-handlers.ts +++ b/src/server/book-handlers.ts @@ -395,7 +395,17 @@ export async function handleBooksApi( return true; } try { - const session = await sessionManager.startNewPlayRun(active.id); + let instanceId: string | undefined; + try { + const raw = await readBody(req); + if (raw.trim()) { + const body = JSON.parse(raw) as { instanceId?: string }; + instanceId = body.instanceId?.trim() || undefined; + } + } catch { + instanceId = undefined; + } + const session = await sessionManager.startNewPlayRun(active.id, instanceId); json(res, 200, { session }); } catch (err) { json(res, 400, { @@ -420,7 +430,11 @@ export async function handleBooksApi( ...s, kindLabel: s.kind === "instance" ? "创作定稿" : "游玩进度", })); - json(res, 200, { saves }); + json(res, 200, { + saves, + playWorkingInstanceId: + sessionManager.playWorkingInstanceId(bookId) ?? null, + }); } catch (err) { json(res, 400, { error: err instanceof Error ? err.message : "读取存档失败", @@ -490,6 +504,28 @@ export async function handleBooksApi( return true; } + if ((req.method === "PATCH" || req.method === "PUT") && !isLoad) { + const body = JSON.parse(await readBody(req)) as { label?: string }; + try { + const save = sessionManager.renameGameSnapshot( + bookId, + saveId, + body.label ?? "", + ); + json(res, 200, { + save: { + ...save, + kindLabel: save.kind === "instance" ? "创作定稿" : "游玩进度", + }, + }); + } catch (err) { + json(res, 400, { + error: err instanceof Error ? err.message : "重命名失败", + }); + } + return true; + } + if (req.method === "DELETE") { try { sessionManager.deleteGameSnapshot(bookId, saveId); diff --git a/src/server/session-manager.ts b/src/server/session-manager.ts index 4fd6cae..35d044c 100644 --- a/src/server/session-manager.ts +++ b/src/server/session-manager.ts @@ -11,14 +11,13 @@ import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-sn import { toRunSnapshotMeta } from "../types/run-snapshot.js"; import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js"; import { - CREATION_SEALED_WAITING_MESSAGE, INSTANCE_OPENING_SNAPSHOT_LABEL, OPENING_OUTPUT_TAG, - SLOT_CREATION_SEALED_BY_OPENING, } from "../skills/opening-seal.js"; import { PLAY_WORKING_SNAPSHOT_ID, PLAY_WORKING_SNAPSHOT_LABEL, + SLOT_PLAY_INSTANCE_ID, SLOT_PLAY_LAYER_ACTIVE, isPlayLayerActive, } from "../skills/play-turn.js"; @@ -209,6 +208,10 @@ export type SessionView = { /** 已从定稿开出聊天层(不是只把顶栏标成游玩) */ playLayerActive: boolean; playReady: boolean; + /** 已落档至少一份产物(instance 快照) */ + hasProduct: boolean; + /** 当前游玩所依据的产物 id */ + playInstanceId?: string; skillCatalog: SkillCatalogEntry[]; toolTrace: ToolTraceEntry[]; burst: BurstState; @@ -371,11 +374,11 @@ export class SessionManager { setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView { const s = this.require(sessionId); - const session = s.runtime.getSession(); - if (stage === "play" && !canEnterPlay(session)) { - throw new Error("实例尚未就绪,无法进入游玩"); - } if (stage === "play") { + s.runtime.prepareEnterPlay(); + if (!canEnterPlay(s.runtime.getSession())) { + throw new Error("实例尚未就绪,无法进入游玩"); + } this.enterPlayLayer(s, { resume: true }); } else { this.enterDesignLayer(s); @@ -609,8 +612,11 @@ export class SessionManager { let blackboardItems = s.runtime.getBlackboard().exportItems(); if (kind === "instance") { - if (!canEnterPlay(runtimeSession) && !runtimeSession.slots.startupCompleted) { - throw new Error("须先验收 Worker 集,才能保存创作定稿"); + s.runtime.prepareEnterPlay(); + runtimeSession = structuredClone(s.runtime.getSession()); + blackboardItems = s.runtime.getBlackboard().exportItems(); + if (!canEnterPlay(runtimeSession)) { + throw new Error("须先完成收口或验收 Worker 集,才能保存产物"); } ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ runtimeSession, @@ -626,6 +632,8 @@ export class SessionManager { bookId: s.bookId, label: trimmed, kind, + instanceId: + kind === "run" ? this.currentPlayInstanceId(s) : undefined, orchestratorId: skillPackId, runtimeSession, blackboardItems, @@ -665,25 +673,30 @@ export class SessionManager { return this.saveGameSnapshot(sessionId, label, "run", note); } - /** 从已验收实例开一条新的游玩线(清空 run 层状态) */ - async startNewPlayRun(sessionId: string): Promise { + /** 从已落档产物开一条新的游玩线(清空 run 层状态) */ + async startNewPlayRun( + sessionId: string, + instanceId?: string, + ): Promise { const s = this.require(sessionId); if (!s.bookId) throw new Error("仅绑定作品时可新建游玩"); - const session = s.runtime.getSession(); - if (!canEnterPlay(session)) { - throw new Error("须先验收 Worker 集,才能开始游玩"); + this.persistCreationIfDesign(s); + s.runtime.prepareEnterPlay(); + if (!canEnterPlay(s.runtime.getSession())) { + throw new Error("须先验收 Worker 集或完成收口,才能开始游玩"); } this.archivePlayWorking(s.bookId); - this.enterPlayLayer(s, { resume: false }); - this.persist(s); + this.enterPlayLayer(s, { resume: false, instanceId }); return this.toView(sessionId); } - /** 进入游玩层:resume 时恢复「当前游玩」,否则从定稿新开一条聊天 */ + /** 进入游玩层:resume 时恢复「当前游玩」,否则从产物新开一条聊天 */ private enterPlayLayer( s: ManagedSession, - opts: { resume: boolean }, + opts: { resume: boolean; instanceId?: string }, ): void { + this.persistCreationIfDesign(s); + s.runtime.prepareEnterPlay(); const session = s.runtime.getSession(); if (!canEnterPlay(session)) { throw new Error("实例尚未就绪,无法进入游玩"); @@ -704,11 +717,11 @@ export class SessionManager { return; } } - this.beginNewPlayFromInstance(s); + this.beginNewPlayFromInstance(s, opts.instanceId); if (s.bookId) this.savePlayWorking(s); } - /** 回到创作层:先把当前聊天写入「当前游玩」,再加载定稿 */ + /** 回到创作层:先把当前聊天写入「当前游玩」,再恢复创作过程 */ private enterDesignLayer(s: ManagedSession): void { const session = s.runtime.getSession(); if ( @@ -721,33 +734,18 @@ export class SessionManager { if (s.bookId && isPlayLayerActive(session.slots)) { this.savePlayWorking(s); } - const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null; - if (instance) { - this.restoreSnapshotInPlace(s, instance, "design"); - return; - } - let runtimeSession = structuredClone(s.runtime.getSession()); - let blackboardItems = s.runtime.getBlackboard().exportItems(); - ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ - runtimeSession, - blackboardItems, - })); - runtimeSession.id = session.id; - runtimeSession.slots = { - ...runtimeSession.slots, - uiLifecycleStage: "design", - [SLOT_PLAY_LAYER_ACTIVE]: undefined, - }; - runtimeSession.phase = "waiting_user"; - runtimeSession.waitingReason = { kind: "input" }; - s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems); + this.restoreCreationFromDisk(s); } - private beginNewPlayFromInstance(s: ManagedSession): void { + private beginNewPlayFromInstance(s: ManagedSession, instanceId?: string): void { const keepId = s.runtime.getSession().id; let runtimeSession: RuntimeSession; let blackboardItems: import("../types/blackboard.js").BlackboardItem[]; - const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null; + const instance = s.bookId + ? instanceId + ? this.loadInstanceSnapshot(s.bookId, instanceId) + : this.latestInstanceSnapshot(s.bookId) + : null; if (instance) { runtimeSession = structuredClone(instance.runtimeSession); blackboardItems = structuredClone(instance.blackboardItems); @@ -764,6 +762,7 @@ export class SessionManager { ...runtimeSession.slots, uiLifecycleStage: "play", [SLOT_PLAY_LAYER_ACTIVE]: true, + [SLOT_PLAY_INSTANCE_ID]: instance?.id, startupCompleted: true, }; runtimeSession.phase = "waiting_user"; @@ -807,6 +806,13 @@ export class SessionManager { ...runtimeSession.slots, uiLifecycleStage: layer, [SLOT_PLAY_LAYER_ACTIVE]: layer === "play" ? true : undefined, + [SLOT_PLAY_INSTANCE_ID]: + layer === "play" + ? snapshot.instanceId || + (typeof runtimeSession.slots[SLOT_PLAY_INSTANCE_ID] === "string" + ? runtimeSession.slots[SLOT_PLAY_INSTANCE_ID] + : undefined) + : undefined, startupCompleted: true, }; if (layer === "play") { @@ -817,12 +823,12 @@ export class SessionManager { runtimeSession.waitingReason = { kind: "input" }; } } else { - const sealed = Boolean(runtimeSession.slots[SLOT_CREATION_SEALED_BY_OPENING]); - runtimeSession.phase = "waiting_user"; - runtimeSession.waitingReason = { - kind: "input", - ...(sealed ? { message: CREATION_SEALED_WAITING_MESSAGE } : {}), - }; + if (runtimeSession.phase === "done") { + runtimeSession.phase = "waiting_user"; + } + if (!runtimeSession.waitingReason) { + runtimeSession.waitingReason = { kind: "input" }; + } runtimeSession.pendingArtifactId = undefined; runtimeSession.pendingDecision = undefined; runtimeSession.currentWorkerId = undefined; @@ -868,6 +874,7 @@ export class SessionManager { bookId: s.bookId, label: PLAY_WORKING_SNAPSHOT_LABEL, kind: "run", + instanceId: this.currentPlayInstanceId(s), orchestratorId: sessionSkillPackId(runtimeSession) ?? (book ? bookSkillPackId(book) : "") ?? @@ -909,19 +916,91 @@ export class SessionManager { s.runtime.getSession().id, INSTANCE_OPENING_SNAPSHOT_LABEL, "instance", - "进入游玩前自动保存定稿", + "开玩前自动保存产物", ); } catch (err) { - console.warn("[会话] 进入游玩前保存定稿失败", err); + console.warn("[会话] 开玩前保存产物失败", err); } } + private currentPlayInstanceId(s: ManagedSession): string | undefined { + const raw = s.runtime.getSession().slots[SLOT_PLAY_INSTANCE_ID]; + if (typeof raw === "string" && raw.trim()) return raw.trim(); + if (!s.bookId) return undefined; + return this.latestInstanceSnapshot(s.bookId)?.id; + } + + playWorkingInstanceId(bookId: string): string | undefined { + const working = loadRunSnapshotFile(bookId, PLAY_WORKING_SNAPSHOT_ID); + if (!working || working.kind !== "run") return undefined; + return working.instanceId; + } + + renameGameSnapshot( + bookId: string, + snapshotId: string, + label: string, + ): RunSnapshotMeta { + if (!getBook(bookId)) throw new Error("Book 不存在"); + const trimmed = label.trim(); + if (!trimmed) throw new Error("名称不能为空"); + const snapshot = loadRunSnapshotFile(bookId, snapshotId); + if (!snapshot) throw new Error("存档不存在"); + const next: RunSnapshot = { ...snapshot, label: trimmed }; + saveRunSnapshotFile(next); + return toRunSnapshotMeta(next); + } + private latestInstanceSnapshot(bookId: string): RunSnapshot | null { const meta = this.listGameSnapshots(bookId).find((s) => s.kind === "instance"); if (!meta) return null; return loadRunSnapshotFile(bookId, meta.id); } + private loadInstanceSnapshot(bookId: string, snapshotId: string): RunSnapshot { + const snapshot = loadRunSnapshotFile(bookId, snapshotId); + if (!snapshot) throw new Error("产物不存在"); + if (snapshot.kind !== "instance") throw new Error("该存档不是产物定稿"); + return snapshot; + } + + private restoreCreationFromDisk(s: ManagedSession): void { + const keepId = s.runtime.getSession().id; + if (!s.bookId) { + this.clearPlayLayerOnSession(s, keepId); + return; + } + const snap = loadBookSession(s.bookId); + if (!snap?.runtimeSession) { + this.clearPlayLayerOnSession(s, keepId); + return; + } + const runtimeSession = structuredClone(snap.runtimeSession); + runtimeSession.id = keepId; + runtimeSession.slots = { + ...runtimeSession.slots, + uiLifecycleStage: "design", + [SLOT_PLAY_LAYER_ACTIVE]: undefined, + }; + s.runtime.restoreFromCheckpoint(runtimeSession, snap.blackboardItems); + s.messages = snap.messages.map((m) => ({ ...m })) as ChatMessage[]; + s.branchState = this.deserializeBranchState(snap.messageBranchState); + } + + private clearPlayLayerOnSession(s: ManagedSession, keepId: string): void { + const runtimeSession = structuredClone(s.runtime.getSession()); + runtimeSession.id = keepId; + runtimeSession.slots = { + ...runtimeSession.slots, + uiLifecycleStage: "design", + [SLOT_PLAY_LAYER_ACTIVE]: undefined, + }; + s.runtime.restoreFromCheckpoint( + runtimeSession, + s.runtime.getBlackboard().exportItems(), + ); + } + private playOpeningMessage(text: string): ChatMessage { return { id: randomUUID(), @@ -935,7 +1014,7 @@ export class SessionManager { }; } - /** 从存档读档:替换当前作品进度,可继续创作 */ + /** 读档:产物开玩,游玩存档续玩。不覆盖创作过程。 */ async loadGameSnapshot(bookId: string, snapshotId: string): Promise { const snapshot = loadRunSnapshotFile(bookId, snapshotId); if (!snapshot) throw new Error("存档不存在"); @@ -946,47 +1025,36 @@ export class SessionManager { throw new Error("存档与当前作品 skill 包不匹配,无法读档"); } - this.dropBookSessions(bookId); - - const newSessionId = randomUUID(); - const messages: ChatMessage[] = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[]; - let runtimeSession = structuredClone(snapshot.runtimeSession); - let blackboardItems = snapshot.blackboardItems; + let sessionId = this.activeBookSessions.get(bookId); + if (!sessionId) { + await this.openBook(bookId); + sessionId = this.activeBookSessions.get(bookId); + } + if (!sessionId) throw new Error("无法打开作品"); + const s = this.require(sessionId); if (snapshot.kind === "instance") { - ({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({ - runtimeSession, - blackboardItems, - })); - runtimeSession.slots = { - ...runtimeSession.slots, - uiLifecycleStage: "design", - [SLOT_PLAY_LAYER_ACTIVE]: undefined, - }; - } else { - runtimeSession.slots = { - ...runtimeSession.slots, - uiLifecycleStage: "play", - [SLOT_PLAY_LAYER_ACTIVE]: true, - }; + this.persistCreationIfDesign(s); + if (isPlayLayerActive(s.runtime.getSession().slots)) { + this.archivePlayWorking(bookId); + } + this.beginNewPlayFromInstance(s, snapshot.id); + this.savePlayWorking(s); + return this.toView( + sessionId, + true, + `已用产物「${snapshot.label}」开玩。创作流程仍在「创作」里。`, + ); } - runtimeSession.id = newSessionId; - - const resumeHint = - snapshot.kind === "instance" - ? `已加载实例「${snapshot.label}」。可开始运行,或调整角色设定后再模拟。` - : `已从存档「${snapshot.label}」读档,可继续创作。`; - - return this.mountRestoredSession({ - sessionId: newSessionId, - bookId, - skillPackId: runSnapshotSkillPackId(snapshot) ?? "", - runtimeSession, - blackboardItems, - messages, - resumeHint, - }); + this.persistCreationIfDesign(s); + this.restoreSnapshotInPlace(s, snapshot, "play"); + this.savePlayWorking(s); + return this.toView( + sessionId, + true, + `已从存档「${snapshot.label}」继续游玩。`, + ); } /** 删除单个存档 */ @@ -1438,9 +1506,6 @@ export class SessionManager { ? s.runtime.getSession().artifacts.find((a) => a.id === pendingId) : undefined; const stageBefore = inferLifecycleStage(s.runtime.getSession()); - const sealedBefore = Boolean( - s.runtime.getSession().slots[SLOT_CREATION_SEALED_BY_OPENING], - ); try { const owned = await this.runExclusive(id, s, () => s.runtime.acceptArtifact(undefined, opts), @@ -1468,8 +1533,6 @@ export class SessionManager { } } this.persist(s); - this.maybeSaveOpeningInstance(s, sealedBefore); - this.persist(s); return this.toView(id); } @@ -1862,43 +1925,32 @@ export class SessionManager { } } - /** 选定开场后正式保存一份 instance 定稿(可进游玩的规格+开局) */ - private maybeSaveOpeningInstance( - s: ManagedSession, - sealedBefore: boolean, - ): void { - if (sealedBefore) return; + /** 游玩中只更新工作副本,不覆盖创作过程 */ + private persist(s: ManagedSession): void { if (!s.bookId) return; + const book = getBook(s.bookId); + if (!book) return; const session = s.runtime.getSession(); - if (!session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return; - if (!canEnterPlay(session)) { - s.messages.push( - this.msg( - "system", - "[创作收口] 开场已选定,但 Worker 集尚未验收,未写入创作定稿存档。", - ), - ); + if ( + isPlayLayerActive(session.slots) || + inferLifecycleStage(session) === "play" + ) { + this.savePlayWorking(s); + this.syncBookSkill(s.bookId, session); + updateBook(s.bookId, { activeSessionId: session.id }); return; } - try { - const meta = this.saveGameSnapshot( - session.id, - INSTANCE_OPENING_SNAPSHOT_LABEL, - "instance", - "选定开场后自动保存", - ); - s.messages.push( - this.msg("system", `[创作收口] 已保存创作定稿「${meta.label}」。`), - ); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - s.messages.push( - this.msg("system", `[创作收口] 开场已选定,定稿存档未写入:${detail}`), - ); - } + this.writeBookSession(s); } - private persist(s: ManagedSession): void { + /** 切到游玩前把当前创作过程写入 session.json */ + private persistCreationIfDesign(s: ManagedSession): void { + if (!s.bookId) return; + if (isPlayLayerActive(s.runtime.getSession().slots)) return; + this.writeBookSession(s); + } + + private writeBookSession(s: ManagedSession): void { if (!s.bookId) return; const book = getBook(s.bookId); if (!book) return; @@ -2545,6 +2597,13 @@ export class SessionManager { lifecycleStage, playLayerActive: isPlayLayerActive(session.slots), playReady: canEnterPlay(session), + hasProduct: Boolean( + s.bookId && + this.listGameSnapshots(s.bookId).some((item) => item.kind === "instance"), + ), + playInstanceId: isPlayLayerActive(session.slots) + ? this.currentPlayInstanceId(s) + : undefined, skillCatalog, toolTrace: buildToolTrace(messages), burst: buildBurstState(messages, session), diff --git a/src/server/skill-catalog.ts b/src/server/skill-catalog.ts index abd5a52..3d91c8e 100644 --- a/src/server/skill-catalog.ts +++ b/src/server/skill-catalog.ts @@ -177,7 +177,7 @@ function buildWorldSimulatorCatalog( id: "declare-ready", stage: "design", label: "实例就绪", - purpose: "Worker 集已 accept 即可进游玩;若走了开场白终节点,选定后会自动保存定稿。", + purpose: "收口或 Worker 集验收后可保存产物;产物可单独开玩。", status: hasAcceptedWorkerSet(session) ? "done" : "pending", }); diff --git a/src/skills/creation-flow.ts b/src/skills/creation-flow.ts index 7b7aa95..13c17da 100644 --- a/src/skills/creation-flow.ts +++ b/src/skills/creation-flow.ts @@ -2292,7 +2292,23 @@ export function isCloserModule( name?: string, ): boolean { if (module?.closer === true) return true; - return (name ?? "").trim() === "开场白与开场变量"; + const t = (name ?? "").trim(); + return t === "开场白与开场变量" || t.startsWith("开场白与开场变量#") || t === "开场白"; +} + +export function hasAcceptedCloserStep( + flow: CreationFlow | null | undefined, + acceptedStepIds: readonly string[], + catalog?: ModuleCatalog | null, +): boolean { + if (flow?.steps.length) { + return flow.steps.some( + (s) => + isCloserModule(findModuleByName(catalog, s.name), s.name) && + isStepAccepted(s, acceptedStepIds), + ); + } + return acceptedStepIds.some((id) => isCloserModule(null, id)); } export function findCloserStepIndex( diff --git a/src/skills/opening-seal.ts b/src/skills/opening-seal.ts index b17ac8a..26e6472 100644 --- a/src/skills/opening-seal.ts +++ b/src/skills/opening-seal.ts @@ -1,5 +1,5 @@ /** - * 开场白终节点:选定后落库、关 DAG。 + * 开场白终节点:选定后落库开场。不关 DAG;产物靠用户「保存」拆出。 * 能力 `opening-setup` 与磁盘 worker `opening-generator` 共用这条收口。 */ import { @@ -22,21 +22,34 @@ export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening"; export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex"; export const CREATION_SEALED_WAITING_MESSAGE = - "开场已选定,创作已收口并保存。可切换到「游玩」。"; + "开场已选定。可保存为产物后开玩,或继续补节点。"; -export const INSTANCE_OPENING_SNAPSHOT_LABEL = "创作定稿(开场)"; +export const INSTANCE_OPENING_SNAPSHOT_LABEL = "定稿"; export function isOpeningLockTag(tag: string): boolean { const t = tag.trim(); return t === OPENING_OUTPUT_TAG || t === OPENING_INITIAL_VARS_TAG; } +/** 收口步 id / name(含「开场白与开场变量#2」) */ +export function isCloserStepRef(id: string | undefined): boolean { + const t = (id ?? "").trim(); + if (!t) return false; + return ( + t === "开场白与开场变量" || + t.startsWith("开场白与开场变量#") || + t === "开场白" + ); +} + export function isOpeningSealArtifact(artifact: { workerId?: string; + stepId?: string; outputTags?: readonly string[]; }): boolean { if ((artifact.workerId ?? "").trim() === "opening-generator") return true; - return (artifact.outputTags ?? []).includes(OPENING_SETUP_ARTIFACT_TAG); + if ((artifact.outputTags ?? []).includes(OPENING_SETUP_ARTIFACT_TAG)) return true; + return isCloserStepRef(artifact.stepId); } export type OpeningVariable = { diff --git a/src/skills/play-turn.ts b/src/skills/play-turn.ts index 89b6e01..b19bb5c 100644 --- a/src/skills/play-turn.ts +++ b/src/skills/play-turn.ts @@ -5,6 +5,8 @@ */ export const SLOT_PLAY_TURN_QUEUE = "playTurnQueue"; export const SLOT_PLAY_LAYER_ACTIVE = "playLayerActive"; +/** 当前游玩线所依据的产物 snapshot id */ +export const SLOT_PLAY_INSTANCE_ID = "playInstanceId"; export const PLAY_WORKING_SNAPSHOT_ID = "play-working"; export const PLAY_WORKING_SNAPSHOT_LABEL = "当前游玩"; diff --git a/src/skills/worker-declaration.ts b/src/skills/worker-declaration.ts index b1764c2..2485bbc 100644 --- a/src/skills/worker-declaration.ts +++ b/src/skills/worker-declaration.ts @@ -1,6 +1,14 @@ import type { Blackboard } from "../blackboard/blackboard.js"; import type { RuntimeSession } from "../types/runtime.js"; import { isPlayLayerActive } from "./play-turn.js"; +import { + SLOT_CREATION_ACCEPTED_UNITS, + parseAcceptedUnits, +} from "./creation-units.js"; +import { + isCloserStepRef, + SLOT_CREATION_SEALED_BY_OPENING, +} from "./opening-seal.js"; import { deriveDesignStageScope, deriveOnDemandWorkerScope, @@ -41,8 +49,14 @@ export function hasAcceptedWorkerSet(session: RuntimeSession): boolean { ); } +function hasAcceptedCloser(session: RuntimeSession): boolean { + if (Boolean(session.slots[SLOT_CREATION_SEALED_BY_OPENING])) return true; + const accepted = parseAcceptedUnits(session.slots[SLOT_CREATION_ACCEPTED_UNITS]); + return accepted.some((id) => isCloserStepRef(id)); +} + export function canEnterPlay(session: RuntimeSession): boolean { - return hasAcceptedWorkerSet(session); + return hasAcceptedWorkerSet(session) || hasAcceptedCloser(session); } export function inferLifecycleStage(session: RuntimeSession): LifecycleStage { diff --git a/src/types/run-snapshot.ts b/src/types/run-snapshot.ts index 7d08582..d36c8db 100644 --- a/src/types/run-snapshot.ts +++ b/src/types/run-snapshot.ts @@ -16,6 +16,8 @@ export type RunSnapshot = { bookId: string; label: string; kind: SnapshotKind; + /** 游玩存档所属的产物(instance)id */ + instanceId?: string; /** skill 包 id(兼容旧字段名) */ orchestratorId?: string; runtimeSession: RuntimeSession; @@ -32,6 +34,8 @@ export type RunSnapshotMeta = { bookId: string; label: string; kind: SnapshotKind; + /** 游玩存档所属的产物(instance)id */ + instanceId?: string; /** skill 包 id(兼容旧字段名) */ orchestratorId?: string; createdAt: string; @@ -44,6 +48,7 @@ export function toRunSnapshotMeta(snapshot: RunSnapshot): RunSnapshotMeta { bookId: snapshot.bookId, label: snapshot.label, kind: snapshot.kind, + instanceId: snapshot.instanceId, orchestratorId: snapshot.orchestratorId, createdAt: snapshot.createdAt, note: snapshot.note, diff --git a/tests/creation-flow.test.ts b/tests/creation-flow.test.ts index d45292b..b4fd928 100644 --- a/tests/creation-flow.test.ts +++ b/tests/creation-flow.test.ts @@ -25,6 +25,7 @@ import { spawnRepeatableCreationStep, spawnInstanceFromPrototype, hasSelectableCreationWork, + hasAcceptedCloserStep, removeUnstartedInstance, looksLikeCreationFlowDoc, parseCreationFlow, @@ -1269,4 +1270,21 @@ modules: if ("error" in ok) return; expect(ok.step.depends_on).toEqual(["生成规则#1"]); }); + + it("detects accepted closer step", () => { + const flow = parseCreationFlow( + JSON.stringify({ + steps: [ + { + id: "开场白与开场变量", + name: "开场白与开场变量", + depends_on: [], + }, + ], + }), + ); + expect(hasAcceptedCloserStep(flow, [])).toBe(false); + expect(hasAcceptedCloserStep(flow, ["开场白与开场变量"])).toBe(true); + expect(hasAcceptedCloserStep(null, ["开场白与开场变量"])).toBe(true); + }); }); diff --git a/tests/opening-seal.test.ts b/tests/opening-seal.test.ts index ca64023..716c974 100644 --- a/tests/opening-seal.test.ts +++ b/tests/opening-seal.test.ts @@ -37,6 +37,13 @@ describe("opening-seal", () => { outputTags: ["输出.开场白"], }), ).toBe(true); + expect( + isOpeningSealArtifact({ + workerId: "design-step", + stepId: "开场白与开场变量", + outputTags: ["artifact.flat.v1"], + }), + ).toBe(true); expect( isOpeningSealArtifact({ workerId: "design-step", diff --git a/tests/phase-machine.test.ts b/tests/phase-machine.test.ts index c3aef0a..9470a3e 100644 --- a/tests/phase-machine.test.ts +++ b/tests/phase-machine.test.ts @@ -422,7 +422,7 @@ describe("phase machine", () => { ]); }); - it("accepting opening-setup seals creation instead of next_intent", () => { + it("accepting opening-setup materializes opening then returns to the graph", () => { let session = createSession("default"); const artifact = createArtifact({ workerId: "design-step", @@ -441,17 +441,17 @@ describe("phase machine", () => { type: "user_accepted_artifact", payload: { artifactId: artifact.id }, }); - expect(accepted.session.waitingReason?.kind).toBe("input"); - expect(accepted.session.waitingReason).toMatchObject({ - kind: "input", - }); - expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]); + expect(accepted.session.phase).toBe("running"); + expect(accepted.effects).toEqual([ + { type: "seal_creation_opening" }, + { type: "propose_next_creation_step" }, + ]); expect( accepted.effects.some((e) => e.type === "invoke_main_agent"), ).toBe(false); }); - it("accepting opening-generator also seals creation", () => { + it("accepting opening-generator also materializes opening then returns to the graph", () => { let session = createSession("default"); const artifact = createArtifact({ workerId: "opening-generator", @@ -469,11 +469,14 @@ describe("phase machine", () => { type: "user_accepted_artifact", payload: { artifactId: artifact.id }, }); - expect(accepted.session.waitingReason?.kind).toBe("input"); - expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]); + expect(accepted.session.phase).toBe("running"); + expect(accepted.effects).toEqual([ + { type: "seal_creation_opening" }, + { type: "propose_next_creation_step" }, + ]); }); - it("sealed creation still blocks input until play", () => { + it("sealed creation still lets the user keep designing", () => { let session = createSession("default"); session = { ...session, @@ -481,14 +484,13 @@ describe("phase machine", () => { waitingReason: { kind: "input" }, slots: { creationSealedByOpening: true, designInstanceReady: true }, }; - const blocked = applyEvent(session, { + const continued = applyEvent(session, { type: "user_submitted_input", - payload: { text: "我买金首饰" }, + payload: { text: "再补一个 NPC" }, }); - expect(blocked.effects).toEqual([ - { type: "emit_message", message: "创作已收口并保存。请切换到「游玩」开始。" }, - ]); - expect(blocked.effects.some((e) => e.type === "run_play_turn")).toBe(false); + expect(continued.session.phase).toBe("running"); + expect(continued.effects).toEqual([{ type: "invoke_main_agent" }]); + expect(continued.effects.some((e) => e.type === "run_play_turn")).toBe(false); }); it("play input starts a play turn even after opening seal", () => { diff --git a/tests/phase-runtime.test.ts b/tests/phase-runtime.test.ts index 5c12082..a8cef6c 100644 --- a/tests/phase-runtime.test.ts +++ b/tests/phase-runtime.test.ts @@ -293,4 +293,43 @@ describe("phase runtime", () => { flow?.steps.find((s) => s.name === "开场白与开场变量")?.depends_on, ).toEqual(["美学纲领与交互范式"]); }); + + it("prepareEnterPlay seals when closer is already accepted", () => { + const session = createSession("default"); + const blackboard = new Blackboard(); + blackboard.write({ + tag: "设计.创作流程", + content: JSON.stringify({ + status: "open", + steps: [ + { + id: "开场白与开场变量", + name: "开场白与开场变量", + depends_on: [], + }, + ], + }), + source: "runtime", + }); + const runtime = new PhaseRuntime({ + initialSession: { + ...session, + slots: { + ...session.slots, + creationAcceptedUnits: ["开场白与开场变量"], + }, + }, + initialBlackboardItems: blackboard.exportItems(), + }); + runtime.prepareEnterPlay(); + expect(runtime.getSession().slots.creationSealedByOpening).toBe(true); + expect(runtime.getSession().slots.designInstanceReady).toBe(true); + expect( + runtime.getBlackboard().getContentByTag("设计.worker集")?.trim(), + ).toBeTruthy(); + const flow = parseCreationFlow( + runtime.getBlackboard().getContentByTag("设计.创作流程"), + ); + expect(flow?.status).not.toBe("closed"); + }); }); diff --git a/tests/run-snapshot.test.ts b/tests/run-snapshot.test.ts index 21f5632..6834130 100644 --- a/tests/run-snapshot.test.ts +++ b/tests/run-snapshot.test.ts @@ -64,6 +64,20 @@ describe("run snapshot store", () => { expect(loaded!.note).toBe("测试快照"); }); + it("stores instanceId on run snapshots and lists it", async () => { + const { saveRunSnapshot, loadRunSnapshot, listRunSnapshots } = await import( + "../src/book/run-snapshot-store.js" + ); + const bookId = randomUUID(); + const instanceId = randomUUID(); + const snapshot = makeSnapshot(bookId, "巷战"); + snapshot.instanceId = instanceId; + + saveRunSnapshot(snapshot); + expect(loadRunSnapshot(bookId, snapshot.id)?.instanceId).toBe(instanceId); + expect(listRunSnapshots(bookId)[0].instanceId).toBe(instanceId); + }); + it("listRunSnapshots returns saved items newest first", async () => { const { saveRunSnapshot, listRunSnapshots } = await import( "../src/book/run-snapshot-store.js" @@ -156,6 +170,7 @@ describe("run snapshot store", () => { runtime: { getSession: () => { slots: Record }; getBlackboard: () => { write: (i: object) => void } }; }; managed.runtime.getSession().slots.startupCompleted = true; + managed.runtime.getSession().slots.designInstanceReady = true; const bb = managed.runtime.getBlackboard(); bb.write({ tag: "情境.实验.设定", content: "囚徒困境", source: "worker" }); bb.write({ tag: "运行.事件流", content: "第1轮", source: "worker" }); @@ -168,6 +183,26 @@ describe("run snapshot store", () => { expect(stored.blackboardItems.some((i) => i.tag === "运行.事件流")).toBe(false); const loaded = await mgr.loadGameSnapshot(book.id, save.id); - expect(loaded.hints.some((h) => h.includes("实例"))).toBe(true); + expect(loaded.hints.some((h) => h.includes("产物"))).toBe(true); + }); + + it("renameGameSnapshot updates the label", async () => { + const { createBook } = await import("../src/book/store.js"); + const { SessionManager } = await import("../src/server/session-manager.js"); + const { loadRunSnapshot } = await import("../src/book/run-snapshot-store.js"); + + const book = createBook({ title: "改名测试" }); + const mgr = new SessionManager(); + const view = await mgr.createForBook(book.id, "world-simulator"); + const managed = mgr["require"](view.id) as { + runtime: { getSession: () => { slots: Record } }; + }; + managed.runtime.getSession().slots.startupCompleted = true; + managed.runtime.getSession().slots.designInstanceReady = true; + + const save = mgr.saveGameSnapshot(view.id, "定稿", "instance"); + const renamed = mgr.renameGameSnapshot(book.id, save.id, "丧尸街巷"); + expect(renamed.label).toBe("丧尸街巷"); + expect(loadRunSnapshot(book.id, save.id)?.label).toBe("丧尸街巷"); }); }); diff --git a/tests/worker-declaration.test.ts b/tests/worker-declaration.test.ts index 8d8a4dc..41661ab 100644 --- a/tests/worker-declaration.test.ts +++ b/tests/worker-declaration.test.ts @@ -3,6 +3,7 @@ import { Blackboard } from "../src/blackboard/blackboard.js"; import { createSession } from "../src/runtime/phase-machine.js"; import { buildInstanceWorkerDeclaration, + canEnterPlay, inferLifecycleStage, isWorkerDeclared, readWorkerSetYamlForDeclaration, @@ -108,4 +109,15 @@ describe("worker-declaration", () => { }; expect(inferLifecycleStage(s3)).toBe("play"); }); + + it("closer accepted is enough to enter play", () => { + const session = { + ...createSession(), + slots: { + ...createSession().slots, + creationAcceptedUnits: ["开场白与开场变量"], + }, + }; + expect(canEnterPlay(session)).toBe(true); + }); }); diff --git a/web/agent-ui.js b/web/agent-ui.js index b8f7dac..d5d261a 100644 --- a/web/agent-ui.js +++ b/web/agent-ui.js @@ -76,6 +76,34 @@ function isPlayView(view) { return Boolean(view?.playLayerActive || view?.lifecycleStage === "play"); } +/** 开场已选定、创作收口,仍停在创作层等用户保存产物 */ +export function isCreationSealedWaiting(view) { + if (!view || isPlayView(view)) return false; + const msg = view.waitingReason?.message; + return ( + view.waitingReason?.kind === "input" && + typeof msg === "string" && + /创作已收口|可保存为产物/.test(msg) + ); +} + +/** 收口节点已验收(图上「已完成」),即使还停在选节点 */ +export function isCreationCloserDone(view) { + if (!view || isPlayView(view)) return false; + const steps = view.creationFlowView?.steps; + if (!Array.isArray(steps)) return false; + return steps.some((s) => s?.closer && s.runState === "done"); +} + +export function canOfferSaveProduct(view) { + return isCreationSealedWaiting(view) || isCreationCloserDone(view); +} + +export function canOfferEnterPlay(view) { + if (!view || isPlayView(view)) return false; + return canOfferSaveProduct(view) || Boolean(view.hasProduct); +} + function renderLiveStreamBody(live, view) { const thinking = (live?.thinking || view?.agentThinking || "").trim(); const output = (live?.output ?? "").trim(); @@ -848,16 +876,19 @@ export function renderLifecycle(view) { document.body.dataset.lifecycle = stage; const toggle = document.getElementById("lifecycle-toggle"); if (!toggle) return; + const ready = Boolean(view?.playReady || view?.hasProduct); + toggle.hidden = !ready; toggle.querySelectorAll("[data-stage]").forEach((btn) => { const s = btn.getAttribute("data-stage"); btn.classList.toggle("active", s === stage); if (s === "play") { - btn.disabled = !view.playReady; - btn.title = view.playReady - ? "进入游玩 / 写作" - : "请先验收 Worker 集(创作定稿)后再切换"; + btn.disabled = !ready; + btn.title = ready + ? "用已保存的产物开玩" + : "请先保存产物后再开玩"; } else { btn.disabled = false; + btn.title = "回到创作"; } }); } @@ -4312,7 +4343,12 @@ function renderSpeakWorkspace(view) { let hint = "在底栏输入你的想法。"; let guideHtml = ""; - if (wr?.kind === "pick_creation_step" || isFlowPlanReview(view)) { + if (canOfferSaveProduct(view)) { + title = "可以落档了"; + hint = view.hasProduct + ? "产物已拆出,可开玩;创作流程还在,补节点或再保存一份新定稿。" + : "保存后创作流程仍在;拆出的产物可以单独开玩、存档。"; + } else if (wr?.kind === "pick_creation_step" || isFlowPlanReview(view)) { title = "选要做的节点"; hint = "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发。"; } else if (showOpening) { @@ -4350,13 +4386,8 @@ function renderSpeakWorkspace(view) { ? proposedOutputCopy(view.proposedNextStep) : view.focus?.detail || "确认执行,或在底栏说明意见。"; } else if (wr?.kind === "input") { - if (wr.message && /创作已收口/.test(wr.message)) { - title = "创作已收口"; - hint = wr.message; - } else { - title = "继续说"; - hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。"; - } + title = "继续说"; + hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。"; } else if (view.uiPrompt && !hasUserMessages(view)) { title = "描述你想创作什么"; hint = String(view.uiPrompt).trim().slice(0, 280); @@ -4377,9 +4408,23 @@ function renderSpeakWorkspace(view) { interactive: flowInteractive, }) : ""; + const enterPlay = canOfferEnterPlay(view) + ? `
+ ${ + canOfferSaveProduct(view) + ? `` + : "" + } + +
` + : ""; return `
- ${flowInteractive ? "流程" : "说话"} + ${ + canOfferSaveProduct(view) ? "落档" : flowInteractive ? "流程" : "说话" + } ${leaveStepControlHtml(view)}

${esc(title)}

@@ -4388,6 +4433,7 @@ function renderSpeakWorkspace(view) { ${guideHtml} ${intake} ${flowHtml} + ${enterPlay}
`; } @@ -4586,6 +4632,12 @@ export function renderMessageFeed(view, loading, handlers = {}) { stage.innerHTML = renderSpeakWorkspace(view); wireCreationFlowGraph(stage, handlers); wireLeaveStepControl(stage, handlers); + stage.querySelector("[data-enter-play]")?.addEventListener("click", () => { + handlers.onEnterPlay?.(); + }); + stage.querySelector("[data-save-product]")?.addEventListener("click", () => { + handlers.onSaveProduct?.(); + }); if (panelFeed) ensureQuestionsHostIn(panelFeed); return; } diff --git a/web/app.js b/web/app.js index fe1e6f8..0f9dc49 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,11 @@ -import { renderWorkspace, updateLiveStreamPanel, resetRailChrome, fillStepArtifactDialog } from "./agent-ui.js"; +import { + renderWorkspace, + updateLiveStreamPanel, + resetRailChrome, + fillStepArtifactDialog, + canOfferSaveProduct, + canOfferEnterPlay, +} from "./agent-ui.js"; import { downloadMarkdown, sessionToMarkdown } from "./export.js"; import { renderIntakePanel } from "./intake-ui.js"; import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; @@ -24,6 +31,8 @@ let bookSelectMode = false; let selectedBookIds = new Set(); let activePlaySaveId = null; const playSavesByBook = new Map(); +const playWorkingInstanceByBook = new Map(); +const expandedProductsByBook = new Map(); const playSavesLoading = new Set(); let bookMenuBookId = null; let livePollTimer = null; @@ -99,6 +108,17 @@ function resolveUserTask(view, loading) { }; } + if (canOfferSaveProduct(view)) { + return { + id: "speak", + label: "可保存", + title: "落档产物", + hint: view.hasProduct + ? "可再保存一份新定稿,或用已有产物开玩。补节点仍点图。" + : "点「保存定稿」拆出产物。创作流程还在,之后用产物开玩。", + }; + } + if (isFlowPlanReview(view) || wr?.kind === "pick_creation_step") { return { id: "speak", @@ -758,12 +778,30 @@ function renderComposer(view, loading) { } if (spec.mode === "pick_step") { + const saveProduct = canOfferSaveProduct(view) + ? `` + : ""; + const enterPlay = canOfferEnterPlay(view) + ? `` + : ""; + const sendCls = + canOfferSaveProduct(view) || canOfferEnterPlay(view) + ? "btn composer-btn" + : "btn btn-primary composer-btn"; const shell = composerInputShell(spec, { textareaHtml: ``, - trailing: ``, + trailing: `${saveProduct}${enterPlay}`, }); root.innerHTML = `
${shell}
`; wireComposerForm({ requiresText: true }); + root.querySelector("[data-act=save-product]")?.addEventListener("click", () => { + void saveCurrentInstance(); + }); + root.querySelector("[data-act=enter-play]")?.addEventListener("click", () => { + enterPlayNow(); + }); applyPendingComposerDraft(); return; } @@ -956,8 +994,49 @@ function playRunsForBook(bookId) { return (playSavesByBook.get(bookId) ?? []).filter((s) => s.kind === "run"); } +function productInstancesForBook(bookId) { + return (playSavesByBook.get(bookId) ?? []).filter((s) => s.kind === "instance"); +} + +function resolveRunInstanceId(run, products) { + if (run.instanceId && products.some((p) => p.id === run.instanceId)) { + return run.instanceId; + } + if (!products.length) return null; + const sorted = [...products].sort((a, b) => + String(a.createdAt).localeCompare(String(b.createdAt)), + ); + let id = sorted[0].id; + for (const product of sorted) { + if (String(product.createdAt) <= String(run.createdAt)) id = product.id; + } + return id; +} + +function runsForProduct(bookId, instanceId, products) { + return playRunsForBook(bookId).filter( + (run) => resolveRunInstanceId(run, products) === instanceId, + ); +} + +function isProductExpanded(bookId, instanceId, fallback) { + const set = expandedProductsByBook.get(bookId); + if (set) return set.has(instanceId); + return Boolean(fallback); +} + +function toggleProductExpanded(bookId, instanceId) { + const open = isProductExpanded(bookId, instanceId, false); + const next = new Set(); + if (!open) next.add(instanceId); + expandedProductsByBook.set(bookId, next); + renderBookList(); +} + function bookPlayReady(bookId) { - if (lastView?.bookId === bookId && Boolean(lastView?.playReady)) return true; + if (lastView?.bookId === bookId && Boolean(lastView?.playReady || lastView?.hasProduct)) { + return true; + } const saves = playSavesByBook.get(bookId); if (!Array.isArray(saves)) return false; return saves.some((s) => s.kind === "instance" || s.kind === "run"); @@ -980,8 +1059,10 @@ async function fetchPlaySavesForBook(bookId, force = false) { try { const data = await api(`/api/books/${encodeURIComponent(bookId)}/saves`); playSavesByBook.set(bookId, data.saves ?? []); + playWorkingInstanceByBook.set(bookId, data.playWorkingInstanceId ?? null); } catch { playSavesByBook.set(bookId, []); + playWorkingInstanceByBook.set(bookId, null); } finally { playSavesLoading.delete(bookId); renderBookList(); @@ -1107,10 +1188,12 @@ function renderExplorerRow({ actionClass, onClick, onDelete, + onRename, child, twistie, expanded, onTwistie, + title, }) { const row = document.createElement("button"); row.type = "button"; @@ -1125,6 +1208,7 @@ function renderExplorerRow({ .filter(Boolean) .join(" "); if (bookId) row.dataset.bookId = bookId; + if (title) row.title = title; row.innerHTML = ` ${twistie ? `${expanded ? "▾" : "▸"}` : ""} @@ -1136,10 +1220,17 @@ function renderExplorerRow({ if (e.target.closest(".explorer-del") || e.target.closest("[data-twist]")) return; onClick?.(e); }); - row.querySelector("[data-twist]")?.addEventListener("click", (e) => { - e.stopPropagation(); - onTwistie?.(); - }); + if (onRename) { + row.addEventListener("dblclick", (e) => { + if (e.target.closest(".explorer-del") || e.target.closest("[data-twist]")) return; + e.preventDefault(); + onRename(); + }); + } + row.querySelector("[data-twist]")?.addEventListener("click", (e) => { + e.stopPropagation(); + (onTwistie || onClick)?.(); + }); row.querySelector(".explorer-del")?.addEventListener("click", (e) => { e.stopPropagation(); onDelete?.(); @@ -1157,11 +1248,16 @@ function renderExplorerRow({ return row; } -function renderBookBranch(book, list, { nested = false } = {}) { +function renderBookBranch(book, list) { const loading = playSavesLoading.has(book.id); const saves = playSavesByBook.get(book.id); - const ready = bookPlayReady(book.id); + const products = productInstancesForBook(book.id); const playHere = book.id === activeBookId && isPlayNow(); + const playingInstanceId = + playHere && lastView?.playInstanceId + ? lastView.playInstanceId + : playWorkingInstanceByBook.get(book.id); + const latestId = products[0]?.id; if (loading && saves === undefined) { const hint = document.createElement("p"); @@ -1171,56 +1267,96 @@ function renderBookBranch(book, list, { nested = false } = {}) { return; } - if (ready) { - list.appendChild( - renderExplorerRow({ - name: "当前", - meta: playHere && !activePlaySaveId ? "进行中" : "工作副本", - active: playHere && !activePlaySaveId, - child: nested, - onClick: () => void openCurrentPlay(book.id), - }), - ); - } + for (const product of products) { + const isLatest = product.id === latestId; + const isPlayingHere = playHere && playingInstanceId === product.id; + const hasWorking = playingInstanceId === product.id; + const expanded = isProductExpanded(book.id, product.id, isPlayingHere); + const runs = runsForProduct(book.id, product.id, products); + const wrap = document.createElement("div"); + wrap.className = `explorer-product${expanded ? " is-open" : ""}`; - for (const s of playRunsForBook(book.id)) { - const when = formatSaveWhen(s.createdAt); - list.appendChild( - renderExplorerRow({ - name: s.label, - meta: when ? `游玩 · ${when}` : "游玩进度", - active: playHere && activePlaySaveId === s.id, - child: nested, - actionClass: "explorer-file", - onClick: () => void loadPlaySave(book.id, s.id), - onDelete: () => void deletePlaySave(book.id, s.id, s.label), - }), - ); - } + const header = renderExplorerRow({ + name: product.label, + meta: isLatest ? "最新" : "", + twistie: true, + expanded, + title: "点开游玩存档,双击改名", + onTwistie: () => toggleProductExpanded(book.id, product.id), + onClick: () => toggleProductExpanded(book.id, product.id), + onRename: () => void renamePlaySave(book.id, product.id, product.label), + onDelete: () => void deletePlaySave(book.id, product.id, product.label, "落档"), + }); + header.setAttribute("aria-expanded", expanded ? "true" : "false"); + wrap.appendChild(header); - if (ready) { - list.appendChild( - renderExplorerRow({ - name: "+ 新开一局", - actionClass: "explorer-action", - child: nested, - onClick: () => void startNewPlayForBook(book.id), - }), - ); + if (expanded) { + const drop = document.createElement("div"); + drop.className = "explorer-drop"; + drop.setAttribute("role", "group"); + drop.setAttribute("aria-label", `${product.label} 的游玩存档`); + + if (hasWorking) { + drop.appendChild( + renderExplorerRow({ + name: "当前游玩", + meta: isPlayingHere && !activePlaySaveId ? "进行中" : "工作副本", + active: isPlayingHere && !activePlaySaveId, + child: true, + onClick: () => void openCurrentPlay(book.id), + }), + ); + } + + if (!runs.length && !hasWorking) { + const hint = document.createElement("p"); + hint.className = "explorer-drop-empty"; + hint.textContent = "还没有游玩存档"; + drop.appendChild(hint); + } + + for (const run of runs) { + const runWhen = formatSaveWhen(run.createdAt); + drop.appendChild( + renderExplorerRow({ + name: run.label, + meta: runWhen || "游玩进度", + active: playHere && activePlaySaveId === run.id, + child: true, + actionClass: "explorer-file", + onClick: () => void loadPlaySave(book.id, run.id), + onRename: () => void renamePlaySave(book.id, run.id, run.label), + onDelete: () => void deletePlaySave(book.id, run.id, run.label, "游玩存档"), + }), + ); + } + + drop.appendChild( + renderExplorerRow({ + name: "+ 新开一局", + actionClass: "explorer-action", + child: true, + onClick: () => void startNewPlayForBook(book.id, product.id), + }), + ); + wrap.appendChild(drop); + } + + list.appendChild(wrap); } } function renderBookContents(book, list) { - const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计"; + const designing = book.id === activeBookId && !isPlayNow(); list.appendChild( renderExplorerRow({ - name: "创作", - meta: skill, - active: book.id === activeBookId && !isPlayNow(), + name: "继续创作", + meta: designing ? "进行中" : "", + active: designing, onClick: () => void openBookDesign(book.id), }), ); - renderBookBranch(book, list, { nested: false }); + renderBookBranch(book, list); } function renderBookList() { @@ -1439,8 +1575,25 @@ async function loadPlaySave(bookId, saveId) { } } -async function deletePlaySave(bookId, saveId, label) { - if (!confirm(`删除游玩存档「${label}」?`)) return; +async function renamePlaySave(bookId, saveId, currentLabel) { + const next = prompt("名称", currentLabel); + if (next == null) return; + const trimmed = next.trim(); + if (!trimmed) return alert("名称不能为空"); + if (trimmed === currentLabel) return; + try { + await api( + `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`, + { method: "PATCH", body: JSON.stringify({ label: trimmed }) }, + ); + await fetchPlaySavesForBook(bookId, true); + } catch (err) { + alert(err.message); + } +} + +async function deletePlaySave(bookId, saveId, label, kindLabel = "游玩存档") { + if (!confirm(`删除${kindLabel}「${label}」?`)) return; try { await api( `/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`, @@ -1453,17 +1606,25 @@ async function deletePlaySave(bookId, saveId, label) { } } -async function startNewPlayForBook(bookId) { - if (!bookPlayReady(bookId)) { - alert("须先验收 Worker 集"); +async function startNewPlayForBook(bookId, instanceId) { + const thisView = lastView?.bookId === bookId ? lastView : null; + if (!bookPlayReady(bookId) && !thisView?.hasProduct && !canOfferEnterPlay(thisView)) { + alert("请先保存产物,或完成收口后再开玩"); return; } try { navigateToBook(bookId); if (lastView?.bookId === bookId) renderSession(lastView, true); - const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { method: "POST" }); + const data = await api(`/api/books/${encodeURIComponent(bookId)}/play/new`, { + method: "POST", + body: JSON.stringify(instanceId ? { instanceId } : {}), + }); activePlaySaveId = null; + if (instanceId) { + expandedProductsByBook.set(bookId, new Set([instanceId])); + } renderSession(data.session, false); + void fetchPlaySavesForBook(bookId, true); } catch (err) { if (lastView) renderSession(lastView, false); alert(err.message); @@ -1549,6 +1710,8 @@ function closeWorkspace({ emptyCopy = "" } = {}) { if (btnInst) btnInst.hidden = true; const btnExport = $("btn-export"); if (btnExport) btnExport.disabled = true; + const toggle = $("lifecycle-toggle"); + if (toggle) toggle.hidden = true; $("message-feed").innerHTML = emptyCopy ? `

${emptyCopy}

` : ""; const stage = $("workspace-stage"); if (stage) { @@ -1683,6 +1846,8 @@ function renderSession(view, loading = false) { onViewStepArtifact: (stepId, name) => showStepArtifactDialog(stepId, name), onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }), onLeaveCreationStep: () => runAction("leave_step"), + onEnterPlay: () => enterPlayNow(), + onSaveProduct: () => void saveCurrentInstance(), }); renderComposer(view, loading); if (loading) startLivePoll(); @@ -1930,6 +2095,14 @@ async function runAction(action, extra = {}) { } } +function enterPlayNow() { + if (activeBookId) { + void openCurrentPlay(activeBookId); + return; + } + void setLifecycle("play"); +} + async function setLifecycle(stage) { if (!sessionId) return; if (stage === lastView?.lifecycleStage) { @@ -1942,8 +2115,8 @@ async function setLifecycle(stage) { return; } } - if (stage === "play" && !lastView?.playReady) { - alert("请先验收 Worker 集(创作定稿)后再进入游玩"); + if (stage === "play" && !lastView?.playReady && !canOfferEnterPlay(lastView) && !lastView?.hasProduct) { + alert("请先保存产物,或完成收口后再开玩"); return; } try { @@ -1953,6 +2126,7 @@ async function setLifecycle(stage) { }); if (stage === "design") activePlaySaveId = null; renderSession(view, false); + if (activeBookId) void fetchPlaySavesForBook(activeBookId, true); } catch (err) { alert(err.message || "无法切换阶段"); if (lastView) renderSession(lastView, false); @@ -2109,17 +2283,34 @@ async function saveCurrentPlay() { } } +function defaultArchiveLabel() { + return ( + formatSaveWhen(new Date().toISOString()) || + new Date().toLocaleString("zh-CN") + ); +} + async function saveCurrentInstance() { - if (!activeBookId || !lastView?.playReady) return alert("须先验收 Worker 集"); - const label = prompt("创作定稿存档名称"); + if (!activeBookId || (!lastView?.playReady && !canOfferSaveProduct(lastView))) { + return alert("须先完成收口或验收 Worker 集"); + } + const label = prompt("落档名称", defaultArchiveLabel()); if (!label?.trim()) return; try { await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, { method: "POST", body: JSON.stringify({ label: label.trim(), sessionId, kind: "instance" }), }); - playSavesByBook.delete(activeBookId); - void fetchPlaySavesForBook(activeBookId, true); + if (lastView) { + lastView = { ...lastView, hasProduct: true }; + renderSession(lastView, false); + } + await fetchPlaySavesForBook(activeBookId, true); + const newest = productInstancesForBook(activeBookId)[0]; + if (newest) { + expandedProductsByBook.set(activeBookId, new Set([newest.id])); + renderBookList(); + } } catch (err) { alert(err.message); } @@ -2223,6 +2414,11 @@ function exportSession() { downloadMarkdown(`${name}.md`, sessionToMarkdown(lastView)); } +$("lifecycle-toggle")?.addEventListener("click", (e) => { + const stage = e.target.closest("[data-stage]")?.getAttribute("data-stage"); + if (stage === "play") enterPlayNow(); + else if (stage === "design") void setLifecycle("design"); +}); $("btn-new-book")?.addEventListener("click", openNewBookDialog); $("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close()); $("book-action-menu")?.addEventListener("click", (e) => { diff --git a/web/index.html b/web/index.html index 85feb65..7a2d5f2 100644 --- a/web/index.html +++ b/web/index.html @@ -80,6 +80,10 @@