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 <cursoragent@cursor.com>
This commit is contained in:
@@ -31,7 +31,6 @@ import {
|
|||||||
normalizeQuestions,
|
normalizeQuestions,
|
||||||
} from "../skills/question-protocol.js";
|
} from "../skills/question-protocol.js";
|
||||||
import {
|
import {
|
||||||
CREATION_SEALED_WAITING_MESSAGE,
|
|
||||||
SLOT_CREATION_SEALED_BY_OPENING,
|
SLOT_CREATION_SEALED_BY_OPENING,
|
||||||
isOpeningSealArtifact,
|
isOpeningSealArtifact,
|
||||||
} from "../skills/opening-seal.js";
|
} 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 {
|
return {
|
||||||
session: touch(running({ ...next, resumeContext: undefined })),
|
session: touch(running({ ...next, resumeContext: undefined })),
|
||||||
@@ -1178,14 +1162,14 @@ export function applyEvent(
|
|||||||
pendingDecision: undefined,
|
pendingDecision: undefined,
|
||||||
currentWorkerId: undefined,
|
currentWorkerId: undefined,
|
||||||
};
|
};
|
||||||
// 开场白终节点:选定后落库、关 DAG,不再问「下一步想写什么」。
|
// 开场白终节点:落库开场,再回到分层图。创作流程保留,产物靠「保存」拆出。
|
||||||
if (isOpeningSealArtifact(artifact)) {
|
if (isOpeningSealArtifact(artifact)) {
|
||||||
return {
|
return {
|
||||||
session: waiting(cleared, {
|
session: touch(running(cleared)),
|
||||||
kind: "input",
|
effects: [
|
||||||
message: CREATION_SEALED_WAITING_MESSAGE,
|
{ type: "seal_creation_opening" },
|
||||||
}),
|
{ type: "propose_next_creation_step" },
|
||||||
effects: [{ type: "seal_creation_opening" }],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (inferLifecycleStage(session) === "play") {
|
if (inferLifecycleStage(session) === "play") {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import {
|
|||||||
isInstanceStep,
|
isInstanceStep,
|
||||||
findStepByRef,
|
findStepByRef,
|
||||||
findModuleByName,
|
findModuleByName,
|
||||||
|
hasAcceptedCloserStep,
|
||||||
parseCreationFlow,
|
parseCreationFlow,
|
||||||
DESIGN_FLOW_WORKER_ID,
|
DESIGN_FLOW_WORKER_ID,
|
||||||
DESIGN_STEP_WORKER_ID,
|
DESIGN_STEP_WORKER_ID,
|
||||||
@@ -114,9 +115,8 @@ import {
|
|||||||
SLOT_ASKED_QUESTIONS,
|
SLOT_ASKED_QUESTIONS,
|
||||||
} from "../skills/question-protocol.js";
|
} from "../skills/question-protocol.js";
|
||||||
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
||||||
|
import { defaultPlaySlots } from "../skills/play-slots.js";
|
||||||
import {
|
import {
|
||||||
closeCreationFlowRaw,
|
|
||||||
CREATION_SEALED_WAITING_MESSAGE,
|
|
||||||
mergeOpeningTablePatch,
|
mergeOpeningTablePatch,
|
||||||
OPENING_CURRENT_VARS_TAG,
|
OPENING_CURRENT_VARS_TAG,
|
||||||
OPENING_INITIAL_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[] {
|
getAvailableSkills(): SkillIndexEntry[] {
|
||||||
return this.availableSkills;
|
return this.availableSkills;
|
||||||
}
|
}
|
||||||
@@ -1402,10 +1419,6 @@ export class PhaseRuntime {
|
|||||||
* 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。
|
* 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。
|
||||||
*/
|
*/
|
||||||
private async awaitCreationStepPick(): Promise<void> {
|
private async awaitCreationStepPick(): Promise<void> {
|
||||||
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
|
|
||||||
this.onMessage(CREATION_SEALED_WAITING_MESSAGE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.syncSlotsToBlackboard(this.session);
|
this.syncSlotsToBlackboard(this.session);
|
||||||
const flow = parseCreationFlow(
|
const flow = parseCreationFlow(
|
||||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||||
@@ -1474,7 +1487,7 @@ export class PhaseRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选定开场白后:把候选落成 输出.开场白 / 初值表,关闭 DAG。
|
* 选定开场白后:把候选落成 输出.开场白 / 初值表。不关 DAG,创作流程继续。
|
||||||
*/
|
*/
|
||||||
private sealCreationOpening(): void {
|
private sealCreationOpening(): void {
|
||||||
const selectedRaw = this.session.slots[SLOT_OPENING_SELECTED_INDEX];
|
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 n = payload?.candidates.length ?? (openingText ? 1 : 0);
|
||||||
const which =
|
const which =
|
||||||
n > 1 && payload
|
n > 1 && payload
|
||||||
@@ -1534,7 +1536,7 @@ export class PhaseRuntime {
|
|||||||
? "开场白"
|
? "开场白"
|
||||||
: "开场";
|
: "开场";
|
||||||
this.onMessage(
|
this.onMessage(
|
||||||
`[创作收口] 已选定${which},工作流计划已关闭。可切换到「游玩」。`,
|
`[创作] 已选定${which}。可保存为产物后开玩,或继续补节点。`,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.session = {
|
this.session = {
|
||||||
@@ -1542,10 +1544,37 @@ export class PhaseRuntime {
|
|||||||
slots: {
|
slots: {
|
||||||
...this.session.slots,
|
...this.session.slots,
|
||||||
[SLOT_CREATION_SEALED_BY_OPENING]: true,
|
[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],
|
[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 能在收口前插入节点 */
|
/** 再编排:解开开场收口,让 design-flow 能在收口前插入节点 */
|
||||||
@@ -2667,7 +2696,6 @@ export class PhaseRuntime {
|
|||||||
/** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */
|
/** invoke_main_agent 副作用:running 时调用总管 LLM,链式推进直到需用户介入 */
|
||||||
private async maybeRunMainAgent(effects: PhaseEffect[]): Promise<void> {
|
private async maybeRunMainAgent(effects: PhaseEffect[]): Promise<void> {
|
||||||
if (inferLifecycleStage(this.session) === "play") return;
|
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")) {
|
if (this.mainAgent && effects.some((e) => e.type === "invoke_main_agent")) {
|
||||||
await this.runMainAgent();
|
await this.runMainAgent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -400,8 +400,8 @@ export function buildFocus(
|
|||||||
actorType: "user",
|
actorType: "user",
|
||||||
actorLabel: "你",
|
actorLabel: "你",
|
||||||
action:
|
action:
|
||||||
reason.message && /创作已收口/.test(reason.message)
|
reason.message && /创作已收口|可保存为产物/.test(reason.message)
|
||||||
? "创作已收口"
|
? "可保存产物"
|
||||||
: "补充说明",
|
: "补充说明",
|
||||||
detail: reason.message?.slice(0, 120),
|
detail: reason.message?.slice(0, 120),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -395,7 +395,17 @@ export async function handleBooksApi(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
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 });
|
json(res, 200, { session });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
json(res, 400, {
|
json(res, 400, {
|
||||||
@@ -420,7 +430,11 @@ export async function handleBooksApi(
|
|||||||
...s,
|
...s,
|
||||||
kindLabel: s.kind === "instance" ? "创作定稿" : "游玩进度",
|
kindLabel: s.kind === "instance" ? "创作定稿" : "游玩进度",
|
||||||
}));
|
}));
|
||||||
json(res, 200, { saves });
|
json(res, 200, {
|
||||||
|
saves,
|
||||||
|
playWorkingInstanceId:
|
||||||
|
sessionManager.playWorkingInstanceId(bookId) ?? null,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
json(res, 400, {
|
json(res, 400, {
|
||||||
error: err instanceof Error ? err.message : "读取存档失败",
|
error: err instanceof Error ? err.message : "读取存档失败",
|
||||||
@@ -490,6 +504,28 @@ export async function handleBooksApi(
|
|||||||
return true;
|
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") {
|
if (req.method === "DELETE") {
|
||||||
try {
|
try {
|
||||||
sessionManager.deleteGameSnapshot(bookId, saveId);
|
sessionManager.deleteGameSnapshot(bookId, saveId);
|
||||||
|
|||||||
+182
-123
@@ -11,14 +11,13 @@ import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-sn
|
|||||||
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
|
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
|
||||||
import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js";
|
import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js";
|
||||||
import {
|
import {
|
||||||
CREATION_SEALED_WAITING_MESSAGE,
|
|
||||||
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
||||||
OPENING_OUTPUT_TAG,
|
OPENING_OUTPUT_TAG,
|
||||||
SLOT_CREATION_SEALED_BY_OPENING,
|
|
||||||
} from "../skills/opening-seal.js";
|
} from "../skills/opening-seal.js";
|
||||||
import {
|
import {
|
||||||
PLAY_WORKING_SNAPSHOT_ID,
|
PLAY_WORKING_SNAPSHOT_ID,
|
||||||
PLAY_WORKING_SNAPSHOT_LABEL,
|
PLAY_WORKING_SNAPSHOT_LABEL,
|
||||||
|
SLOT_PLAY_INSTANCE_ID,
|
||||||
SLOT_PLAY_LAYER_ACTIVE,
|
SLOT_PLAY_LAYER_ACTIVE,
|
||||||
isPlayLayerActive,
|
isPlayLayerActive,
|
||||||
} from "../skills/play-turn.js";
|
} from "../skills/play-turn.js";
|
||||||
@@ -209,6 +208,10 @@ export type SessionView = {
|
|||||||
/** 已从定稿开出聊天层(不是只把顶栏标成游玩) */
|
/** 已从定稿开出聊天层(不是只把顶栏标成游玩) */
|
||||||
playLayerActive: boolean;
|
playLayerActive: boolean;
|
||||||
playReady: boolean;
|
playReady: boolean;
|
||||||
|
/** 已落档至少一份产物(instance 快照) */
|
||||||
|
hasProduct: boolean;
|
||||||
|
/** 当前游玩所依据的产物 id */
|
||||||
|
playInstanceId?: string;
|
||||||
skillCatalog: SkillCatalogEntry[];
|
skillCatalog: SkillCatalogEntry[];
|
||||||
toolTrace: ToolTraceEntry[];
|
toolTrace: ToolTraceEntry[];
|
||||||
burst: BurstState;
|
burst: BurstState;
|
||||||
@@ -371,11 +374,11 @@ export class SessionManager {
|
|||||||
|
|
||||||
setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView {
|
setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView {
|
||||||
const s = this.require(sessionId);
|
const s = this.require(sessionId);
|
||||||
const session = s.runtime.getSession();
|
|
||||||
if (stage === "play" && !canEnterPlay(session)) {
|
|
||||||
throw new Error("实例尚未就绪,无法进入游玩");
|
|
||||||
}
|
|
||||||
if (stage === "play") {
|
if (stage === "play") {
|
||||||
|
s.runtime.prepareEnterPlay();
|
||||||
|
if (!canEnterPlay(s.runtime.getSession())) {
|
||||||
|
throw new Error("实例尚未就绪,无法进入游玩");
|
||||||
|
}
|
||||||
this.enterPlayLayer(s, { resume: true });
|
this.enterPlayLayer(s, { resume: true });
|
||||||
} else {
|
} else {
|
||||||
this.enterDesignLayer(s);
|
this.enterDesignLayer(s);
|
||||||
@@ -609,8 +612,11 @@ export class SessionManager {
|
|||||||
let blackboardItems = s.runtime.getBlackboard().exportItems();
|
let blackboardItems = s.runtime.getBlackboard().exportItems();
|
||||||
|
|
||||||
if (kind === "instance") {
|
if (kind === "instance") {
|
||||||
if (!canEnterPlay(runtimeSession) && !runtimeSession.slots.startupCompleted) {
|
s.runtime.prepareEnterPlay();
|
||||||
throw new Error("须先验收 Worker 集,才能保存创作定稿");
|
runtimeSession = structuredClone(s.runtime.getSession());
|
||||||
|
blackboardItems = s.runtime.getBlackboard().exportItems();
|
||||||
|
if (!canEnterPlay(runtimeSession)) {
|
||||||
|
throw new Error("须先完成收口或验收 Worker 集,才能保存产物");
|
||||||
}
|
}
|
||||||
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
||||||
runtimeSession,
|
runtimeSession,
|
||||||
@@ -626,6 +632,8 @@ export class SessionManager {
|
|||||||
bookId: s.bookId,
|
bookId: s.bookId,
|
||||||
label: trimmed,
|
label: trimmed,
|
||||||
kind,
|
kind,
|
||||||
|
instanceId:
|
||||||
|
kind === "run" ? this.currentPlayInstanceId(s) : undefined,
|
||||||
orchestratorId: skillPackId,
|
orchestratorId: skillPackId,
|
||||||
runtimeSession,
|
runtimeSession,
|
||||||
blackboardItems,
|
blackboardItems,
|
||||||
@@ -665,25 +673,30 @@ export class SessionManager {
|
|||||||
return this.saveGameSnapshot(sessionId, label, "run", note);
|
return this.saveGameSnapshot(sessionId, label, "run", note);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从已验收实例开一条新的游玩线(清空 run 层状态) */
|
/** 从已落档产物开一条新的游玩线(清空 run 层状态) */
|
||||||
async startNewPlayRun(sessionId: string): Promise<SessionView> {
|
async startNewPlayRun(
|
||||||
|
sessionId: string,
|
||||||
|
instanceId?: string,
|
||||||
|
): Promise<SessionView> {
|
||||||
const s = this.require(sessionId);
|
const s = this.require(sessionId);
|
||||||
if (!s.bookId) throw new Error("仅绑定作品时可新建游玩");
|
if (!s.bookId) throw new Error("仅绑定作品时可新建游玩");
|
||||||
const session = s.runtime.getSession();
|
this.persistCreationIfDesign(s);
|
||||||
if (!canEnterPlay(session)) {
|
s.runtime.prepareEnterPlay();
|
||||||
throw new Error("须先验收 Worker 集,才能开始游玩");
|
if (!canEnterPlay(s.runtime.getSession())) {
|
||||||
|
throw new Error("须先验收 Worker 集或完成收口,才能开始游玩");
|
||||||
}
|
}
|
||||||
this.archivePlayWorking(s.bookId);
|
this.archivePlayWorking(s.bookId);
|
||||||
this.enterPlayLayer(s, { resume: false });
|
this.enterPlayLayer(s, { resume: false, instanceId });
|
||||||
this.persist(s);
|
|
||||||
return this.toView(sessionId);
|
return this.toView(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 进入游玩层:resume 时恢复「当前游玩」,否则从定稿新开一条聊天 */
|
/** 进入游玩层:resume 时恢复「当前游玩」,否则从产物新开一条聊天 */
|
||||||
private enterPlayLayer(
|
private enterPlayLayer(
|
||||||
s: ManagedSession,
|
s: ManagedSession,
|
||||||
opts: { resume: boolean },
|
opts: { resume: boolean; instanceId?: string },
|
||||||
): void {
|
): void {
|
||||||
|
this.persistCreationIfDesign(s);
|
||||||
|
s.runtime.prepareEnterPlay();
|
||||||
const session = s.runtime.getSession();
|
const session = s.runtime.getSession();
|
||||||
if (!canEnterPlay(session)) {
|
if (!canEnterPlay(session)) {
|
||||||
throw new Error("实例尚未就绪,无法进入游玩");
|
throw new Error("实例尚未就绪,无法进入游玩");
|
||||||
@@ -704,11 +717,11 @@ export class SessionManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.beginNewPlayFromInstance(s);
|
this.beginNewPlayFromInstance(s, opts.instanceId);
|
||||||
if (s.bookId) this.savePlayWorking(s);
|
if (s.bookId) this.savePlayWorking(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回到创作层:先把当前聊天写入「当前游玩」,再加载定稿 */
|
/** 回到创作层:先把当前聊天写入「当前游玩」,再恢复创作过程 */
|
||||||
private enterDesignLayer(s: ManagedSession): void {
|
private enterDesignLayer(s: ManagedSession): void {
|
||||||
const session = s.runtime.getSession();
|
const session = s.runtime.getSession();
|
||||||
if (
|
if (
|
||||||
@@ -721,33 +734,18 @@ export class SessionManager {
|
|||||||
if (s.bookId && isPlayLayerActive(session.slots)) {
|
if (s.bookId && isPlayLayerActive(session.slots)) {
|
||||||
this.savePlayWorking(s);
|
this.savePlayWorking(s);
|
||||||
}
|
}
|
||||||
const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null;
|
this.restoreCreationFromDisk(s);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private beginNewPlayFromInstance(s: ManagedSession): void {
|
private beginNewPlayFromInstance(s: ManagedSession, instanceId?: string): void {
|
||||||
const keepId = s.runtime.getSession().id;
|
const keepId = s.runtime.getSession().id;
|
||||||
let runtimeSession: RuntimeSession;
|
let runtimeSession: RuntimeSession;
|
||||||
let blackboardItems: import("../types/blackboard.js").BlackboardItem[];
|
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) {
|
if (instance) {
|
||||||
runtimeSession = structuredClone(instance.runtimeSession);
|
runtimeSession = structuredClone(instance.runtimeSession);
|
||||||
blackboardItems = structuredClone(instance.blackboardItems);
|
blackboardItems = structuredClone(instance.blackboardItems);
|
||||||
@@ -764,6 +762,7 @@ export class SessionManager {
|
|||||||
...runtimeSession.slots,
|
...runtimeSession.slots,
|
||||||
uiLifecycleStage: "play",
|
uiLifecycleStage: "play",
|
||||||
[SLOT_PLAY_LAYER_ACTIVE]: true,
|
[SLOT_PLAY_LAYER_ACTIVE]: true,
|
||||||
|
[SLOT_PLAY_INSTANCE_ID]: instance?.id,
|
||||||
startupCompleted: true,
|
startupCompleted: true,
|
||||||
};
|
};
|
||||||
runtimeSession.phase = "waiting_user";
|
runtimeSession.phase = "waiting_user";
|
||||||
@@ -807,6 +806,13 @@ export class SessionManager {
|
|||||||
...runtimeSession.slots,
|
...runtimeSession.slots,
|
||||||
uiLifecycleStage: layer,
|
uiLifecycleStage: layer,
|
||||||
[SLOT_PLAY_LAYER_ACTIVE]: layer === "play" ? true : undefined,
|
[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,
|
startupCompleted: true,
|
||||||
};
|
};
|
||||||
if (layer === "play") {
|
if (layer === "play") {
|
||||||
@@ -817,12 +823,12 @@ export class SessionManager {
|
|||||||
runtimeSession.waitingReason = { kind: "input" };
|
runtimeSession.waitingReason = { kind: "input" };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const sealed = Boolean(runtimeSession.slots[SLOT_CREATION_SEALED_BY_OPENING]);
|
if (runtimeSession.phase === "done") {
|
||||||
runtimeSession.phase = "waiting_user";
|
runtimeSession.phase = "waiting_user";
|
||||||
runtimeSession.waitingReason = {
|
}
|
||||||
kind: "input",
|
if (!runtimeSession.waitingReason) {
|
||||||
...(sealed ? { message: CREATION_SEALED_WAITING_MESSAGE } : {}),
|
runtimeSession.waitingReason = { kind: "input" };
|
||||||
};
|
}
|
||||||
runtimeSession.pendingArtifactId = undefined;
|
runtimeSession.pendingArtifactId = undefined;
|
||||||
runtimeSession.pendingDecision = undefined;
|
runtimeSession.pendingDecision = undefined;
|
||||||
runtimeSession.currentWorkerId = undefined;
|
runtimeSession.currentWorkerId = undefined;
|
||||||
@@ -868,6 +874,7 @@ export class SessionManager {
|
|||||||
bookId: s.bookId,
|
bookId: s.bookId,
|
||||||
label: PLAY_WORKING_SNAPSHOT_LABEL,
|
label: PLAY_WORKING_SNAPSHOT_LABEL,
|
||||||
kind: "run",
|
kind: "run",
|
||||||
|
instanceId: this.currentPlayInstanceId(s),
|
||||||
orchestratorId:
|
orchestratorId:
|
||||||
sessionSkillPackId(runtimeSession) ??
|
sessionSkillPackId(runtimeSession) ??
|
||||||
(book ? bookSkillPackId(book) : "") ??
|
(book ? bookSkillPackId(book) : "") ??
|
||||||
@@ -909,19 +916,91 @@ export class SessionManager {
|
|||||||
s.runtime.getSession().id,
|
s.runtime.getSession().id,
|
||||||
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
||||||
"instance",
|
"instance",
|
||||||
"进入游玩前自动保存定稿",
|
"开玩前自动保存产物",
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} 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 {
|
private latestInstanceSnapshot(bookId: string): RunSnapshot | null {
|
||||||
const meta = this.listGameSnapshots(bookId).find((s) => s.kind === "instance");
|
const meta = this.listGameSnapshots(bookId).find((s) => s.kind === "instance");
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
return loadRunSnapshotFile(bookId, meta.id);
|
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 {
|
private playOpeningMessage(text: string): ChatMessage {
|
||||||
return {
|
return {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -935,7 +1014,7 @@ export class SessionManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从存档读档:替换当前作品进度,可继续创作 */
|
/** 读档:产物开玩,游玩存档续玩。不覆盖创作过程。 */
|
||||||
async loadGameSnapshot(bookId: string, snapshotId: string): Promise<SessionView> {
|
async loadGameSnapshot(bookId: string, snapshotId: string): Promise<SessionView> {
|
||||||
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
|
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
|
||||||
if (!snapshot) throw new Error("存档不存在");
|
if (!snapshot) throw new Error("存档不存在");
|
||||||
@@ -946,47 +1025,36 @@ export class SessionManager {
|
|||||||
throw new Error("存档与当前作品 skill 包不匹配,无法读档");
|
throw new Error("存档与当前作品 skill 包不匹配,无法读档");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dropBookSessions(bookId);
|
let sessionId = this.activeBookSessions.get(bookId);
|
||||||
|
if (!sessionId) {
|
||||||
const newSessionId = randomUUID();
|
await this.openBook(bookId);
|
||||||
const messages: ChatMessage[] = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[];
|
sessionId = this.activeBookSessions.get(bookId);
|
||||||
let runtimeSession = structuredClone(snapshot.runtimeSession);
|
}
|
||||||
let blackboardItems = snapshot.blackboardItems;
|
if (!sessionId) throw new Error("无法打开作品");
|
||||||
|
const s = this.require(sessionId);
|
||||||
|
|
||||||
if (snapshot.kind === "instance") {
|
if (snapshot.kind === "instance") {
|
||||||
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
this.persistCreationIfDesign(s);
|
||||||
runtimeSession,
|
if (isPlayLayerActive(s.runtime.getSession().slots)) {
|
||||||
blackboardItems,
|
this.archivePlayWorking(bookId);
|
||||||
}));
|
}
|
||||||
runtimeSession.slots = {
|
this.beginNewPlayFromInstance(s, snapshot.id);
|
||||||
...runtimeSession.slots,
|
this.savePlayWorking(s);
|
||||||
uiLifecycleStage: "design",
|
return this.toView(
|
||||||
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
|
sessionId,
|
||||||
};
|
true,
|
||||||
} else {
|
`已用产物「${snapshot.label}」开玩。创作流程仍在「创作」里。`,
|
||||||
runtimeSession.slots = {
|
);
|
||||||
...runtimeSession.slots,
|
|
||||||
uiLifecycleStage: "play",
|
|
||||||
[SLOT_PLAY_LAYER_ACTIVE]: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runtimeSession.id = newSessionId;
|
this.persistCreationIfDesign(s);
|
||||||
|
this.restoreSnapshotInPlace(s, snapshot, "play");
|
||||||
const resumeHint =
|
this.savePlayWorking(s);
|
||||||
snapshot.kind === "instance"
|
return this.toView(
|
||||||
? `已加载实例「${snapshot.label}」。可开始运行,或调整角色设定后再模拟。`
|
sessionId,
|
||||||
: `已从存档「${snapshot.label}」读档,可继续创作。`;
|
true,
|
||||||
|
`已从存档「${snapshot.label}」继续游玩。`,
|
||||||
return this.mountRestoredSession({
|
);
|
||||||
sessionId: newSessionId,
|
|
||||||
bookId,
|
|
||||||
skillPackId: runSnapshotSkillPackId(snapshot) ?? "",
|
|
||||||
runtimeSession,
|
|
||||||
blackboardItems,
|
|
||||||
messages,
|
|
||||||
resumeHint,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除单个存档 */
|
/** 删除单个存档 */
|
||||||
@@ -1438,9 +1506,6 @@ export class SessionManager {
|
|||||||
? s.runtime.getSession().artifacts.find((a) => a.id === pendingId)
|
? s.runtime.getSession().artifacts.find((a) => a.id === pendingId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const stageBefore = inferLifecycleStage(s.runtime.getSession());
|
const stageBefore = inferLifecycleStage(s.runtime.getSession());
|
||||||
const sealedBefore = Boolean(
|
|
||||||
s.runtime.getSession().slots[SLOT_CREATION_SEALED_BY_OPENING],
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const owned = await this.runExclusive(id, s, () =>
|
const owned = await this.runExclusive(id, s, () =>
|
||||||
s.runtime.acceptArtifact(undefined, opts),
|
s.runtime.acceptArtifact(undefined, opts),
|
||||||
@@ -1468,8 +1533,6 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.persist(s);
|
this.persist(s);
|
||||||
this.maybeSaveOpeningInstance(s, sealedBefore);
|
|
||||||
this.persist(s);
|
|
||||||
return this.toView(id);
|
return this.toView(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1862,43 +1925,32 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 选定开场后正式保存一份 instance 定稿(可进游玩的规格+开局) */
|
/** 游玩中只更新工作副本,不覆盖创作过程 */
|
||||||
private maybeSaveOpeningInstance(
|
private persist(s: ManagedSession): void {
|
||||||
s: ManagedSession,
|
|
||||||
sealedBefore: boolean,
|
|
||||||
): void {
|
|
||||||
if (sealedBefore) return;
|
|
||||||
if (!s.bookId) return;
|
if (!s.bookId) return;
|
||||||
|
const book = getBook(s.bookId);
|
||||||
|
if (!book) return;
|
||||||
const session = s.runtime.getSession();
|
const session = s.runtime.getSession();
|
||||||
if (!session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return;
|
if (
|
||||||
if (!canEnterPlay(session)) {
|
isPlayLayerActive(session.slots) ||
|
||||||
s.messages.push(
|
inferLifecycleStage(session) === "play"
|
||||||
this.msg(
|
) {
|
||||||
"system",
|
this.savePlayWorking(s);
|
||||||
"[创作收口] 开场已选定,但 Worker 集尚未验收,未写入创作定稿存档。",
|
this.syncBookSkill(s.bookId, session);
|
||||||
),
|
updateBook(s.bookId, { activeSessionId: session.id });
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
this.writeBookSession(s);
|
||||||
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}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
if (!s.bookId) return;
|
||||||
const book = getBook(s.bookId);
|
const book = getBook(s.bookId);
|
||||||
if (!book) return;
|
if (!book) return;
|
||||||
@@ -2545,6 +2597,13 @@ export class SessionManager {
|
|||||||
lifecycleStage,
|
lifecycleStage,
|
||||||
playLayerActive: isPlayLayerActive(session.slots),
|
playLayerActive: isPlayLayerActive(session.slots),
|
||||||
playReady: canEnterPlay(session),
|
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,
|
skillCatalog,
|
||||||
toolTrace: buildToolTrace(messages),
|
toolTrace: buildToolTrace(messages),
|
||||||
burst: buildBurstState(messages, session),
|
burst: buildBurstState(messages, session),
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ function buildWorldSimulatorCatalog(
|
|||||||
id: "declare-ready",
|
id: "declare-ready",
|
||||||
stage: "design",
|
stage: "design",
|
||||||
label: "实例就绪",
|
label: "实例就绪",
|
||||||
purpose: "Worker 集已 accept 即可进游玩;若走了开场白终节点,选定后会自动保存定稿。",
|
purpose: "收口或 Worker 集验收后可保存产物;产物可单独开玩。",
|
||||||
status: hasAcceptedWorkerSet(session) ? "done" : "pending",
|
status: hasAcceptedWorkerSet(session) ? "done" : "pending",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2292,7 +2292,23 @@ export function isCloserModule(
|
|||||||
name?: string,
|
name?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (module?.closer === true) return true;
|
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(
|
export function findCloserStepIndex(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 开场白终节点:选定后落库、关 DAG。
|
* 开场白终节点:选定后落库开场。不关 DAG;产物靠用户「保存」拆出。
|
||||||
* 能力 `opening-setup` 与磁盘 worker `opening-generator` 共用这条收口。
|
* 能力 `opening-setup` 与磁盘 worker `opening-generator` 共用这条收口。
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
@@ -22,21 +22,34 @@ export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening";
|
|||||||
export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex";
|
export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex";
|
||||||
|
|
||||||
export const CREATION_SEALED_WAITING_MESSAGE =
|
export const CREATION_SEALED_WAITING_MESSAGE =
|
||||||
"开场已选定,创作已收口并保存。可切换到「游玩」。";
|
"开场已选定。可保存为产物后开玩,或继续补节点。";
|
||||||
|
|
||||||
export const INSTANCE_OPENING_SNAPSHOT_LABEL = "创作定稿(开场)";
|
export const INSTANCE_OPENING_SNAPSHOT_LABEL = "定稿";
|
||||||
|
|
||||||
export function isOpeningLockTag(tag: string): boolean {
|
export function isOpeningLockTag(tag: string): boolean {
|
||||||
const t = tag.trim();
|
const t = tag.trim();
|
||||||
return t === OPENING_OUTPUT_TAG || t === OPENING_INITIAL_VARS_TAG;
|
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: {
|
export function isOpeningSealArtifact(artifact: {
|
||||||
workerId?: string;
|
workerId?: string;
|
||||||
|
stepId?: string;
|
||||||
outputTags?: readonly string[];
|
outputTags?: readonly string[];
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if ((artifact.workerId ?? "").trim() === "opening-generator") return true;
|
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 = {
|
export type OpeningVariable = {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
export const SLOT_PLAY_TURN_QUEUE = "playTurnQueue";
|
export const SLOT_PLAY_TURN_QUEUE = "playTurnQueue";
|
||||||
export const SLOT_PLAY_LAYER_ACTIVE = "playLayerActive";
|
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_ID = "play-working";
|
||||||
export const PLAY_WORKING_SNAPSHOT_LABEL = "当前游玩";
|
export const PLAY_WORKING_SNAPSHOT_LABEL = "当前游玩";
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||||
import type { RuntimeSession } from "../types/runtime.js";
|
import type { RuntimeSession } from "../types/runtime.js";
|
||||||
import { isPlayLayerActive } from "./play-turn.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 {
|
import {
|
||||||
deriveDesignStageScope,
|
deriveDesignStageScope,
|
||||||
deriveOnDemandWorkerScope,
|
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 {
|
export function canEnterPlay(session: RuntimeSession): boolean {
|
||||||
return hasAcceptedWorkerSet(session);
|
return hasAcceptedWorkerSet(session) || hasAcceptedCloser(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferLifecycleStage(session: RuntimeSession): LifecycleStage {
|
export function inferLifecycleStage(session: RuntimeSession): LifecycleStage {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type RunSnapshot = {
|
|||||||
bookId: string;
|
bookId: string;
|
||||||
label: string;
|
label: string;
|
||||||
kind: SnapshotKind;
|
kind: SnapshotKind;
|
||||||
|
/** 游玩存档所属的产物(instance)id */
|
||||||
|
instanceId?: string;
|
||||||
/** skill 包 id(兼容旧字段名) */
|
/** skill 包 id(兼容旧字段名) */
|
||||||
orchestratorId?: string;
|
orchestratorId?: string;
|
||||||
runtimeSession: RuntimeSession;
|
runtimeSession: RuntimeSession;
|
||||||
@@ -32,6 +34,8 @@ export type RunSnapshotMeta = {
|
|||||||
bookId: string;
|
bookId: string;
|
||||||
label: string;
|
label: string;
|
||||||
kind: SnapshotKind;
|
kind: SnapshotKind;
|
||||||
|
/** 游玩存档所属的产物(instance)id */
|
||||||
|
instanceId?: string;
|
||||||
/** skill 包 id(兼容旧字段名) */
|
/** skill 包 id(兼容旧字段名) */
|
||||||
orchestratorId?: string;
|
orchestratorId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -44,6 +48,7 @@ export function toRunSnapshotMeta(snapshot: RunSnapshot): RunSnapshotMeta {
|
|||||||
bookId: snapshot.bookId,
|
bookId: snapshot.bookId,
|
||||||
label: snapshot.label,
|
label: snapshot.label,
|
||||||
kind: snapshot.kind,
|
kind: snapshot.kind,
|
||||||
|
instanceId: snapshot.instanceId,
|
||||||
orchestratorId: snapshot.orchestratorId,
|
orchestratorId: snapshot.orchestratorId,
|
||||||
createdAt: snapshot.createdAt,
|
createdAt: snapshot.createdAt,
|
||||||
note: snapshot.note,
|
note: snapshot.note,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
spawnRepeatableCreationStep,
|
spawnRepeatableCreationStep,
|
||||||
spawnInstanceFromPrototype,
|
spawnInstanceFromPrototype,
|
||||||
hasSelectableCreationWork,
|
hasSelectableCreationWork,
|
||||||
|
hasAcceptedCloserStep,
|
||||||
removeUnstartedInstance,
|
removeUnstartedInstance,
|
||||||
looksLikeCreationFlowDoc,
|
looksLikeCreationFlowDoc,
|
||||||
parseCreationFlow,
|
parseCreationFlow,
|
||||||
@@ -1269,4 +1270,21 @@ modules:
|
|||||||
if ("error" in ok) return;
|
if ("error" in ok) return;
|
||||||
expect(ok.step.depends_on).toEqual(["生成规则#1"]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ describe("opening-seal", () => {
|
|||||||
outputTags: ["输出.开场白"],
|
outputTags: ["输出.开场白"],
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isOpeningSealArtifact({
|
||||||
|
workerId: "design-step",
|
||||||
|
stepId: "开场白与开场变量",
|
||||||
|
outputTags: ["artifact.flat.v1"],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
isOpeningSealArtifact({
|
isOpeningSealArtifact({
|
||||||
workerId: "design-step",
|
workerId: "design-step",
|
||||||
|
|||||||
+18
-16
@@ -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");
|
let session = createSession("default");
|
||||||
const artifact = createArtifact({
|
const artifact = createArtifact({
|
||||||
workerId: "design-step",
|
workerId: "design-step",
|
||||||
@@ -441,17 +441,17 @@ describe("phase machine", () => {
|
|||||||
type: "user_accepted_artifact",
|
type: "user_accepted_artifact",
|
||||||
payload: { artifactId: artifact.id },
|
payload: { artifactId: artifact.id },
|
||||||
});
|
});
|
||||||
expect(accepted.session.waitingReason?.kind).toBe("input");
|
expect(accepted.session.phase).toBe("running");
|
||||||
expect(accepted.session.waitingReason).toMatchObject({
|
expect(accepted.effects).toEqual([
|
||||||
kind: "input",
|
{ type: "seal_creation_opening" },
|
||||||
});
|
{ type: "propose_next_creation_step" },
|
||||||
expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
accepted.effects.some((e) => e.type === "invoke_main_agent"),
|
accepted.effects.some((e) => e.type === "invoke_main_agent"),
|
||||||
).toBe(false);
|
).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");
|
let session = createSession("default");
|
||||||
const artifact = createArtifact({
|
const artifact = createArtifact({
|
||||||
workerId: "opening-generator",
|
workerId: "opening-generator",
|
||||||
@@ -469,11 +469,14 @@ describe("phase machine", () => {
|
|||||||
type: "user_accepted_artifact",
|
type: "user_accepted_artifact",
|
||||||
payload: { artifactId: artifact.id },
|
payload: { artifactId: artifact.id },
|
||||||
});
|
});
|
||||||
expect(accepted.session.waitingReason?.kind).toBe("input");
|
expect(accepted.session.phase).toBe("running");
|
||||||
expect(accepted.effects).toEqual([{ type: "seal_creation_opening" }]);
|
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");
|
let session = createSession("default");
|
||||||
session = {
|
session = {
|
||||||
...session,
|
...session,
|
||||||
@@ -481,14 +484,13 @@ describe("phase machine", () => {
|
|||||||
waitingReason: { kind: "input" },
|
waitingReason: { kind: "input" },
|
||||||
slots: { creationSealedByOpening: true, designInstanceReady: true },
|
slots: { creationSealedByOpening: true, designInstanceReady: true },
|
||||||
};
|
};
|
||||||
const blocked = applyEvent(session, {
|
const continued = applyEvent(session, {
|
||||||
type: "user_submitted_input",
|
type: "user_submitted_input",
|
||||||
payload: { text: "我买金首饰" },
|
payload: { text: "再补一个 NPC" },
|
||||||
});
|
});
|
||||||
expect(blocked.effects).toEqual([
|
expect(continued.session.phase).toBe("running");
|
||||||
{ type: "emit_message", message: "创作已收口并保存。请切换到「游玩」开始。" },
|
expect(continued.effects).toEqual([{ type: "invoke_main_agent" }]);
|
||||||
]);
|
expect(continued.effects.some((e) => e.type === "run_play_turn")).toBe(false);
|
||||||
expect(blocked.effects.some((e) => e.type === "run_play_turn")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("play input starts a play turn even after opening seal", () => {
|
it("play input starts a play turn even after opening seal", () => {
|
||||||
|
|||||||
@@ -293,4 +293,43 @@ describe("phase runtime", () => {
|
|||||||
flow?.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
|
flow?.steps.find((s) => s.name === "开场白与开场变量")?.depends_on,
|
||||||
).toEqual(["美学纲领与交互范式"]);
|
).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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ describe("run snapshot store", () => {
|
|||||||
expect(loaded!.note).toBe("测试快照");
|
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 () => {
|
it("listRunSnapshots returns saved items newest first", async () => {
|
||||||
const { saveRunSnapshot, listRunSnapshots } = await import(
|
const { saveRunSnapshot, listRunSnapshots } = await import(
|
||||||
"../src/book/run-snapshot-store.js"
|
"../src/book/run-snapshot-store.js"
|
||||||
@@ -156,6 +170,7 @@ describe("run snapshot store", () => {
|
|||||||
runtime: { getSession: () => { slots: Record<string, unknown> }; getBlackboard: () => { write: (i: object) => void } };
|
runtime: { getSession: () => { slots: Record<string, unknown> }; getBlackboard: () => { write: (i: object) => void } };
|
||||||
};
|
};
|
||||||
managed.runtime.getSession().slots.startupCompleted = true;
|
managed.runtime.getSession().slots.startupCompleted = true;
|
||||||
|
managed.runtime.getSession().slots.designInstanceReady = true;
|
||||||
const bb = managed.runtime.getBlackboard();
|
const bb = managed.runtime.getBlackboard();
|
||||||
bb.write({ tag: "情境.实验.设定", content: "囚徒困境", source: "worker" });
|
bb.write({ tag: "情境.实验.设定", content: "囚徒困境", source: "worker" });
|
||||||
bb.write({ tag: "运行.事件流", content: "第1轮", 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);
|
expect(stored.blackboardItems.some((i) => i.tag === "运行.事件流")).toBe(false);
|
||||||
|
|
||||||
const loaded = await mgr.loadGameSnapshot(book.id, save.id);
|
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<string, unknown> } };
|
||||||
|
};
|
||||||
|
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("丧尸街巷");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Blackboard } from "../src/blackboard/blackboard.js";
|
|||||||
import { createSession } from "../src/runtime/phase-machine.js";
|
import { createSession } from "../src/runtime/phase-machine.js";
|
||||||
import {
|
import {
|
||||||
buildInstanceWorkerDeclaration,
|
buildInstanceWorkerDeclaration,
|
||||||
|
canEnterPlay,
|
||||||
inferLifecycleStage,
|
inferLifecycleStage,
|
||||||
isWorkerDeclared,
|
isWorkerDeclared,
|
||||||
readWorkerSetYamlForDeclaration,
|
readWorkerSetYamlForDeclaration,
|
||||||
@@ -108,4 +109,15 @@ describe("worker-declaration", () => {
|
|||||||
};
|
};
|
||||||
expect(inferLifecycleStage(s3)).toBe("play");
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-13
@@ -76,6 +76,34 @@ function isPlayView(view) {
|
|||||||
return Boolean(view?.playLayerActive || view?.lifecycleStage === "play");
|
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) {
|
function renderLiveStreamBody(live, view) {
|
||||||
const thinking = (live?.thinking || view?.agentThinking || "").trim();
|
const thinking = (live?.thinking || view?.agentThinking || "").trim();
|
||||||
const output = (live?.output ?? "").trim();
|
const output = (live?.output ?? "").trim();
|
||||||
@@ -848,16 +876,19 @@ export function renderLifecycle(view) {
|
|||||||
document.body.dataset.lifecycle = stage;
|
document.body.dataset.lifecycle = stage;
|
||||||
const toggle = document.getElementById("lifecycle-toggle");
|
const toggle = document.getElementById("lifecycle-toggle");
|
||||||
if (!toggle) return;
|
if (!toggle) return;
|
||||||
|
const ready = Boolean(view?.playReady || view?.hasProduct);
|
||||||
|
toggle.hidden = !ready;
|
||||||
toggle.querySelectorAll("[data-stage]").forEach((btn) => {
|
toggle.querySelectorAll("[data-stage]").forEach((btn) => {
|
||||||
const s = btn.getAttribute("data-stage");
|
const s = btn.getAttribute("data-stage");
|
||||||
btn.classList.toggle("active", s === stage);
|
btn.classList.toggle("active", s === stage);
|
||||||
if (s === "play") {
|
if (s === "play") {
|
||||||
btn.disabled = !view.playReady;
|
btn.disabled = !ready;
|
||||||
btn.title = view.playReady
|
btn.title = ready
|
||||||
? "进入游玩 / 写作"
|
? "用已保存的产物开玩"
|
||||||
: "请先验收 Worker 集(创作定稿)后再切换";
|
: "请先保存产物后再开玩";
|
||||||
} else {
|
} else {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
btn.title = "回到创作";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4312,7 +4343,12 @@ function renderSpeakWorkspace(view) {
|
|||||||
let hint = "在底栏输入你的想法。";
|
let hint = "在底栏输入你的想法。";
|
||||||
let guideHtml = "";
|
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 = "选要做的节点";
|
title = "选要做的节点";
|
||||||
hint = "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发。";
|
hint = "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发。";
|
||||||
} else if (showOpening) {
|
} else if (showOpening) {
|
||||||
@@ -4350,13 +4386,8 @@ function renderSpeakWorkspace(view) {
|
|||||||
? proposedOutputCopy(view.proposedNextStep)
|
? proposedOutputCopy(view.proposedNextStep)
|
||||||
: view.focus?.detail || "确认执行,或在底栏说明意见。";
|
: view.focus?.detail || "确认执行,或在底栏说明意见。";
|
||||||
} else if (wr?.kind === "input") {
|
} else if (wr?.kind === "input") {
|
||||||
if (wr.message && /创作已收口/.test(wr.message)) {
|
title = "继续说";
|
||||||
title = "创作已收口";
|
hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。";
|
||||||
hint = wr.message;
|
|
||||||
} else {
|
|
||||||
title = "继续说";
|
|
||||||
hint = view.hints?.[0] || wr.message || "直接输入你的想法或补充。";
|
|
||||||
}
|
|
||||||
} else if (view.uiPrompt && !hasUserMessages(view)) {
|
} else if (view.uiPrompt && !hasUserMessages(view)) {
|
||||||
title = "描述你想创作什么";
|
title = "描述你想创作什么";
|
||||||
hint = String(view.uiPrompt).trim().slice(0, 280);
|
hint = String(view.uiPrompt).trim().slice(0, 280);
|
||||||
@@ -4377,9 +4408,23 @@ function renderSpeakWorkspace(view) {
|
|||||||
interactive: flowInteractive,
|
interactive: flowInteractive,
|
||||||
})
|
})
|
||||||
: "";
|
: "";
|
||||||
|
const enterPlay = canOfferEnterPlay(view)
|
||||||
|
? `<div class="workspace-intent-actions">
|
||||||
|
${
|
||||||
|
canOfferSaveProduct(view)
|
||||||
|
? `<button type="button" class="btn btn-primary" data-save-product>保存定稿</button>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
<button type="button" class="btn${
|
||||||
|
canOfferSaveProduct(view) ? "" : " btn-primary"
|
||||||
|
}" data-enter-play>${view.hasProduct ? "用产物开玩" : "开玩"}</button>
|
||||||
|
</div>`
|
||||||
|
: "";
|
||||||
return `<section class="workspace-intent">
|
return `<section class="workspace-intent">
|
||||||
<div class="workspace-intent-head">
|
<div class="workspace-intent-head">
|
||||||
<span class="workspace-surface-kicker">${flowInteractive ? "流程" : "说话"}</span>
|
<span class="workspace-surface-kicker">${
|
||||||
|
canOfferSaveProduct(view) ? "落档" : flowInteractive ? "流程" : "说话"
|
||||||
|
}</span>
|
||||||
${leaveStepControlHtml(view)}
|
${leaveStepControlHtml(view)}
|
||||||
</div>
|
</div>
|
||||||
<h2 class="workspace-intent-title">${esc(title)}</h2>
|
<h2 class="workspace-intent-title">${esc(title)}</h2>
|
||||||
@@ -4388,6 +4433,7 @@ function renderSpeakWorkspace(view) {
|
|||||||
${guideHtml}
|
${guideHtml}
|
||||||
${intake}
|
${intake}
|
||||||
${flowHtml}
|
${flowHtml}
|
||||||
|
${enterPlay}
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4586,6 +4632,12 @@ export function renderMessageFeed(view, loading, handlers = {}) {
|
|||||||
stage.innerHTML = renderSpeakWorkspace(view);
|
stage.innerHTML = renderSpeakWorkspace(view);
|
||||||
wireCreationFlowGraph(stage, handlers);
|
wireCreationFlowGraph(stage, handlers);
|
||||||
wireLeaveStepControl(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);
|
if (panelFeed) ensureQuestionsHostIn(panelFeed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+256
-60
@@ -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 { downloadMarkdown, sessionToMarkdown } from "./export.js";
|
||||||
import { renderIntakePanel } from "./intake-ui.js";
|
import { renderIntakePanel } from "./intake-ui.js";
|
||||||
import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
|
import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js";
|
||||||
@@ -24,6 +31,8 @@ let bookSelectMode = false;
|
|||||||
let selectedBookIds = new Set();
|
let selectedBookIds = new Set();
|
||||||
let activePlaySaveId = null;
|
let activePlaySaveId = null;
|
||||||
const playSavesByBook = new Map();
|
const playSavesByBook = new Map();
|
||||||
|
const playWorkingInstanceByBook = new Map();
|
||||||
|
const expandedProductsByBook = new Map();
|
||||||
const playSavesLoading = new Set();
|
const playSavesLoading = new Set();
|
||||||
let bookMenuBookId = null;
|
let bookMenuBookId = null;
|
||||||
let livePollTimer = 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") {
|
if (isFlowPlanReview(view) || wr?.kind === "pick_creation_step") {
|
||||||
return {
|
return {
|
||||||
id: "speak",
|
id: "speak",
|
||||||
@@ -758,12 +778,30 @@ function renderComposer(view, loading) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (spec.mode === "pick_step") {
|
if (spec.mode === "pick_step") {
|
||||||
|
const saveProduct = canOfferSaveProduct(view)
|
||||||
|
? `<button type="button" class="btn btn-primary" data-act="save-product">保存定稿</button>`
|
||||||
|
: "";
|
||||||
|
const enterPlay = canOfferEnterPlay(view)
|
||||||
|
? `<button type="button" class="btn" data-act="enter-play">${
|
||||||
|
view.hasProduct ? "用产物开玩" : "开玩"
|
||||||
|
}</button>`
|
||||||
|
: "";
|
||||||
|
const sendCls =
|
||||||
|
canOfferSaveProduct(view) || canOfferEnterPlay(view)
|
||||||
|
? "btn composer-btn"
|
||||||
|
: "btn btn-primary composer-btn";
|
||||||
const shell = composerInputShell(spec, {
|
const shell = composerInputShell(spec, {
|
||||||
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(spec.placeholder)}"></textarea>`,
|
textareaHtml: `<textarea id="composer-input" rows="1" placeholder="${esc(spec.placeholder)}"></textarea>`,
|
||||||
trailing: `<button type="submit" class="btn btn-primary composer-btn" data-act="pick-replan" disabled title="写下意见后再发;点图上节点即确认并进入">${esc(spec.submitLabel || "发送")}</button>`,
|
trailing: `${saveProduct}${enterPlay}<button type="submit" class="${sendCls}" data-act="pick-replan" disabled title="写下意见后再发;点图上节点即确认并进入">${esc(spec.submitLabel || "发送")}</button>`,
|
||||||
});
|
});
|
||||||
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
|
root.innerHTML = `<form class="composer-form" id="composer-form">${shell}</form>`;
|
||||||
wireComposerForm({ requiresText: true });
|
wireComposerForm({ requiresText: true });
|
||||||
|
root.querySelector("[data-act=save-product]")?.addEventListener("click", () => {
|
||||||
|
void saveCurrentInstance();
|
||||||
|
});
|
||||||
|
root.querySelector("[data-act=enter-play]")?.addEventListener("click", () => {
|
||||||
|
enterPlayNow();
|
||||||
|
});
|
||||||
applyPendingComposerDraft();
|
applyPendingComposerDraft();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -956,8 +994,49 @@ function playRunsForBook(bookId) {
|
|||||||
return (playSavesByBook.get(bookId) ?? []).filter((s) => s.kind === "run");
|
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) {
|
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);
|
const saves = playSavesByBook.get(bookId);
|
||||||
if (!Array.isArray(saves)) return false;
|
if (!Array.isArray(saves)) return false;
|
||||||
return saves.some((s) => s.kind === "instance" || s.kind === "run");
|
return saves.some((s) => s.kind === "instance" || s.kind === "run");
|
||||||
@@ -980,8 +1059,10 @@ async function fetchPlaySavesForBook(bookId, force = false) {
|
|||||||
try {
|
try {
|
||||||
const data = await api(`/api/books/${encodeURIComponent(bookId)}/saves`);
|
const data = await api(`/api/books/${encodeURIComponent(bookId)}/saves`);
|
||||||
playSavesByBook.set(bookId, data.saves ?? []);
|
playSavesByBook.set(bookId, data.saves ?? []);
|
||||||
|
playWorkingInstanceByBook.set(bookId, data.playWorkingInstanceId ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
playSavesByBook.set(bookId, []);
|
playSavesByBook.set(bookId, []);
|
||||||
|
playWorkingInstanceByBook.set(bookId, null);
|
||||||
} finally {
|
} finally {
|
||||||
playSavesLoading.delete(bookId);
|
playSavesLoading.delete(bookId);
|
||||||
renderBookList();
|
renderBookList();
|
||||||
@@ -1107,10 +1188,12 @@ function renderExplorerRow({
|
|||||||
actionClass,
|
actionClass,
|
||||||
onClick,
|
onClick,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onRename,
|
||||||
child,
|
child,
|
||||||
twistie,
|
twistie,
|
||||||
expanded,
|
expanded,
|
||||||
onTwistie,
|
onTwistie,
|
||||||
|
title,
|
||||||
}) {
|
}) {
|
||||||
const row = document.createElement("button");
|
const row = document.createElement("button");
|
||||||
row.type = "button";
|
row.type = "button";
|
||||||
@@ -1125,6 +1208,7 @@ function renderExplorerRow({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
if (bookId) row.dataset.bookId = bookId;
|
if (bookId) row.dataset.bookId = bookId;
|
||||||
|
if (title) row.title = title;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
${twistie ? `<span class="explorer-twist" data-twist>${expanded ? "▾" : "▸"}</span>` : ""}
|
${twistie ? `<span class="explorer-twist" data-twist>${expanded ? "▾" : "▸"}</span>` : ""}
|
||||||
<span class="explorer-main">
|
<span class="explorer-main">
|
||||||
@@ -1136,10 +1220,17 @@ function renderExplorerRow({
|
|||||||
if (e.target.closest(".explorer-del") || e.target.closest("[data-twist]")) return;
|
if (e.target.closest(".explorer-del") || e.target.closest("[data-twist]")) return;
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
});
|
});
|
||||||
row.querySelector("[data-twist]")?.addEventListener("click", (e) => {
|
if (onRename) {
|
||||||
e.stopPropagation();
|
row.addEventListener("dblclick", (e) => {
|
||||||
onTwistie?.();
|
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) => {
|
row.querySelector(".explorer-del")?.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete?.();
|
onDelete?.();
|
||||||
@@ -1157,11 +1248,16 @@ function renderExplorerRow({
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBookBranch(book, list, { nested = false } = {}) {
|
function renderBookBranch(book, list) {
|
||||||
const loading = playSavesLoading.has(book.id);
|
const loading = playSavesLoading.has(book.id);
|
||||||
const saves = playSavesByBook.get(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 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) {
|
if (loading && saves === undefined) {
|
||||||
const hint = document.createElement("p");
|
const hint = document.createElement("p");
|
||||||
@@ -1171,56 +1267,96 @@ function renderBookBranch(book, list, { nested = false } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ready) {
|
for (const product of products) {
|
||||||
list.appendChild(
|
const isLatest = product.id === latestId;
|
||||||
renderExplorerRow({
|
const isPlayingHere = playHere && playingInstanceId === product.id;
|
||||||
name: "当前",
|
const hasWorking = playingInstanceId === product.id;
|
||||||
meta: playHere && !activePlaySaveId ? "进行中" : "工作副本",
|
const expanded = isProductExpanded(book.id, product.id, isPlayingHere);
|
||||||
active: playHere && !activePlaySaveId,
|
const runs = runsForProduct(book.id, product.id, products);
|
||||||
child: nested,
|
const wrap = document.createElement("div");
|
||||||
onClick: () => void openCurrentPlay(book.id),
|
wrap.className = `explorer-product${expanded ? " is-open" : ""}`;
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const s of playRunsForBook(book.id)) {
|
const header = renderExplorerRow({
|
||||||
const when = formatSaveWhen(s.createdAt);
|
name: product.label,
|
||||||
list.appendChild(
|
meta: isLatest ? "最新" : "",
|
||||||
renderExplorerRow({
|
twistie: true,
|
||||||
name: s.label,
|
expanded,
|
||||||
meta: when ? `游玩 · ${when}` : "游玩进度",
|
title: "点开游玩存档,双击改名",
|
||||||
active: playHere && activePlaySaveId === s.id,
|
onTwistie: () => toggleProductExpanded(book.id, product.id),
|
||||||
child: nested,
|
onClick: () => toggleProductExpanded(book.id, product.id),
|
||||||
actionClass: "explorer-file",
|
onRename: () => void renamePlaySave(book.id, product.id, product.label),
|
||||||
onClick: () => void loadPlaySave(book.id, s.id),
|
onDelete: () => void deletePlaySave(book.id, product.id, product.label, "落档"),
|
||||||
onDelete: () => void deletePlaySave(book.id, s.id, s.label),
|
});
|
||||||
}),
|
header.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||||
);
|
wrap.appendChild(header);
|
||||||
}
|
|
||||||
|
|
||||||
if (ready) {
|
if (expanded) {
|
||||||
list.appendChild(
|
const drop = document.createElement("div");
|
||||||
renderExplorerRow({
|
drop.className = "explorer-drop";
|
||||||
name: "+ 新开一局",
|
drop.setAttribute("role", "group");
|
||||||
actionClass: "explorer-action",
|
drop.setAttribute("aria-label", `${product.label} 的游玩存档`);
|
||||||
child: nested,
|
|
||||||
onClick: () => void startNewPlayForBook(book.id),
|
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) {
|
function renderBookContents(book, list) {
|
||||||
const skill = book.activeSkillName ?? book.activeSkillId ?? book.orchestratorName ?? "实例设计";
|
const designing = book.id === activeBookId && !isPlayNow();
|
||||||
list.appendChild(
|
list.appendChild(
|
||||||
renderExplorerRow({
|
renderExplorerRow({
|
||||||
name: "创作",
|
name: "继续创作",
|
||||||
meta: skill,
|
meta: designing ? "进行中" : "",
|
||||||
active: book.id === activeBookId && !isPlayNow(),
|
active: designing,
|
||||||
onClick: () => void openBookDesign(book.id),
|
onClick: () => void openBookDesign(book.id),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
renderBookBranch(book, list, { nested: false });
|
renderBookBranch(book, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBookList() {
|
function renderBookList() {
|
||||||
@@ -1439,8 +1575,25 @@ async function loadPlaySave(bookId, saveId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePlaySave(bookId, saveId, label) {
|
async function renamePlaySave(bookId, saveId, currentLabel) {
|
||||||
if (!confirm(`删除游玩存档「${label}」?`)) return;
|
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 {
|
try {
|
||||||
await api(
|
await api(
|
||||||
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`,
|
`/api/books/${encodeURIComponent(bookId)}/saves/${encodeURIComponent(saveId)}`,
|
||||||
@@ -1453,17 +1606,25 @@ async function deletePlaySave(bookId, saveId, label) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startNewPlayForBook(bookId) {
|
async function startNewPlayForBook(bookId, instanceId) {
|
||||||
if (!bookPlayReady(bookId)) {
|
const thisView = lastView?.bookId === bookId ? lastView : null;
|
||||||
alert("须先验收 Worker 集");
|
if (!bookPlayReady(bookId) && !thisView?.hasProduct && !canOfferEnterPlay(thisView)) {
|
||||||
|
alert("请先保存产物,或完成收口后再开玩");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
navigateToBook(bookId);
|
navigateToBook(bookId);
|
||||||
if (lastView?.bookId === bookId) renderSession(lastView, true);
|
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;
|
activePlaySaveId = null;
|
||||||
|
if (instanceId) {
|
||||||
|
expandedProductsByBook.set(bookId, new Set([instanceId]));
|
||||||
|
}
|
||||||
renderSession(data.session, false);
|
renderSession(data.session, false);
|
||||||
|
void fetchPlaySavesForBook(bookId, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (lastView) renderSession(lastView, false);
|
if (lastView) renderSession(lastView, false);
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
@@ -1549,6 +1710,8 @@ function closeWorkspace({ emptyCopy = "" } = {}) {
|
|||||||
if (btnInst) btnInst.hidden = true;
|
if (btnInst) btnInst.hidden = true;
|
||||||
const btnExport = $("btn-export");
|
const btnExport = $("btn-export");
|
||||||
if (btnExport) btnExport.disabled = true;
|
if (btnExport) btnExport.disabled = true;
|
||||||
|
const toggle = $("lifecycle-toggle");
|
||||||
|
if (toggle) toggle.hidden = true;
|
||||||
$("message-feed").innerHTML = emptyCopy ? `<p class="empty">${emptyCopy}</p>` : "";
|
$("message-feed").innerHTML = emptyCopy ? `<p class="empty">${emptyCopy}</p>` : "";
|
||||||
const stage = $("workspace-stage");
|
const stage = $("workspace-stage");
|
||||||
if (stage) {
|
if (stage) {
|
||||||
@@ -1683,6 +1846,8 @@ function renderSession(view, loading = false) {
|
|||||||
onViewStepArtifact: (stepId, name) => showStepArtifactDialog(stepId, name),
|
onViewStepArtifact: (stepId, name) => showStepArtifactDialog(stepId, name),
|
||||||
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
|
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
|
||||||
onLeaveCreationStep: () => runAction("leave_step"),
|
onLeaveCreationStep: () => runAction("leave_step"),
|
||||||
|
onEnterPlay: () => enterPlayNow(),
|
||||||
|
onSaveProduct: () => void saveCurrentInstance(),
|
||||||
});
|
});
|
||||||
renderComposer(view, loading);
|
renderComposer(view, loading);
|
||||||
if (loading) startLivePoll();
|
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) {
|
async function setLifecycle(stage) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
if (stage === lastView?.lifecycleStage) {
|
if (stage === lastView?.lifecycleStage) {
|
||||||
@@ -1942,8 +2115,8 @@ async function setLifecycle(stage) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (stage === "play" && !lastView?.playReady) {
|
if (stage === "play" && !lastView?.playReady && !canOfferEnterPlay(lastView) && !lastView?.hasProduct) {
|
||||||
alert("请先验收 Worker 集(创作定稿)后再进入游玩");
|
alert("请先保存产物,或完成收口后再开玩");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1953,6 +2126,7 @@ async function setLifecycle(stage) {
|
|||||||
});
|
});
|
||||||
if (stage === "design") activePlaySaveId = null;
|
if (stage === "design") activePlaySaveId = null;
|
||||||
renderSession(view, false);
|
renderSession(view, false);
|
||||||
|
if (activeBookId) void fetchPlaySavesForBook(activeBookId, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message || "无法切换阶段");
|
alert(err.message || "无法切换阶段");
|
||||||
if (lastView) renderSession(lastView, false);
|
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() {
|
async function saveCurrentInstance() {
|
||||||
if (!activeBookId || !lastView?.playReady) return alert("须先验收 Worker 集");
|
if (!activeBookId || (!lastView?.playReady && !canOfferSaveProduct(lastView))) {
|
||||||
const label = prompt("创作定稿存档名称");
|
return alert("须先完成收口或验收 Worker 集");
|
||||||
|
}
|
||||||
|
const label = prompt("落档名称", defaultArchiveLabel());
|
||||||
if (!label?.trim()) return;
|
if (!label?.trim()) return;
|
||||||
try {
|
try {
|
||||||
await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, {
|
await api(`/api/books/${encodeURIComponent(activeBookId)}/saves`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ label: label.trim(), sessionId, kind: "instance" }),
|
body: JSON.stringify({ label: label.trim(), sessionId, kind: "instance" }),
|
||||||
});
|
});
|
||||||
playSavesByBook.delete(activeBookId);
|
if (lastView) {
|
||||||
void fetchPlaySavesForBook(activeBookId, true);
|
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) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
}
|
}
|
||||||
@@ -2223,6 +2414,11 @@ function exportSession() {
|
|||||||
downloadMarkdown(`${name}.md`, sessionToMarkdown(lastView));
|
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-new-book")?.addEventListener("click", openNewBookDialog);
|
||||||
$("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close());
|
$("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close());
|
||||||
$("book-action-menu")?.addEventListener("click", (e) => {
|
$("book-action-menu")?.addEventListener("click", (e) => {
|
||||||
|
|||||||
+5
-1
@@ -80,6 +80,10 @@
|
|||||||
|
|
||||||
<footer class="composer" id="composer">
|
<footer class="composer" id="composer">
|
||||||
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
|
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text">—</span></span>
|
||||||
|
<div class="lifecycle-toggle" id="lifecycle-toggle" hidden role="group" aria-label="创作与游玩">
|
||||||
|
<button type="button" class="lifecycle-btn" data-stage="design">创作</button>
|
||||||
|
<button type="button" class="lifecycle-btn" data-stage="play">游玩</button>
|
||||||
|
</div>
|
||||||
<div id="composer-main"></div>
|
<div id="composer-main"></div>
|
||||||
<details class="more-menu">
|
<details class="more-menu">
|
||||||
<summary class="btn-sm more-menu-sum" title="更多">更多</summary>
|
<summary class="btn-sm more-menu-sum" title="更多">更多</summary>
|
||||||
@@ -121,7 +125,7 @@
|
|||||||
|
|
||||||
<menu id="book-action-menu" class="context-menu" hidden>
|
<menu id="book-action-menu" class="context-menu" hidden>
|
||||||
<button type="button" class="context-menu-item" id="book-menu-open" data-action="open">打开创作</button>
|
<button type="button" class="context-menu-item" id="book-menu-open" data-action="open">打开创作</button>
|
||||||
<button type="button" class="context-menu-item" id="book-menu-play" data-action="play">进入游玩</button>
|
<button type="button" class="context-menu-item" id="book-menu-play" data-action="play">用产物开玩</button>
|
||||||
<button type="button" class="context-menu-item" id="book-menu-save-instance" data-action="save-instance">保存定稿</button>
|
<button type="button" class="context-menu-item" id="book-menu-save-instance" data-action="save-instance">保存定稿</button>
|
||||||
<button type="button" class="context-menu-item" id="book-menu-save-play" data-action="save-play">保存游玩</button>
|
<button type="button" class="context-menu-item" id="book-menu-save-play" data-action="save-play">保存游玩</button>
|
||||||
<button type="button" class="context-menu-item" id="book-menu-export" data-action="export">导出</button>
|
<button type="button" class="context-menu-item" id="book-menu-export" data-action="export">导出</button>
|
||||||
|
|||||||
+23
-17
@@ -3,7 +3,7 @@
|
|||||||
* 契约:docs/play-presentation-shells.md · present.v1
|
* 契约:docs/play-presentation-shells.md · present.v1
|
||||||
*
|
*
|
||||||
* 信息层级约定(所有壳共用):
|
* 信息层级约定(所有壳共用):
|
||||||
* P0 body — 最大字号与面积,阅读主轴
|
* P0 body — 阅读主轴:字号大于辅助栏,但不做成讲稿大字
|
||||||
* P1 actions — 需要决策时醒目,但不压过正文
|
* P1 actions — 需要决策时醒目,但不压过正文
|
||||||
* P2 monitor — 芯片密排,一眼扫完
|
* P2 monitor — 芯片密排,一眼扫完
|
||||||
* P3 header / footer / aside — 辅助,紧凑
|
* P3 header / footer / aside — 辅助,紧凑
|
||||||
@@ -66,20 +66,20 @@ export function shellDefaultRegions(shell) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatBlockContent(value) {
|
function formatBlockContent(value) {
|
||||||
|
let text = "";
|
||||||
if (value === undefined || value === null) return "";
|
if (value === undefined || value === null) return "";
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") text = value;
|
||||||
if (Array.isArray(value)) {
|
else if (Array.isArray(value)) {
|
||||||
return value
|
text = value
|
||||||
.map((x) => (typeof x === "string" ? x : JSON.stringify(x)))
|
.map((x) => (typeof x === "string" ? x : JSON.stringify(x)))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
} else if (typeof value === "object") {
|
||||||
if (typeof value === "object") {
|
text = Object.entries(value)
|
||||||
return Object.entries(value)
|
|
||||||
.map(([k, v]) => `${k}:${typeof v === "string" ? v : JSON.stringify(v)}`)
|
.map(([k, v]) => `${k}:${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
} else text = String(value);
|
||||||
return String(value);
|
return text.replace(/\n{3,}/g, "\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 模型常把 tag 名写进正文开头;展示前剥掉 */
|
/** 模型常把 tag 名写进正文开头;展示前剥掉 */
|
||||||
@@ -132,7 +132,11 @@ function regionInnerHtml(region, rawValue, esc) {
|
|||||||
if (chips) return chips;
|
if (chips) return chips;
|
||||||
}
|
}
|
||||||
const text = formatBlockContent(rawValue).trim();
|
const text = formatBlockContent(rawValue).trim();
|
||||||
return `<div class="present-region-body">${esc(text).replace(/\n/g, "<br>")}</div>`;
|
const paras = text.split(/\n\n+/).filter(Boolean);
|
||||||
|
const inner = paras
|
||||||
|
.map((p) => `<p>${esc(p).replace(/\n/g, "<br>")}</p>`)
|
||||||
|
.join("");
|
||||||
|
return `<div class="present-region-body">${inner}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,26 +304,28 @@ export function renderPresentShellHtml(packet, esc, opts = {}) {
|
|||||||
${actionsHtml}
|
${actionsHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case "turn_panel":
|
case "turn_panel": {
|
||||||
|
const boardSide = `${by.aside || ""}${by.footer || ""}`;
|
||||||
layoutHtml = `
|
layoutHtml = `
|
||||||
<div class="present-board">
|
<div class="present-board">
|
||||||
<div class="present-board-top">${by.monitor || ""}${by.header || ""}</div>
|
<div class="present-board-top">${by.monitor || ""}${by.header || ""}</div>
|
||||||
<div class="present-board-mid">
|
<div class="present-board-mid${boardSide ? "" : " is-solo"}">
|
||||||
<div class="present-stage">${by.body || ""}</div>
|
<div class="present-stage">${by.body || ""}</div>
|
||||||
<div class="present-board-side">${by.aside || ""}${by.footer || ""}</div>
|
${boardSide ? `<div class="present-board-side">${boardSide}</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
${actionsHtml}
|
${actionsHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "split_board":
|
case "split_board":
|
||||||
layoutHtml = `
|
layoutHtml = `
|
||||||
<div class="present-split">
|
<div class="present-split${by.aside ? "" : " is-solo"}">
|
||||||
<div class="present-split-main">
|
<div class="present-split-main">
|
||||||
${by.header || ""}
|
${by.header || ""}
|
||||||
<div class="present-stage">${by.body || ""}</div>
|
<div class="present-stage">${by.body || ""}</div>
|
||||||
${by.footer || ""}
|
${by.footer || ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="present-split-side">${by.aside || ""}</div>
|
${by.aside ? `<div class="present-split-side">${by.aside}</div>` : ""}
|
||||||
${actionsHtml}
|
${actionsHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
@@ -334,13 +340,13 @@ export function renderPresentShellHtml(packet, esc, opts = {}) {
|
|||||||
break;
|
break;
|
||||||
case "chapter_reader":
|
case "chapter_reader":
|
||||||
layoutHtml = `
|
layoutHtml = `
|
||||||
<div class="present-reader">
|
<div class="present-reader${by.aside ? "" : " is-solo"}">
|
||||||
<div class="present-reader-main">
|
<div class="present-reader-main">
|
||||||
${by.header || ""}
|
${by.header || ""}
|
||||||
<div class="present-stage present-stage--read">${by.body || ""}</div>
|
<div class="present-stage present-stage--read">${by.body || ""}</div>
|
||||||
${by.footer || ""}
|
${by.footer || ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="present-reader-aside">${by.aside || ""}</div>
|
${by.aside ? `<div class="present-reader-aside">${by.aside}</div>` : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case "prose":
|
case "prose":
|
||||||
|
|||||||
+117
-18
@@ -849,6 +849,13 @@ body.design-workspace[data-design-surface="answer"] .workspace-answer {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
max-width: 46rem;
|
max-width: 46rem;
|
||||||
}
|
}
|
||||||
|
.workspace-intent-actions {
|
||||||
|
margin-top: 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
.workspace-intent-meta {
|
.workspace-intent-meta {
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1666,6 +1673,10 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
|
|||||||
}
|
}
|
||||||
.explorer-row.child .explorer-name { font-size: 12px; font-weight: 400; }
|
.explorer-row.child .explorer-name { font-size: 12px; font-weight: 400; }
|
||||||
.explorer-row:hover { background: var(--bg); }
|
.explorer-row:hover { background: var(--bg); }
|
||||||
|
.explorer-row:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
.explorer-row.active { background: var(--accent-soft); }
|
.explorer-row.active { background: var(--accent-soft); }
|
||||||
.explorer-row.active .explorer-name { color: var(--accent); font-weight: 600; }
|
.explorer-row.active .explorer-name { color: var(--accent); font-weight: 600; }
|
||||||
.explorer-row.selected { background: #dbeafe; }
|
.explorer-row.selected { background: #dbeafe; }
|
||||||
@@ -1728,15 +1739,59 @@ body.design-workspace .flow-step[data-run="done"] .flow-name {
|
|||||||
.explorer-hint {
|
.explorer-hint {
|
||||||
margin: 4px 8px 4px 28px; font-size: 11px; color: var(--muted); line-height: 1.4;
|
margin: 4px 8px 4px 28px; font-size: 11px; color: var(--muted); line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
.explorer-product {
|
||||||
|
margin: 2px 0 6px;
|
||||||
|
}
|
||||||
|
.explorer-product.is-open {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.explorer-product.is-open > .explorer-row {
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.explorer-product.is-open > .explorer-row:hover {
|
||||||
|
background: #ececee;
|
||||||
|
}
|
||||||
|
.explorer-drop {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.explorer-drop .explorer-row.child {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
.explorer-drop-empty {
|
||||||
|
margin: 4px 8px 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
.sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; }
|
.sidebar-empty { padding: 12px; color: var(--muted); font-size: 13px; text-align: center; }
|
||||||
|
|
||||||
/* Lifecycle */
|
/* Lifecycle */
|
||||||
.lifecycle-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; padding: 2px; }
|
.lifecycle-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.lifecycle-toggle[hidden] { display: none; }
|
||||||
.lifecycle-btn {
|
.lifecycle-btn {
|
||||||
border: none; background: transparent; padding: 4px 14px; border-radius: 6px;
|
border: none; background: transparent; padding: 4px 10px; border-radius: 6px;
|
||||||
font-size: 13px; cursor: pointer; color: var(--muted);
|
font-size: 12px; cursor: pointer; color: var(--muted); font-weight: 500;
|
||||||
}
|
}
|
||||||
.lifecycle-btn.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
.lifecycle-btn.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
|
.lifecycle-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.lifecycle-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
body[data-lifecycle="play"] { --accent: #16a34a; --accent-soft: #dcfce7; --bg: #f0fdf4; }
|
body[data-lifecycle="play"] { --accent: #16a34a; --accent-soft: #dcfce7; --bg: #f0fdf4; }
|
||||||
body[data-lifecycle="play"] .panel-rail { background: #f7fef9; }
|
body[data-lifecycle="play"] .panel-rail { background: #f7fef9; }
|
||||||
|
|
||||||
@@ -2227,10 +2282,10 @@ body.rail-collapsed .message-feed {
|
|||||||
.msg-q-list li { margin: 0.35rem 0; }
|
.msg-q-list li { margin: 0.35rem 0; }
|
||||||
.msg-q-lead { margin: 0; line-height: 1.55; font-size: 14px; }
|
.msg-q-lead { margin: 0; line-height: 1.55; font-size: 14px; }
|
||||||
|
|
||||||
/* 游玩:每轮终稿仍是气泡(方便扫上下文),正文在气泡里撑高 */
|
/* 游玩:终稿铺满阅读区;字号跟正文阅读走,不跟创作验收的大字稿走 */
|
||||||
body[data-lifecycle="play"] .message-feed {
|
body[data-lifecycle="play"] .message-feed {
|
||||||
padding: 16px 18px 22px;
|
padding: 10px 12px 16px;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
body[data-lifecycle="play"] .msg-assistant-row .msg-bubble {
|
body[data-lifecycle="play"] .msg-assistant-row .msg-bubble {
|
||||||
width: min(100%, 44rem);
|
width: min(100%, 44rem);
|
||||||
@@ -2246,7 +2301,12 @@ body[data-lifecycle="play"] .msg-user-row .msg-bubble {
|
|||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
}
|
}
|
||||||
body[data-lifecycle="play"] .msg-play-final .msg-bubble {
|
body[data-lifecycle="play"] .msg-play-final .msg-bubble {
|
||||||
width: min(100%, 46rem);
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
body[data-lifecycle="play"] .msg-play-final .msg-body {
|
||||||
|
padding: 6px 14px 12px;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
body[data-lifecycle="play"] .msg-play-final .present-shell {
|
body[data-lifecycle="play"] .msg-play-final .present-shell {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -2258,14 +2318,25 @@ body[data-lifecycle="play"] .msg-play-final .present-shell {
|
|||||||
body[data-lifecycle="play"] .msg-play-final .present-shell-badge {
|
body[data-lifecycle="play"] .msg-play-final .present-shell-badge {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
body[data-lifecycle="play"] .msg-play-final .present-stack,
|
||||||
|
body[data-lifecycle="play"] .msg-play-final .present-board,
|
||||||
|
body[data-lifecycle="play"] .msg-play-final .present-split,
|
||||||
|
body[data-lifecycle="play"] .msg-play-final .present-reader {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
body[data-lifecycle="play"] .msg-play-final .present-stage .present-region--body .present-region-body,
|
body[data-lifecycle="play"] .msg-play-final .present-stage .present-region--body .present-region-body,
|
||||||
body[data-lifecycle="play"] .msg-play-final .present-stage.present-region--body .present-region-body {
|
body[data-lifecycle="play"] .msg-play-final .present-stage.present-region--body .present-region-body,
|
||||||
font-size: 18.5px;
|
body[data-lifecycle="play"] .msg-play-final .present-stage--read .present-region-body {
|
||||||
line-height: 1.88;
|
font-size: 15px;
|
||||||
|
line-height: 1.65;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
body[data-lifecycle="play"] .msg-play-final .present-tone--book .present-stage--read .present-region-body,
|
||||||
|
body[data-lifecycle="play"] .msg-play-final .present-tone--book .present-stage .present-region-body {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
body[data-lifecycle="play"] .msg-play-final .present-shell--chat_monitor .present-stage {
|
body[data-lifecycle="play"] .msg-play-final .present-shell--chat_monitor .present-stage {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -4692,15 +4763,16 @@ body.design-workspace .af-mosaic {
|
|||||||
* 视觉:少盒子、少渐变;层级靠字号/间距/对比,不靠装饰抢注意力。
|
* 视觉:少盒子、少渐变;层级靠字号/间距/对比,不靠装饰抢注意力。
|
||||||
*/
|
*/
|
||||||
.present-shell {
|
.present-shell {
|
||||||
|
white-space: normal;
|
||||||
--present-ink: #1c1917;
|
--present-ink: #1c1917;
|
||||||
--present-muted: #78716c;
|
--present-muted: #78716c;
|
||||||
--present-line: #e7e5e4;
|
--present-line: #e7e5e4;
|
||||||
--present-soft: #f5f5f4;
|
--present-soft: #f5f5f4;
|
||||||
--present-accent: #0f766e;
|
--present-accent: #0f766e;
|
||||||
--present-body: 17.5px;
|
--present-body: 15px;
|
||||||
--present-body-lh: 1.78;
|
--present-body-lh: 1.62;
|
||||||
--present-body-hero: 19px;
|
--present-body-hero: 16.5px;
|
||||||
--present-body-read: 18.5px;
|
--present-body-read: 15.5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -4749,9 +4821,15 @@ body.design-workspace .af-mosaic {
|
|||||||
.present-region-body {
|
.present-region-body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
white-space: pre-wrap;
|
white-space: normal;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.present-region-body > p {
|
||||||
|
margin: 0 0 0.7em;
|
||||||
|
}
|
||||||
|
.present-region-body > p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
/* P0:正文舞台 —— 随字数伸展(500~2000 靠外层滚动,不塞进小框) */
|
/* P0:正文舞台 —— 随字数伸展(500~2000 靠外层滚动,不塞进小框) */
|
||||||
.present-stage {
|
.present-stage {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -4778,8 +4856,8 @@ body.design-workspace .af-mosaic {
|
|||||||
.present-stage--read .present-region-body {
|
.present-stage--read .present-region-body {
|
||||||
font-family: var(--font-read);
|
font-family: var(--font-read);
|
||||||
font-size: var(--present-body-read);
|
font-size: var(--present-body-read);
|
||||||
line-height: 1.9;
|
line-height: 1.7;
|
||||||
max-width: 42rem;
|
max-width: none;
|
||||||
}
|
}
|
||||||
/* 双栏:侧栏钉住,正文再长也能对照 */
|
/* 双栏:侧栏钉住,正文再长也能对照 */
|
||||||
.present-board-side,
|
.present-board-side,
|
||||||
@@ -4893,6 +4971,13 @@ body.design-workspace .af-mosaic {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
.present-board-mid.is-solo,
|
||||||
|
.present-board-mid:not(:has(.present-board-side)) {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.present-board-side:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.present-board-side {
|
.present-board-side {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4912,6 +4997,13 @@ body.design-workspace .af-mosaic {
|
|||||||
gap: 16px 20px;
|
gap: 16px 20px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
.present-split.is-solo,
|
||||||
|
.present-split:not(:has(.present-split-side)) {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.present-split-side:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.present-split-main {
|
.present-split-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4963,6 +5055,13 @@ body.design-workspace .af-mosaic {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
.present-reader.is-solo,
|
||||||
|
.present-reader:not(:has(.present-reader-aside)) {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.present-reader-aside:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.present-reader-main {
|
.present-reader-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -5015,7 +5114,7 @@ body.design-workspace .af-mosaic {
|
|||||||
.present-tone--book .present-stage--read .present-region-body,
|
.present-tone--book .present-stage--read .present-region-body,
|
||||||
.present-tone--book .present-stage .present-region-body {
|
.present-tone--book .present-stage .present-region-body {
|
||||||
font-family: var(--font-read);
|
font-family: var(--font-read);
|
||||||
max-width: 38rem;
|
max-width: none;
|
||||||
}
|
}
|
||||||
.present-tone--terminal {
|
.present-tone--terminal {
|
||||||
--present-ink: #d6d3d1;
|
--present-ink: #d6d3d1;
|
||||||
|
|||||||
Reference in New Issue
Block a user