Files
writing-agent/src/server/session-manager.ts
T
chenmoranzhiandCursor 66ed1eaca8 会话与前端接上听书、呈现投影、请求轮廓和旁观气泡偏好。
新建作品可选听书连载,朗读与消费进度走专用接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 23:49:44 +08:00

4926 lines
166 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from "node:crypto";
import { appendBookSession, getBook, updateBook } from "../book/store.js";
import { loadBookSession, saveBookSession } from "../book/session-store.js";
import {
deleteRunSnapshot as deleteRunSnapshotFile,
listRunSnapshots,
loadRunSnapshot as loadRunSnapshotFile,
saveRunSnapshot as saveRunSnapshotFile,
} from "../book/run-snapshot-store.js";
import type { RunSnapshot, RunSnapshotMeta, SnapshotKind } from "../types/run-snapshot.js";
import { toRunSnapshotMeta } from "../types/run-snapshot.js";
import { materializeInstanceSnapshotPayload } from "../book/snapshot-filters.js";
import {
listSnapshotProducts,
patchBlackboardTagContent,
type SnapshotProductRow,
} from "../book/snapshot-products.js";
import {
INSTANCE_OPENING_SNAPSHOT_LABEL,
OPENING_OUTPUT_TAG,
OPENING_PERSONA_CHOICE_TAG,
OPENING_PERSONA_TAG,
OPENING_PRODUCT_FAMILY,
SLOT_OPENING_SELECTED_INDEX,
parseOpeningSealPayload,
readSealedOpeningPersona,
serializeOpeningPersona,
splitOpeningDocument,
} from "../skills/opening-seal.js";
import { buildPlayAuxView, collectOpenings } from "../skills/play-aux.js";
import {
dictateProductFamily,
} from "../dictate/repeatable-tags.js";
import {
PLAY_WORKING_SNAPSHOT_ID,
PLAY_WORKING_SNAPSHOT_LABEL,
SLOT_PLAY_INSTANCE_ID,
SLOT_PLAY_LAYER_ACTIVE,
isPlayLayerActive,
} from "../skills/play-turn.js";
import {
DIALOGUE_HISTORY_TAG,
appendDialogueHistoryTurn,
} from "../skills/dialogue-history.js";
import { PhaseRuntime, createDecision } from "../runtime/phase-runtime.js";
import { createSession } from "../runtime/phase-machine.js";
import {
createDefaultMainAgentLlm,
hasRealLlmConfig,
reloadDefaultMainAgentLlm,
type LlmTrackingRef,
} from "../runtime/llm-factory.js";
import {
acceptPreferenceCandidate,
collectPreferenceCandidates,
formatRecentDialogueForCollect,
} from "../preference/index.js";
import {
loadAppSettings,
normalizeContextTraceKeepLatest,
normalizePreferenceCollectEveryTurns,
} from "../config/settings.js";
import { isAbortError, runWithAbortSignal } from "../llm/run-abort.js";
import { getSessionTokenTotals, type MessageTokenUsage } from "../stats/token-store.js";
import type { PersistedBookSession } from "../types/book-session.js";
import type { RuntimeSession, WaitingReason } from "../types/runtime.js";
import type { IntakeProgress } from "../types/intake.js";
import {
buildFocus,
buildPipeline,
buildToolTrace,
buildBurstState,
buildSkillCatalog,
inferLifecycleStage,
canEnterPlay,
classifyAgentMessage,
type AgentMessageKind,
type SessionFocus,
type StageStep,
type LifecycleStage,
type SkillCatalogEntry,
type ToolTraceEntry,
type BurstState,
} from "./agent-view.js";
import {
buildIntakeProgress,
readIntakeValues,
} from "../intake/intake.js";
import {
bookSkillPackId,
persistedSkillPackId,
runSnapshotSkillPackId,
sessionSkillPackId,
skillPacksMatch,
} from "../book/skill-id.js";
import type { RequestOutline } from "../llm/context-outline.js";
import {
pruneContextTraces,
type LlmContextTrace,
} from "../types/context-trace.js";
import type { ActiveSkillSnapshot } from "../types/runtime.js";
import type { BlackboardItem } from "../types/blackboard.js";
import { Blackboard } from "../blackboard/blackboard.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
import { parseReplyDataContract } from "../skills/reply-contract.js";
import { projectPresentBlocks } from "../skills/present-project.js";
import {
CREATION_ACCEPTED_CONTENT_TAG,
CREATION_ACCEPTED_UNITS_TAG,
isDesignDiskWorker,
parseAcceptedContentStore,
parseAcceptedUnits,
SLOT_CREATION_ACCEPTED_UNITS,
} from "../skills/creation-units.js";
import {
formatWorkerSetForUser,
type WorkerSetUserView,
} from "../skills/worker-set-view.js";
import {
CONTEXT_ORDER_TAG,
applyContextOrderEdit,
mergeContextOrderIntoWorkerSetJson,
parseContextOrder,
serializeContextOrder,
synthesizeContextOrderFromWorkers,
type ContextOrderEdit,
} from "../skills/context-order.js";
import {
CREATION_CURRENT_STEP_TAG,
CREATION_FLOW_TAG,
CREATION_PROPOSED_STEP_TAG,
CREATION_SELECTED_RECIPE_TAG,
DESIGN_FLOW_WORKER_ID,
creationFlowFromRecipeSeed,
findRecipeCatalogEntry,
formatCreationFlowForUser,
formatDictateRecipeBrief,
formatRecipeExamplesForCreationPlan,
formatModuleCatalogForDictate,
findModuleByName,
findStepByRef,
type FlowStepTitleHint,
isProgressPointerTag,
loadRecipeCatalog,
loadRecipeDetail,
loadModuleCatalog,
parseCreationFlow,
parseSelectedRecipeRef,
resolveDesignStepBinding,
stepUnitId,
stringifyCreationFlow,
stripReviseSteps,
type CreationFlow,
type CreationFlowUserView,
type ModuleCatalog,
type RecipeCatalogEntry,
} from "../skills/creation-flow.js";
import { loadSkill } from "../skills/loader.js";
import { effectiveStartupMode, DEFAULT_UI_PROMPT } from "../config/default-orchestrator.js";
import { buildBoardPanel } from "../runtime/compress-after-worker.js";
import {
displayWorkerLabel,
formatWorkerDisplayTitle,
reviewComposerCopy,
} from "./display-labels.js";
import {
buildCreationDialogueTranscript,
foldRunProcessMessages,
} from "./prune-creation-messages.js";
import {
CREATION_INTAKE_MODE_TAG,
DICTATE_MODE_VALUE,
DICTATE_LAYER_META_KEY,
DICTATE_ORDER_META_KEY,
DICTATE_SELF_SCORE_META_KEY,
applyDictatePlayBind,
buildDictateModuleReadIndex,
buildDictateInsertFeedbackIndex,
defaultDictateOrder,
extractDictateDialogue,
isDictateModeValue,
lookupDictateInsertFeedback,
runDictateTurn,
sortDictateProducts,
type DictateProduct,
} from "../dictate/index.js";
import {
LISTEN_SERIAL_MODE_VALUE,
isListenSerialModeValue,
markListenConsumed,
readListenState,
runListenSerialTurn,
toListenView,
type ListenSerialView,
} from "../listen-serial/index.js";
import { synthesizeSpeech, type TtsSynthesizeResult } from "../tts/index.js";
import {
formatQuestionAnswersForAi,
formatQuestionAnswersForDisplay,
isModuleOpeningQuestions,
normalizeQuestions,
} from "../skills/question-protocol.js";
import type { QuestionAnswer } from "../types/questions.js";
import {
expandAtDirectives,
type DirectiveContext,
} from "../directives/expand.js";
import {
getActivePersona,
listPersonas,
personaForLifecycle,
} from "../persona/store.js";
import {
DICTATE_OPENING_REPEATABLE_FAMILIES,
ensureProtagonistFromPersona,
formatPersonaDescription,
overlayPlayPersona,
parseProtagonistSpec,
personaBoundToOpening,
PROTAGONIST_TAG,
protagonistTagForOpeningSlot,
readSessionProtagonist,
} from "../persona/protagonist-bind.js";
import type { TableCellPatch } from "../blackboard/table-cells.js";
import {
appendBranchVariant,
cloneCheckpoint,
createMessageBranchState,
createUserVariantMessage,
ensureBranchForEdit,
ensureBranchForRefresh,
findPrecedingUserIndex,
canAttemptRefresh,
isRefreshableMessage,
recordPreMessageCheckpoint,
switchBranchVariant,
updateActiveBranchVariant,
type MessageBranchState,
type SessionCheckpoint,
} from "./message-branch.js";
import {
createCompareJob,
findCandidate,
getCompareJob,
runCompareJob,
type ModelCompareJob,
} from "./model-compare.js";
/** 这些消息种类挂载全量 LLM 上下文痕迹(右键可查看) */
const CONTEXT_TRACE_MESSAGE_KINDS = new Set<AgentMessageKind>([
"worker_output",
"orchestrator_decision",
"orchestrator_assessment",
"orchestrator_thinking",
]);
export type ChatMessage = {
id: string;
role: "system" | "user";
text: string;
createdAt: string;
kind?: AgentMessageKind;
actor?: string;
title?: string;
body?: string;
/** 思维链 / reasoning_content */
thinking?: string;
/** 该次 LLM 请求实际携带的全量上下文(观察用;按设置只保留最新 N 条) */
contextTrace?: LlmContextTrace;
/** 本条回复对应的各次请求标签轮廓(选题、填写都在;不含正文) */
requestOutlines?: RequestOutline[];
/** run 验收后过程讨论折叠(主 feed 隐藏);创作 messages 不删不藏 */
compressed?: boolean;
tokenUsage?: MessageTokenUsage;
/** 消息分支:同位多版本(编辑 / 重 roll) */
branchGroupId?: string;
branchIndex?: number;
branchTotal?: number;
/** 视图用:这条现在能不能点「重出一版」 */
canRefresh?: boolean;
};
export type SessionView = {
id: string;
bookId?: string;
bookTitle?: string;
activeSkill?: string;
phase: RuntimeSession["phase"];
waitingReason?: WaitingReason;
startupCompleted: boolean;
skills: Array<{ name: string; description: string; category: string }>;
messages: ChatMessage[];
hints: string[];
actions: SessionAction[];
/** @deprecated 用 skillCatalog */
pipeline: StageStep[];
lifecycleStage: LifecycleStage;
/** 已从定稿开出聊天层(不是只把顶栏标成游玩) */
playLayerActive: boolean;
playReady: boolean;
/** 已落档至少一份产物(instance 快照) */
hasProduct: boolean;
/** 当前游玩所依据的产物 id */
playInstanceId?: string;
skillCatalog: SkillCatalogEntry[];
toolTrace: ToolTraceEntry[];
burst: BurstState;
focus: SessionFocus;
/** 启动填空进度(waitingReason.kind === intake 时有值) */
intake?: IntakeProgress;
intakePrompt?: string;
/** agent-first 首屏固定引导(纯 UI,非消息流) */
uiPrompt?: string;
/** 待验收产物全文(review_artifact 时在右侧 Tab 展示) */
reviewArtifact?: ReviewArtifactView;
/** Worker 集用户视图(验收 / 设计进度共用) */
workerSetView?: WorkerSetUserView;
/** 创作流程用户视图(步骤顺序 + 依赖) */
creationFlowView?: CreationFlowUserView;
/** 确认开干:下一步提案(含可编辑编排参数) */
proposedNextStep?: ProposedNextStepView;
/** 能力包内可选初始配方(用户手动选) */
recipes?: Array<{ id: string; name: string; declaration: string }>;
/** 用户已选初始配方 */
selectedRecipe?: { id: string; name: string; declaration: string } | null;
/**
* 开局引导:配方起点或当前能力默认问题的 opening(说话面主柱;对齐美学纲领)。
*/
openingGuide?: { stepId: string; stepName: string; text: string } | null;
/** 黑板与定稿上下文(强可读) */
boardPanel?: BoardPanelView;
/** 游玩呈现壳微调(来自设计.正文组成) */
presentationTweaks?: {
shell_id?: string;
tone_chrome?: string;
show_suggested_actions?: boolean;
block_labels?: Record<string, string>;
empty_states?: Record<string, string>;
};
/** 正文组成固定前端。游玩和开场把 present.v1 灌进去,替换示例内容。 */
presentationFrontend?: { html: string; css: string; js: string };
/** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */
presentationSlots?: Array<{ id: string; type: string }>;
/**
* 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并,随真值刷新。
*/
presentationProjection?: Record<string, unknown>;
/** 总管正在输出的思维链(流式,轮询用) */
agentThinking?: string;
/** Agent / Worker 流式输出(轮询用) */
liveStream?: LiveStreamView;
/**
* 会话是否有进行中的排他运行(send/action 的 runExclusive)。
* 前端 loading 应对齐此标志,避免 POST 挂住时底栏永远「生成中」。
*/
runActive?: boolean;
resumed?: boolean;
/** 创作进料:recipe=配方节点流;dictate=转述整理;listen_serial=听书连载 */
creationMode?: "recipe" | "dictate" | "listen_serial";
/** 转述式已写入黑板的产物(按相对序) */
dictateProducts?: Array<{ tag: string; content: string; order?: number }>;
/** 听书连载投影(大纲/正文缓冲/未读字数) */
listenSerial?: ListenSerialView;
tokenStats?: {
sessionTotal: number;
sessionCached?: number;
sessionCacheMiss?: number;
lastCaller?: string;
lastTotal?: number;
byCaller?: Record<
string,
{ totalTokens: number; cachedTokens: number; cacheMissTokens: number; calls: number }
>;
};
/** 用户角色列表(快速切换) */
personas?: Array<{ id: string; name: string }>;
/** 当前选中的用户角色 */
activePersona?: { id: string; name: string; description: string } | null;
/** 本局开场自带的用户角色预设(名字+简介);关掉这局就不加载 */
sessionProtagonist?: { name: string; description: string; summary: string } | null;
/** 用户是否选用开场预设:opening / global */
openingPersonaChoice?: "opening" | "global" | null;
/** 游玩辅助 Tab:表格 / 开场与角色 */
playAux?: import("../skills/play-aux.js").PlayAuxView;
/** 待审核偏好候选:展示时可编辑,收下 / 不要 */
preferenceReview?: Array<{ id: string; content: string }>;
};
export type SessionAction =
| { type: "send_message"; label: string; placeholder: string }
| { type: "answer_questions"; label: string }
| { type: "skip_questions"; label: string }
| { type: "confirm_intake"; label: string }
| { type: "approve"; label: string }
| { type: "accept"; label: string }
| { type: "reject"; label: string }
| { type: "replan"; label: string }
| { type: "leave_step"; label: string }
| { type: "run_outline"; label: string }
| { type: "finish"; label: string };
export type LiveStreamView = {
actor: "orchestrator" | "worker";
actorId?: string;
label: string;
thinking?: string;
output?: string;
};
export type BoardPanelView = {
finals: Array<{ tag: string; source: string; preview: string; updatedAt: string }>;
active: Array<{ tag: string; source: string; preview: string; updatedAt: string }>;
archivedCount: number;
brief?: string;
};
export type ProposedNextStepView = {
stepId: string;
name: string;
declaration?: string;
params: Record<string, unknown>;
paramsMissing: string[];
paramSpecs: Array<{
key: string;
label: string;
required: boolean;
hint?: string;
}>;
/** 节点特性:prior-artifact = 先验产物(不在确认开干时钉「写什么」) */
kind?: string;
/** 用户在「下一步想写什么」里写的意向(与步骤 mode 不同) */
intent?: string;
/** 步骤怎么跑:revise = 回头修改既有产物 */
mode?: "fresh" | "revise";
revises?: string;
/** 收口节点(开场白):确认开干前仍可改编排追加前序节点 */
closer?: boolean;
};
export type ReviewArtifactView = {
id: string;
workerId: string;
summary?: string;
body: string;
outputTags?: string[];
/** 产出该产物的 worker_output 消息(验收态下 feed 会隐藏该消息) */
sourceMessageId?: string;
/** 与 sourceMessage 同源的全量 LLM 上下文,供待验收卡片右键查看 */
contextTrace?: LlmContextTrace;
/** 创作 design-* 验收时解析后的 Worker 集(供 UI 结构化展示) */
workerSet?: import("../skills/worker-set-parse.js").ParsedWorkerSet;
/** 中文卡片化用户视图 */
workerSetView?: WorkerSetUserView;
/** 创作流程(步骤 + 依赖) */
creationFlowView?: CreationFlowUserView;
};
type ManagedSession = {
runtime: PhaseRuntime;
messages: ChatMessage[];
bookId?: string;
trackingRef: LlmTrackingRef;
branchState: MessageBranchState;
/** 当前能力包可选初始配方(用户手动选) */
recipeOptions?: RecipeCatalogEntry[];
/** 模块目录(分层图 / 可反复增殖) */
moduleCatalog?: ModuleCatalog | null;
/** 开局模块 opening,缓存给意图页 */
openingGuide?: { stepId: string; stepName: string; text: string } | null;
/** 当前 LLM 请求的取消器(停止并重试) */
runAbort?: AbortController;
/** 递增世代:后一次请求作废前一次 */
runGen?: number;
/** 本轮开始时的消息条数,取消时裁掉执行中产生的调度句 */
runMessageCutoff?: number;
/** 本局游玩已完成的用户回合数(偏好采集计数;不进快照) */
preferencePlayTurns?: number;
/** 待审核的偏好候选(可编辑后收下/不要) */
preferenceReview?: import("../preference/collect.js").PreferenceCandidate[];
};
export class SessionManager {
private readonly sessions = new Map<string, ManagedSession>();
/** bookId → 当前内存中的 sessionId */
private readonly activeBookSessions = new Map<string, string>();
/** sessionId → 流式 thinking 缓冲(思考结束后仍保留,直到本轮结束) */
private readonly agentThinkingLive = new Map<string, string>();
/** 本轮思维链已收束;下一次 delta 起新一轮 */
private readonly agentThinkingClosed = new Set<string>();
/** sessionId → worker 流式缓冲 */
private readonly workerLive = new Map<
string,
{ workerId: string; thinking: string; output: string }
>();
async create(): Promise<SessionView> {
return this.createForBook();
}
setLifecycleStage(
sessionId: string,
stage: LifecycleStage,
opts?: { useOpeningPersona?: boolean },
): SessionView {
const s = this.require(sessionId);
if (stage === "play") {
s.runtime.prepareEnterPlay();
if (!canEnterPlay(s.runtime.getSession())) {
throw new Error("实例尚未就绪,无法进入游玩");
}
this.enterPlayLayer(s, {
resume: true,
useOpeningPersona: opts?.useOpeningPersona,
});
} else {
this.enterDesignLayer(s);
}
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
/** 用户手改黑板 tag(创作期改设定/表;尊重后续 worker 的表 rev 合并) */
writeUserBoardTag(
sessionId: string,
tag: string,
content: string,
): SessionView {
const s = this.require(sessionId);
const trimmedTag = tag.trim();
if (!trimmedTag) throw new Error("tag 不能为空");
s.runtime.getBlackboard().write({
tag: trimmedTag,
content: content ?? "",
source: "user",
});
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
/** 游玩表按格手改。冲突格在 tablePatch.skipped,整表不会被旧内容盖掉。 */
patchPlayTable(
sessionId: string,
tag: string,
cells: TableCellPatch[],
): SessionView & {
tablePatch: ReturnType<PhaseRuntime["patchUserTable"]>;
} {
const s = this.require(sessionId);
const tablePatch = s.runtime.patchUserTable(tag.trim() || "变量.当前", cells);
if (s.bookId) this.persist(s);
return { ...this.toView(sessionId), tablePatch };
}
/**
* 游玩辅助面点选开场。usePersona=true 用该开场自带角色;false 保持当前用户角色卡。
*/
selectPlayOpening(
sessionId: string,
index: number,
usePersona?: boolean,
): SessionView {
const s = this.require(sessionId);
const board = s.runtime.getBlackboard();
const aux = buildPlayAuxView(board, s.runtime.getSession().slots);
if (!aux.openings.length) throw new Error("没有可选开场");
const idx = Math.min(Math.max(0, Math.trunc(index)), aux.openings.length - 1);
const picked = aux.openings[idx]!;
s.runtime.setOpeningSelectedIndex(idx);
const persona = picked.persona;
if (usePersona === false) {
this.writeOpeningPersonaChoice(s, false);
} else if (persona) {
board.write({
tag: OPENING_PERSONA_TAG,
content: serializeOpeningPersona(persona),
source: "user",
});
this.writeOpeningPersonaChoice(s, true);
}
// 与进游玩一致:用完整文档(含 present.v1),勿用侧栏预览的压扁正文
const rawOnBoard = board.getContentByTag(picked.tag)?.trim() ?? "";
const body =
splitOpeningDocument(
rawOnBoard || picked.document || picked.text,
).text.trim() ||
picked.document?.trim() ||
picked.text.trim();
const expanded = this.expandDirectivesText(
body,
this.playDirectiveContext(s),
);
board.write({
tag: OPENING_OUTPUT_TAG,
content: expanded,
source: "user",
});
const openingAt = s.messages.findIndex((m) => m.title === "开场白");
if (openingAt >= 0) {
// 保留 id,前端才能 patch 呈现壳 forceFill,而不是拆 iframe 后软灌样本槽
s.messages[openingAt] = this.playOpeningMessage(
expanded,
s.messages[openingAt]!.id,
);
} else {
s.messages.unshift(this.playOpeningMessage(expanded));
}
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
/**
* 编排上下文投影排序:写入 设计.worker集.context_order(若有规格),
* 并同步 设计.上下文投影排序。下次声明驱动拼装即按新序。
*/
patchContextOrder(sessionId: string, edit: ContextOrderEdit): SessionView {
const s = this.require(sessionId);
const bb = s.runtime.getBlackboard();
const workerSetRaw =
bb.getContentByTag("设计.worker集")?.trim() ||
bb.getContentByTag("设计.worker集.草稿")?.trim() ||
"";
const orderTagRaw = bb.getContentByTag(CONTEXT_ORDER_TAG)?.trim() || "";
let current = parseContextOrder(
workerSetRaw
? parseWorkerSetYaml(workerSetRaw).context_order
: undefined,
);
if (!current) current = parseContextOrder(orderTagRaw);
if (!current && workerSetRaw) {
const parsed = parseWorkerSetYaml(workerSetRaw);
// 与检查器合成逻辑一致:缺 context 时用包内默认契约(视图侧也会合成)
current = synthesizeContextOrderFromWorkers(
parsed.workers.map((w) => ({
ref: w.ref,
name: w.name,
context: w.context,
})),
parsed.play_slots,
);
}
if (!current && edit.action !== "replace") {
throw new Error("尚无上下文投影排序可编辑;请先完成游玩拓扑/细化终稿,或提交完整 replace");
}
if (!current && edit.action === "replace") {
current = parseContextOrder(edit.context_order);
if (!current) throw new Error("context_order 无法解析");
}
const next = applyContextOrderEdit(current!, edit);
const serialized = serializeContextOrder(next);
const orderMeta = bb.getLatestByTag(CONTEXT_ORDER_TAG)?.metadata;
bb.write({
tag: CONTEXT_ORDER_TAG,
content: serialized,
source: "user",
metadata: orderMeta,
});
if (workerSetRaw) {
const targetTag = bb.getContentByTag("设计.worker集")?.trim()
? "设计.worker集"
: bb.getContentByTag("设计.worker集.草稿")?.trim()
? "设计.worker集.草稿"
: null;
if (targetTag) {
bb.write({
tag: targetTag,
content: mergeContextOrderIntoWorkerSetJson(workerSetRaw, next),
source: "user",
metadata: bb.getLatestByTag(targetTag)?.metadata,
});
}
} else {
// 尚无规格:至少落下排序表,供细化终稿合并
bb.write({
tag: CONTEXT_ORDER_TAG,
content: serialized,
source: "user",
metadata: orderMeta,
});
}
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
/**
* 打开 Book:优先恢复磁盘快照;无快照则新建 Session。
* 若该 Book 已在内存中,直接返回当前视图。
*/
async openBook(bookId: string): Promise<SessionView> {
const book = getBook(bookId);
if (!book) throw new Error("Book 不存在");
const inMemory = this.getActiveSessionForBook(bookId);
if (inMemory) return inMemory;
const snapshot = loadBookSession(bookId);
if (snapshot && skillPacksMatch(book, persistedSkillPackId(snapshot))) {
return this.restoreFromSnapshot(snapshot);
}
const legacyPreselect = bookSkillPackId(book);
return this.createForBook(bookId, legacyPreselect);
}
async createForBook(
bookId?: string,
preselectSkillId?: string,
selectedRecipeId?: string,
opts?: { creationMode?: "recipe" | "dictate" | "listen_serial" },
): Promise<SessionView> {
const id = randomUUID();
const messages: ChatMessage[] = [];
const book = bookId ? getBook(bookId) : null;
const initialSession = createSession("default");
initialSession.id = id;
const trackingRef: LlmTrackingRef = {
current: {
sessionId: id,
bookId,
bookTitle: book?.title,
orchestratorId: preselectSkillId ?? (book ? bookSkillPackId(book) : undefined),
},
};
const managed: ManagedSession = {
runtime: null as unknown as PhaseRuntime,
messages,
bookId,
trackingRef,
branchState: createMessageBranchState(),
};
const onMessage = this.buildOnMessageHandler(() => managed);
const thinkingHandlers = this.buildStreamHandlers(id, () => managed);
const llm = createDefaultMainAgentLlm(trackingRef, () =>
this.personaForManaged(managed),
);
const runtime = new PhaseRuntime({
autoStubWorker: !hasRealLlmConfig(),
llm,
onMessage,
...thinkingHandlers,
initialSession,
});
managed.runtime = runtime;
if (preselectSkillId) {
await runtime.startWithOrchestrator(preselectSkillId);
} else {
await runtime.start();
}
const creationMode = opts?.creationMode ?? "recipe";
const useDictate = creationMode === "dictate";
const useListen = creationMode === "listen_serial";
await this.refreshRecipeOptions(
managed,
useDictate || useListen ? "dictate" : "recipe",
);
if (selectedRecipeId?.trim()) {
await this.applySelectedRecipe(managed, selectedRecipeId.trim(), {
seedFlow: !useDictate && !useListen,
});
}
if (useListen) {
this.enterListenSerialMode(managed);
} else if (useDictate) {
this.enterDictateMode(managed);
} else {
await this.maybeKickAgentAfterDemand(runtime);
}
recordPreMessageCheckpoint(
managed.branchState,
0,
this.captureCheckpoint(managed),
);
this.sessions.set(id, managed);
if (bookId) {
this.activeBookSessions.set(bookId, id);
appendBookSession(bookId, id);
this.persist(managed);
}
return this.toView(id);
}
/** 切换到对话落盘进料(与工序编排并行;不跑 design-flow) */
private enterDictateMode(s: ManagedSession): void {
const board = s.runtime.getBlackboard();
board.write({
tag: CREATION_INTAKE_MODE_TAG,
content: DICTATE_MODE_VALUE,
source: "runtime",
});
s.runtime.enterDictateIntake();
const recipe = resolveSelectedRecipeView(board, s.recipeOptions);
const recipeLine = recipe?.name
? `\n本局配方:${recipe.name}${
recipe.declaration ? `(${recipe.declaration})` : ""
}。`
: "";
const welcome =
`对话落盘:直接说你想要的体验与设定。我会整理进产物;变量与映射用工具钉死。对话过长时可清空对话(产物保留)。${recipeLine}`;
s.messages.push({
id: randomUUID(),
role: "system",
text: welcome,
createdAt: new Date().toISOString(),
kind: "orchestrator_prompt",
actor: "orchestrator",
title: "对话落盘",
body: welcome,
});
}
private isDictateMode(s: ManagedSession): boolean {
return isDictateModeValue(
s.runtime.getBlackboard().getContentByTag(CREATION_INTAKE_MODE_TAG),
);
}
/** 听书连载:薄上下文连续产文,不走对话落盘工具环 */
private enterListenSerialMode(s: ManagedSession): void {
const board = s.runtime.getBlackboard();
board.write({
tag: CREATION_INTAKE_MODE_TAG,
content: LISTEN_SERIAL_MODE_VALUE,
source: "runtime",
});
s.runtime.enterDictateIntake(
"听书连载:用几句话描述题材与爽点;确认后我会生成大纲并自动补正文。不满意就说一句意见改后面。",
);
const welcome =
"听书连载:说几句你想听的故事(题材、人设、爽点即可)。我会轻回问或给出草案;确认后自动出大纲和正文缓冲。听的过程中随时一句意见,只改后面的走向。";
s.messages.push({
id: randomUUID(),
role: "system",
text: welcome,
createdAt: new Date().toISOString(),
kind: "orchestrator_prompt",
actor: "orchestrator",
title: "听书连载",
body: welcome,
});
}
private isListenSerialMode(s: ManagedSession): boolean {
return isListenSerialModeValue(
s.runtime.getBlackboard().getContentByTag(CREATION_INTAKE_MODE_TAG),
);
}
private creationIntakeMode(
s: ManagedSession,
): "recipe" | "dictate" | "listen_serial" {
if (this.isListenSerialMode(s)) return "listen_serial";
if (this.isDictateMode(s)) return "dictate";
return "recipe";
}
private extractListenDialogue(
messages: ChatMessage[],
): Array<{ role: "user" | "assistant"; text: string }> {
const out: Array<{ role: "user" | "assistant"; text: string }> = [];
for (const m of messages) {
if (m.kind === "user_input" || m.role === "user") {
const t = (m.body ?? m.text ?? "").trim();
if (t) out.push({ role: "user", text: t });
continue;
}
if (m.kind === "listen_reply") {
const t = (m.body ?? m.text ?? "").trim();
if (t) out.push({ role: "assistant", text: t });
}
}
return out;
}
private async runListenSerialTurnForSession(
s: ManagedSession,
userText: string,
): Promise<void> {
const llm = createDefaultMainAgentLlm(s.trackingRef, () =>
this.personaForManaged(s),
);
const dialogue = this.extractListenDialogue(s.messages);
const result = await runListenSerialTurn({
llm,
board: s.runtime.getBlackboard(),
dialogue,
userText,
});
const keep = normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest,
);
const requestOutlines = s.trackingRef.current.pendingOutlines;
const pendingTrace = s.trackingRef.current.pendingContextTrace;
const tokenUsage = s.trackingRef.current.pendingUsage;
s.trackingRef.current.pendingOutlines = undefined;
s.trackingRef.current.pendingContextTrace = undefined;
s.trackingRef.current.pendingUsage = undefined;
s.messages.push({
id: randomUUID(),
role: "system",
text: result.reply,
createdAt: new Date().toISOString(),
kind: "listen_reply",
actor: "orchestrator",
title: "听书连载",
body: result.reply,
...(tokenUsage ? { tokenUsage } : {}),
...(keep > 0 && pendingTrace ? { contextTrace: pendingTrace } : {}),
...(requestOutlines?.length ? { requestOutlines } : {}),
});
s.messages = pruneContextTraces(s.messages, keep);
s.runtime.enterDictateIntake(
result.softStop
? "本段已告一段落。输入「下一波」或「收工」,或一句意见改向。"
: "继续听/读,或说「继续」补货;不满意直接一句意见。",
);
}
/** 听书:标记已消费字数(阅读进度) */
markListenSerialConsumed(
sessionId: string,
consumedChars: number,
): SessionView {
const s = this.require(sessionId);
if (!this.isListenSerialMode(s)) {
throw new Error("当前不是听书连载模式");
}
markListenConsumed(s.runtime.getBlackboard(), consumedChars);
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
/** 听书:按节合成语音(默认 Edge) */
async synthesizeListenSection(
sessionId: string,
sectionId: string,
): Promise<TtsSynthesizeResult & { sectionId: string; title: string }> {
const s = this.require(sessionId);
if (!this.isListenSerialMode(s)) {
throw new Error("当前不是听书连载模式");
}
const id = sectionId.trim();
if (!id) throw new Error("需要 sectionId");
const state = readListenState(s.runtime.getBlackboard());
const section = state.sections.find((x) => x.id === id);
if (!section?.text?.trim()) {
throw new Error("未找到该节正文");
}
const spoken = `【${section.title}】\n${section.text}`.trim();
const result = await synthesizeSpeech({
text: spoken,
cacheKey: `${sessionId}:${id}`,
});
return { ...result, sectionId: id, title: section.title };
}
private listDictateProducts(s: ManagedSession): DictateProduct[] {
const board = s.runtime.getBlackboard();
const products: DictateProduct[] = [];
for (const e of board.listTagIndex()) {
if (!e.tag.startsWith("用户.") && !e.tag.startsWith("设计.")) continue;
const item = board.getLatestByTag(e.tag);
const content = item?.content?.trim() ?? "";
if (!content) continue;
const rawOrder = item?.metadata?.[DICTATE_ORDER_META_KEY];
const order =
typeof rawOrder === "number" && Number.isFinite(rawOrder)
? rawOrder
: undefined;
const layer = item?.metadata?.[DICTATE_LAYER_META_KEY];
const selfScore = item?.metadata?.[DICTATE_SELF_SCORE_META_KEY];
products.push({
tag: e.tag,
content,
order,
...(layer === "intermediate" || layer === "final" ? { layer } : {}),
...(selfScore && typeof selfScore === "object"
? { selfScore: selfScore as DictateProduct["selfScore"] }
: {}),
});
}
return sortDictateProducts(products);
}
/** 清空转述对话,保留黑板产物(产物即浓缩上下文) */
async clearDictateDialogue(sessionId: string): Promise<SessionView> {
const s = this.require(sessionId);
if (!this.isDictateMode(s)) {
throw new Error("当前不是转述整理模式");
}
const kept = this.listDictateProducts(s);
s.messages = [];
const note =
kept.length > 0
? `对话已清空。仍保留 ${kept.length} 项产物:${kept.map((p) => p.tag).join("、")}。`
: "对话已清空。尚无产物,请继续说明需求。";
s.messages.push({
id: randomUUID(),
role: "system",
text: note,
createdAt: new Date().toISOString(),
kind: "system_info",
actor: "system",
title: "对话已清空",
body: note,
});
s.runtime.enterDictateIntake("对话已清空;产物仍在。请继续。");
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
private async runDictateTurnForSession(
s: ManagedSession,
_userText: string,
): Promise<void> {
const llm = createDefaultMainAgentLlm(s.trackingRef, () =>
this.personaForManaged(s),
);
let dialogue = extractDictateDialogue(s.messages);
const recipeView = resolveSelectedRecipeView(
s.runtime.getBlackboard(),
s.recipeOptions,
);
const recipeBrief = await resolveDictateRecipeBriefForSession({
skillName: s.runtime.getActiveSkill()?.name,
recipeView,
recipeOptions: s.recipeOptions,
});
const moduleCatalog = await resolveDictateModuleCatalogForSession({
skillName: s.runtime.getActiveSkill()?.name,
moduleCatalog: s.moduleCatalog,
});
if (moduleCatalog) s.moduleCatalog = moduleCatalog;
const moduleGuide = moduleCatalog
? formatModuleCatalogForDictate(moduleCatalog)
: undefined;
const insertFeedbackIndex = await resolveDictateInsertFeedbackIndexForSession({
skillName: s.runtime.getActiveSkill()?.name,
moduleCatalog,
});
const repeatableFamilies = [
...(moduleCatalog?.modules ?? [])
.filter((m) => m.repeatable === true && m.artifact?.trim())
.map((m) => m.artifact!.trim()),
...DICTATE_OPENING_REPEATABLE_FAMILIES,
];
const recipeExamples = await resolveDictateRecipeExamplesForSession({
skillName: s.runtime.getActiveSkill()?.name,
recipeOptions: s.recipeOptions,
});
const moduleReadIndex = await resolveDictateModuleReadIndexForSession({
skillName: s.runtime.getActiveSkill()?.name,
moduleCatalog,
});
const result = await runDictateTurn({
llm,
dialogue,
recipeName: recipeView?.name,
recipeBrief,
moduleGuide,
recipeExamples,
moduleCatalog,
moduleReadIndex,
repeatableFamilies,
handlers: {
listProducts: () => this.listDictateProducts(s),
writeProduct: (tag, content, order, metadata) => {
const board = s.runtime.getBlackboard();
let writeContent = content;
if (dictateProductFamily(tag) === OPENING_PRODUCT_FAMILY) {
const split = splitOpeningDocument(content);
if (split.persona) {
const personaTag = protagonistTagForOpeningSlot(tag);
ensureProtagonistFromPersona(board, split.persona, personaTag, {
overwrite: personaTag !== PROTAGONIST_TAG,
});
}
// 仅有用户角色 meta、无叙事:抬到主角设定,不污染开场白
if (split.persona && !split.text.trim()) {
return;
}
writeContent = split.text.trim() || content;
}
const existing = board.getLatestByTag(tag);
const prev = existing?.metadata?.[DICTATE_ORDER_META_KEY];
const resolved =
typeof order === "number" && Number.isFinite(order)
? order
: typeof prev === "number" && Number.isFinite(prev)
? prev
: defaultDictateOrder(tag);
board.write({
tag,
content: writeContent,
source: "agent",
metadata: {
...(existing?.metadata ?? {}),
[DICTATE_ORDER_META_KEY]: resolved,
...(metadata?.layer
? { [DICTATE_LAYER_META_KEY]: metadata.layer }
: {}),
...(metadata?.selfScore
? { [DICTATE_SELF_SCORE_META_KEY]: metadata.selfScore }
: {}),
},
});
},
deleteProduct: (tag) => s.runtime.getBlackboard().deleteByTag(tag),
readTag: (tag) => s.runtime.getBlackboard().getContentByTag(tag),
writeTag: (tag, content) => {
const board = s.runtime.getBlackboard();
board.write({
tag,
content,
source: "agent",
metadata: {
...(board.getLatestByTag(tag)?.metadata ?? {}),
[DICTATE_ORDER_META_KEY]: defaultDictateOrder(tag),
},
});
},
deleteTag: (tag) => s.runtime.getBlackboard().deleteByTag(tag),
prepareOpening: () => {
applyDictatePlayBind(s.runtime.getBlackboard());
},
clearDialogue: () => {
const kept = this.listDictateProducts(s);
const lastUser = [...s.messages]
.reverse()
.find((m) => m.kind === "user_input");
const note =
kept.length > 0
? `(已清空先前对话;保留产物 ${kept.map((p) => p.tag).join("、")})`
: "(已清空先前对话)";
s.messages = [
{
id: randomUUID(),
role: "system",
text: note,
createdAt: new Date().toISOString(),
kind: "system_info",
actor: "system",
title: "对话已清空",
body: note,
},
...(lastUser ? [lastUser] : []),
];
dialogue = extractDictateDialogue(s.messages);
},
onToolCall: (name, detail) => {
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
s.messages.push(
this.msg("system", `[总管 tool] ${name}: ${detail}`),
);
},
onThinkingDelta: (delta) => {
const id = s.runtime.getSession().id;
const prev = this.agentThinkingLive.get(id) ?? "";
this.agentThinkingLive.set(id, prev + delta);
this.agentThinkingClosed.delete(id);
},
onThinkingDone: (text) => {
const id = s.runtime.getSession().id;
if (text.trim()) this.agentThinkingLive.set(id, text);
this.agentThinkingClosed.add(id);
},
lookupInsertFeedback: (tag) =>
lookupDictateInsertFeedback(insertFeedbackIndex, tag),
},
});
const sessionId = s.runtime.getSession().id;
const thinking = this.agentThinkingLive.get(sessionId)?.trim() || undefined;
this.clearAgentThinking(sessionId);
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
const keep = normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest,
);
const requestOutlines = s.trackingRef.current.pendingOutlines;
const pendingTrace = s.trackingRef.current.pendingContextTrace;
const tokenUsage = s.trackingRef.current.pendingUsage;
s.trackingRef.current.pendingOutlines = undefined;
s.trackingRef.current.pendingContextTrace = undefined;
s.trackingRef.current.pendingUsage = undefined;
s.messages.push({
id: randomUUID(),
role: "system",
text: result.reply,
createdAt: new Date().toISOString(),
kind: "dictate_reply",
actor: "orchestrator",
title: "转述整理",
body: result.reply,
...(thinking ? { thinking } : {}),
...(tokenUsage ? { tokenUsage } : {}),
...(keep > 0 && pendingTrace ? { contextTrace: pendingTrace } : {}),
...(requestOutlines?.length ? { requestOutlines } : {}),
});
s.messages = pruneContextTraces(s.messages, keep);
s.runtime.enterDictateIntake();
}
/** 用户手动选定 / 更换初始配方 */
async setSelectedRecipe(
sessionId: string,
recipeId: string,
): Promise<SessionView> {
const s = this.require(sessionId);
await this.refreshRecipeOptions(s);
await this.applySelectedRecipe(s, recipeId);
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
get(id: string): SessionView | null {
const s = this.sessions.get(id);
if (!s) return null;
if (s.runtime.recoverOrphanedRun()) this.persist(s);
return this.toView(id);
}
getActiveSessionForBook(bookId: string): SessionView | null {
const sessionId = this.activeBookSessions.get(bookId);
if (!sessionId) return null;
return this.get(sessionId);
}
/**
* 保存快照。
* - instance:实例化后的对象(情境/规则/角色设定等),不含 run 轮次状态
* - run:运行存档,完整进度
*/
saveGameSnapshot(
sessionId: string,
label: string,
kind: SnapshotKind = "run",
note?: string,
): RunSnapshotMeta {
const s = this.require(sessionId);
if (!s.bookId) throw new Error("仅绑定作品时可存档");
const book = getBook(s.bookId);
if (!book) throw new Error("Book 不存在");
const trimmed = label.trim();
if (!trimmed) throw new Error("请输入存档名称");
let runtimeSession = structuredClone(s.runtime.getSession());
let blackboardItems = s.runtime.getBlackboard().exportItems();
if (kind === "instance") {
s.runtime.prepareEnterPlay();
this.ensurePlayOpeningWritten(s);
runtimeSession = structuredClone(s.runtime.getSession());
blackboardItems = s.runtime.getBlackboard().exportItems();
if (!canEnterPlay(runtimeSession)) {
throw new Error("须先完成收口、验收 Worker 集,或(转述)先写入产物,才能保存定稿");
}
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
}
const skillPackId =
sessionSkillPackId(runtimeSession) ?? bookSkillPackId(book) ?? "";
const snapshot: RunSnapshot = {
version: 1,
id: randomUUID(),
bookId: s.bookId,
label: trimmed,
kind,
instanceId:
kind === "run" ? this.currentPlayInstanceId(s) : undefined,
orchestratorId: skillPackId,
runtimeSession,
blackboardItems,
messages: s.messages.map((m) => ({ ...m })),
createdAt: new Date().toISOString(),
note: note?.trim() || undefined,
};
saveRunSnapshotFile(snapshot);
return toRunSnapshotMeta(snapshot);
}
/** 列出某 Book 的全部存档(不含内部「当前游玩」工作副本) */
listGameSnapshots(bookId: string): RunSnapshotMeta[] {
if (!getBook(bookId)) throw new Error("Book 不存在");
return listRunSnapshots(bookId).filter((s) => s.id !== PLAY_WORKING_SNAPSHOT_ID);
}
/** 仅游玩 run 存档(UI 抽屉用) */
listPlaySnapshots(bookId: string): RunSnapshotMeta[] {
return this.listGameSnapshots(bookId).filter((s) => s.kind === "run");
}
/** 保存游玩进度(创作靠 session.json 自动续作,不手动存 instance) */
savePlaySnapshot(
sessionId: string,
label: string,
note?: string,
): RunSnapshotMeta {
const s = this.require(sessionId);
const session = s.runtime.getSession();
if (!canEnterPlay(session)) {
throw new Error("须先验收 Worker 集,才能保存游玩存档");
}
if (inferLifecycleStage(session) !== "play" || !isPlayLayerActive(session.slots)) {
throw new Error("请先切换到「游玩」再保存游玩存档");
}
return this.saveGameSnapshot(sessionId, label, "run", note);
}
/** 从已落档产物开一条新的游玩线(清空 run 层状态) */
async startNewPlayRun(
sessionId: string,
instanceId?: string,
opts?: { useOpeningPersona?: boolean },
): Promise<SessionView> {
const s = this.require(sessionId);
if (!s.bookId) throw new Error("仅绑定作品时可新建游玩");
this.persistCreationIfDesign(s);
s.runtime.prepareEnterPlay();
if (!canEnterPlay(s.runtime.getSession())) {
throw new Error("须先验收 Worker 集、完成收口,或(转述)先写入产物并落档,才能开始游玩");
}
this.archivePlayWorking(s.bookId);
this.enterPlayLayer(s, {
resume: false,
instanceId,
useOpeningPersona: opts?.useOpeningPersona,
});
return this.toView(sessionId);
}
/** 进入游玩层:resume 时恢复「当前游玩」,否则从产物新开一条聊天 */
private enterPlayLayer(
s: ManagedSession,
opts: {
resume: boolean;
instanceId?: string;
useOpeningPersona?: boolean;
},
): void {
this.persistCreationIfDesign(s);
s.runtime.prepareEnterPlay();
const session = s.runtime.getSession();
if (!canEnterPlay(session)) {
throw new Error("实例尚未就绪,无法进入游玩");
}
if (
opts.resume &&
isPlayLayerActive(session.slots) &&
inferLifecycleStage(session) === "play"
) {
this.writeOpeningPersonaChoice(s, opts.useOpeningPersona);
this.sanitizePlayHitl(s);
return;
}
if (s.bookId) this.ensureInstanceSnapshot(s);
if (opts.resume && s.bookId) {
const working = loadRunSnapshotFile(s.bookId, PLAY_WORKING_SNAPSHOT_ID);
if (working?.kind === "run") {
this.restoreSnapshotInPlace(s, working, "play");
this.writeOpeningPersonaChoice(s, opts.useOpeningPersona);
return;
}
}
this.beginNewPlayFromInstance(s, opts.instanceId, opts.useOpeningPersona);
if (s.bookId) this.savePlayWorking(s);
}
/** 回到创作层:先把当前聊天写入「当前游玩」,再恢复创作过程 */
private enterDesignLayer(s: ManagedSession): void {
const session = s.runtime.getSession();
if (
inferLifecycleStage(session) === "design" &&
!isPlayLayerActive(session.slots)
) {
s.runtime.setLifecycleStage("design");
return;
}
if (s.bookId && isPlayLayerActive(session.slots)) {
this.savePlayWorking(s);
}
this.restoreCreationFromDisk(s);
}
private beginNewPlayFromInstance(
s: ManagedSession,
instanceId?: string,
useOpeningPersona?: boolean,
): void {
const keepId = s.runtime.getSession().id;
// 换上 instance 快照前先记下当前创作里的开场白(可能比上次定稿更新)
const liveDesignOpening =
s.runtime.getBlackboard().getContentByTag("设计.开场白")?.trim() ?? "";
let runtimeSession: RuntimeSession;
let blackboardItems: import("../types/blackboard.js").BlackboardItem[];
const instance = s.bookId
? instanceId
? this.loadInstanceSnapshot(s.bookId, instanceId)
: this.latestInstanceSnapshot(s.bookId)
: null;
if (instance) {
runtimeSession = structuredClone(instance.runtimeSession);
blackboardItems = structuredClone(instance.blackboardItems);
} else {
runtimeSession = structuredClone(s.runtime.getSession());
blackboardItems = s.runtime.getBlackboard().exportItems();
}
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
runtimeSession.id = keepId;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "play",
[SLOT_PLAY_LAYER_ACTIVE]: true,
[SLOT_PLAY_INSTANCE_ID]: instance?.id,
startupCompleted: true,
};
runtimeSession.phase = "waiting_user";
runtimeSession.waitingReason = { kind: "input" };
runtimeSession.pendingArtifactId = undefined;
runtimeSession.pendingDecision = undefined;
runtimeSession.currentWorkerId = undefined;
runtimeSession.resumeContext = undefined;
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
// 新开一局(未点名旧定稿):用当前创作的设计.开场白覆盖快照里过期的开场
if (liveDesignOpening && !instanceId) {
s.runtime.getBlackboard().write({
tag: "设计.开场白",
content: liveDesignOpening,
source: "runtime",
});
}
this.writeOpeningPersonaChoice(s, useOpeningPersona);
const opening = this.ensurePlayOpeningWritten(
s,
instance?.messages as ChatMessage[] | undefined,
);
if (opening) {
s.runtime.getBlackboard().write({
tag: DIALOGUE_HISTORY_TAG,
content: appendDialogueHistoryTurn("", { role: "助手", text: opening }),
source: "runtime",
});
s.messages = [this.playOpeningMessage(opening)];
} else {
s.messages = [];
}
s.branchState = createMessageBranchState();
s.preferencePlayTurns = 0;
s.preferenceReview = undefined;
}
/** 开玩时记下是否用这条开场自带的用户角色;须在恢复快照之后写,否则会被冲掉。 */
private writeOpeningPersonaChoice(
s: ManagedSession,
useOpeningPersona?: boolean,
): void {
if (useOpeningPersona === undefined) return;
s.runtime.getBlackboard().write({
tag: OPENING_PERSONA_CHOICE_TAG,
content: useOpeningPersona ? "opening" : "global",
source: "user",
});
}
/**
* 保证黑板上有 输出.开场白,供开玩主区展示。
* 优先级:选中的设计.开场白[#槽] / 开场白与开场变量候选 > 已有 输出.开场白 > 其它产物拼装。
* 切勿用 dictate_reply(常含确认/建议补充)盖过已钉的设计.开场白。
* 返回值已展开 @ 指令(进游玩时绑定当前用户角色 / 掷骰)。
*/
private ensurePlayOpeningWritten(
s: ManagedSession,
messageSource?: ChatMessage[],
): string {
this.sealOpeningPersonaFromBoard(s);
const board = s.runtime.getBlackboard();
const openings = collectOpenings(board);
const selectedRaw = s.runtime.getSession().slots[SLOT_OPENING_SELECTED_INDEX];
const selectedIndex =
typeof selectedRaw === "number"
? selectedRaw
: typeof selectedRaw === "string" && selectedRaw.trim()
? Number(selectedRaw)
: 0;
const picked =
openings.length > 0
? openings[
Math.min(
Math.max(0, Math.trunc(selectedIndex) || 0),
openings.length - 1,
)
]
: null;
if (picked) {
if (picked.persona) {
ensureProtagonistFromPersona(
board,
picked.persona,
protagonistTagForOpeningSlot(picked.tag),
);
}
const body = splitOpeningDocument(
board.getContentByTag(picked.tag) ?? picked.text,
).text.trim() || picked.text.trim();
if (body) {
// 若叙事与黑板原文不同(剥了 meta),回写该开场 tag
const rawOnBoard = board.getContentByTag(picked.tag)?.trim() ?? "";
if (
dictateProductFamily(picked.tag) === OPENING_PRODUCT_FAMILY &&
rawOnBoard &&
body !== rawOnBoard &&
splitOpeningDocument(rawOnBoard).persona
) {
const existingDesign = board.getLatestByTag(picked.tag);
board.write({
tag: picked.tag,
content: body,
source: "runtime",
metadata: existingDesign?.metadata,
});
}
const expanded = this.expandDirectivesText(
body,
this.playDirectiveContext(s),
);
const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? "";
if (existing !== expanded) {
board.write({
tag: OPENING_OUTPUT_TAG,
content: expanded,
source: "runtime",
});
}
return expanded;
}
if (picked.persona) {
return board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? "";
}
}
const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? "";
if (existing) {
const expanded = this.expandDirectivesText(
existing,
this.playDirectiveContext(s),
);
if (expanded !== existing) {
board.write({
tag: OPENING_OUTPUT_TAG,
content: expanded,
source: "runtime",
});
}
return expanded;
}
const fromProducts = this.deriveOpeningFromDictateProducts(s);
const fromMessages = fromProducts
? ""
: this.deriveOpeningFromMessages(messageSource ?? s.messages);
const opening = (fromProducts || fromMessages).trim();
if (!opening) return "";
const expanded = this.expandDirectivesText(
opening,
this.playDirectiveContext(s),
);
board.write({
tag: OPENING_OUTPUT_TAG,
content: expanded,
source: "runtime",
});
return expanded;
}
/** 选中开场绑定的用户角色 → 运行.开场用户角色,并补写同槽主角设定。 */
private sealOpeningPersonaFromBoard(s: ManagedSession): void {
const board = s.runtime.getBlackboard();
if (readSealedOpeningPersona(board.getContentByTag(OPENING_PERSONA_TAG))) {
return;
}
const openings = collectOpenings(board);
const selectedRaw = s.runtime.getSession().slots[SLOT_OPENING_SELECTED_INDEX];
const selectedIndex =
typeof selectedRaw === "number"
? selectedRaw
: typeof selectedRaw === "string" && selectedRaw.trim()
? Number(selectedRaw)
: 0;
const picked =
openings.length > 0
? openings[
Math.min(
Math.max(0, Math.trunc(selectedIndex) || 0),
openings.length - 1,
)
]
: null;
const fromPicked = picked
? personaBoundToOpening(
board,
picked.tag,
board.getContentByTag(picked.tag) ?? picked.text,
)
: null;
const protagonist = parseProtagonistSpec(
board.getContentByTag(PROTAGONIST_TAG),
);
const persona =
fromPicked ??
(protagonist
? {
name: protagonist.name,
description: formatPersonaDescription(protagonist),
}
: null);
if (!persona) return;
board.write({
tag: OPENING_PERSONA_TAG,
content: serializeOpeningPersona(persona),
source: "runtime",
});
ensureProtagonistFromPersona(
board,
persona,
picked ? protagonistTagForOpeningSlot(picked.tag) : PROTAGONIST_TAG,
);
}
private personaForManaged(s: ManagedSession): ReturnType<
typeof personaForLifecycle
> {
if (!s.runtime) return personaForLifecycle("design");
return overlayPlayPersona(
inferLifecycleStage(s.runtime.getSession()),
s.runtime.getBlackboard(),
);
}
private playDirectiveContext(s: ManagedSession): DirectiveContext {
const p = this.personaForManaged(s);
if (!p) return {};
return { persona: p };
}
private directiveContextFor(s: ManagedSession): DirectiveContext {
const p = this.personaForManaged(s);
if (!p) return {};
return { persona: p };
}
/** 发送 / 开场白:匹配即替换;骰子写入展开后正文,不重掷。 */
private expandDirectivesText(text: string, ctx: DirectiveContext): string {
if (!text.includes("@") && !text.includes("{{user}}")) return text;
return expandAtDirectives(text, ctx).text;
}
private deriveOpeningFromMessages(messages: ChatMessage[] | undefined): string {
if (!messages?.length) return "";
for (let i = messages.length - 1; i >= 0; i -= 1) {
const m = messages[i];
if (!m || m.role === "user") continue;
const kind = m.kind ?? "";
// 转述聊天常夹确认/建议,不能当开场白
if (
kind === "dictate_reply" ||
kind === "listen_reply" ||
kind === "agent_tool" ||
kind === "orchestrator_thinking" ||
kind === "worker_questions"
) {
continue;
}
if (
kind === "system_info" &&
!/开场白|用户展示/.test(String(m.title ?? ""))
) {
continue;
}
const body = String(m.body ?? m.text ?? "").trim();
if (body.length < 24) continue;
if (
kind === "worker_output" ||
/开场白|用户展示/.test(String(m.title ?? "")) ||
m.actor === "narrator"
) {
return body;
}
if (body.length >= 80) return body;
}
return "";
}
private deriveOpeningFromDictateProducts(s: ManagedSession): string {
const products = this.listDictateProducts(s);
if (!products.length) return "";
const opening =
products.find((p) => p.tag === "设计.开场白")?.content?.trim() ||
products.find((p) => dictateProductFamily(p.tag) === OPENING_PRODUCT_FAMILY)
?.content?.trim();
if (opening) return opening;
const preferred = products.filter(
(p) =>
/开场|开局|场景|需求|设定|叙事/.test(p.tag) &&
p.tag !== "设计.正文组成" &&
!p.tag.includes("正文组成"),
);
const list = preferred.length ? preferred : products.filter((p) => p.tag !== "设计.正文组成");
if (!list.length) return "";
const chunks = list.slice(0, 8).map((p) => `【${p.tag}】\n${p.content}`);
return `开场设定已就绪。\n\n${chunks.join("\n\n")}`;
}
private restoreSnapshotInPlace(
s: ManagedSession,
snapshot: RunSnapshot,
layer: LifecycleStage,
): void {
const keepId = s.runtime.getSession().id;
let runtimeSession = structuredClone(snapshot.runtimeSession);
let blackboardItems = structuredClone(snapshot.blackboardItems);
if (snapshot.kind === "instance" || layer === "design") {
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
runtimeSession,
blackboardItems,
}));
}
runtimeSession.id = keepId;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: layer,
[SLOT_PLAY_LAYER_ACTIVE]: layer === "play" ? true : undefined,
[SLOT_PLAY_INSTANCE_ID]:
layer === "play"
? snapshot.instanceId ||
(typeof runtimeSession.slots[SLOT_PLAY_INSTANCE_ID] === "string"
? runtimeSession.slots[SLOT_PLAY_INSTANCE_ID]
: undefined)
: undefined,
startupCompleted: true,
};
if (layer === "play") {
runtimeSession.phase = runtimeSession.phase === "done"
? "waiting_user"
: runtimeSession.phase;
if (!runtimeSession.waitingReason) {
runtimeSession.waitingReason = { kind: "input" };
}
} else {
if (runtimeSession.phase === "done") {
runtimeSession.phase = "waiting_user";
}
if (!runtimeSession.waitingReason) {
runtimeSession.waitingReason = { kind: "input" };
}
runtimeSession.pendingArtifactId = undefined;
runtimeSession.pendingDecision = undefined;
runtimeSession.currentWorkerId = undefined;
}
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
s.messages = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[];
s.branchState = createMessageBranchState();
if (layer === "play") {
this.sanitizePlayHitl(s);
// 旧产物/工作副本可能没有开场消息:补一条到主区
if (!s.messages.some((m) => (m.body ?? m.text ?? "").trim())) {
const opening = this.ensurePlayOpeningWritten(
s,
snapshot.messages as ChatMessage[],
);
if (opening) {
s.messages = [this.playOpeningMessage(opening)];
const hist =
s.runtime.getBlackboard().getContentByTag(DIALOGUE_HISTORY_TAG) ?? "";
if (!hist.trim()) {
s.runtime.getBlackboard().write({
tag: DIALOGUE_HISTORY_TAG,
content: appendDialogueHistoryTurn("", {
role: "助手",
text: opening,
}),
source: "runtime",
});
}
}
}
}
}
/** 游玩层不保留创作 HITL(验收 / 确认下一步 / 修订) */
private sanitizePlayHitl(s: ManagedSession): void {
const session = s.runtime.getSession();
const play =
isPlayLayerActive(session.slots) || inferLifecycleStage(session) === "play";
if (!play) return;
const kind = session.waitingReason?.kind;
if (
kind !== "review_artifact" &&
kind !== "approve_step" &&
kind !== "revision" &&
kind !== "next_intent" &&
kind !== "pick_creation_step" &&
kind !== "worker_questions"
) {
return;
}
session.phase = "waiting_user";
session.waitingReason = { kind: "input" };
session.pendingArtifactId = undefined;
session.pendingDecision = undefined;
session.currentWorkerId = undefined;
session.resumeContext = undefined;
session.updatedAt = new Date().toISOString();
}
private savePlayWorking(s: ManagedSession): void {
if (!s.bookId) return;
const book = getBook(s.bookId);
const runtimeSession = structuredClone(s.runtime.getSession());
const snapshot: RunSnapshot = {
version: 1,
id: PLAY_WORKING_SNAPSHOT_ID,
bookId: s.bookId,
label: PLAY_WORKING_SNAPSHOT_LABEL,
kind: "run",
instanceId: this.currentPlayInstanceId(s),
orchestratorId:
sessionSkillPackId(runtimeSession) ??
(book ? bookSkillPackId(book) : "") ??
"",
runtimeSession,
blackboardItems: s.runtime.getBlackboard().exportItems(),
messages: s.messages.map((m) => ({ ...m })),
createdAt: new Date().toISOString(),
note: "切换创作/游玩时自动保存的当前聊天",
};
saveRunSnapshotFile(snapshot);
}
private archivePlayWorking(bookId: string): void {
const working = loadRunSnapshotFile(bookId, PLAY_WORKING_SNAPSHOT_ID);
if (!working) return;
if (!working.messages.some((m) => m.role === "user")) return;
const stamped: RunSnapshot = {
...working,
id: randomUUID(),
label: `游玩 ${new Date().toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}`,
createdAt: new Date().toISOString(),
note: "新建游玩前自动保存",
};
saveRunSnapshotFile(stamped);
}
private ensureInstanceSnapshot(s: ManagedSession): void {
if (!s.bookId) return;
if (this.latestInstanceSnapshot(s.bookId)) return;
if (!canEnterPlay(s.runtime.getSession())) return;
try {
this.saveGameSnapshot(
s.runtime.getSession().id,
INSTANCE_OPENING_SNAPSHOT_LABEL,
"instance",
"开玩前自动保存产物",
);
} catch (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);
}
/** 快照内创作产物(按流程步骤序);含 play-working */
getGameSnapshotProducts(
bookId: string,
snapshotId: string,
): { save: RunSnapshotMeta; products: SnapshotProductRow[] } {
if (!getBook(bookId)) throw new Error("Book 不存在");
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
if (!snapshot) throw new Error("存档不存在");
return {
save: toRunSnapshotMeta(snapshot),
products: listSnapshotProducts(snapshot.blackboardItems),
};
}
/** 手改快照某一产物 tag;写回文件。当前游玩工作副本会同步活板。 */
patchGameSnapshotProduct(
bookId: string,
snapshotId: string,
tag: string,
content: string,
): { save: RunSnapshotMeta; products: SnapshotProductRow[] } {
if (!getBook(bookId)) throw new Error("Book 不存在");
const trimmedTag = tag.trim();
if (!trimmedTag) throw new Error("tag 不能为空");
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
if (!snapshot) throw new Error("存档不存在");
const blackboardItems = patchBlackboardTagContent(
snapshot.blackboardItems,
trimmedTag,
content,
);
const next: RunSnapshot = { ...snapshot, blackboardItems };
saveRunSnapshotFile(next);
if (snapshotId === PLAY_WORKING_SNAPSHOT_ID) {
const active = this.getActiveSessionForBook(bookId);
if (active) {
try {
this.writeUserBoardTag(active.id, trimmedTag, content);
} catch {
/* 快照已写回 */
}
}
}
return {
save: toRunSnapshotMeta(next),
products: listSnapshotProducts(blackboardItems),
};
}
private latestInstanceSnapshot(bookId: string): RunSnapshot | null {
const meta = this.listGameSnapshots(bookId).find((s) => s.kind === "instance");
if (!meta) return null;
return loadRunSnapshotFile(bookId, meta.id);
}
private loadInstanceSnapshot(bookId: string, snapshotId: string): RunSnapshot {
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
if (!snapshot) throw new Error("产物不存在");
if (snapshot.kind !== "instance") throw new Error("该存档不是产物定稿");
return snapshot;
}
private restoreCreationFromDisk(s: ManagedSession): void {
const keepId = s.runtime.getSession().id;
if (!s.bookId) {
this.clearPlayLayerOnSession(s, keepId);
return;
}
const snap = loadBookSession(s.bookId);
if (!snap?.runtimeSession) {
this.clearPlayLayerOnSession(s, keepId);
return;
}
const runtimeSession = structuredClone(snap.runtimeSession);
runtimeSession.id = keepId;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "design",
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
};
s.runtime.restoreFromCheckpoint(runtimeSession, snap.blackboardItems);
s.messages = snap.messages.map((m) => ({ ...m })) as ChatMessage[];
s.branchState = this.deserializeBranchState(snap.messageBranchState);
}
private clearPlayLayerOnSession(s: ManagedSession, keepId: string): void {
const runtimeSession = structuredClone(s.runtime.getSession());
runtimeSession.id = keepId;
runtimeSession.slots = {
...runtimeSession.slots,
uiLifecycleStage: "design",
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
};
s.runtime.restoreFromCheckpoint(
runtimeSession,
s.runtime.getBlackboard().exportItems(),
);
}
private playOpeningMessage(text: string, keepId?: string): ChatMessage {
return {
id: keepId || randomUUID(),
role: "system",
text,
createdAt: new Date().toISOString(),
kind: "system_info",
actor: "narrator",
title: "开场白",
body: text,
};
}
/** 读档:产物开玩,游玩存档续玩。不覆盖创作过程。 */
async loadGameSnapshot(bookId: string, snapshotId: string): Promise<SessionView> {
const snapshot = loadRunSnapshotFile(bookId, snapshotId);
if (!snapshot) throw new Error("存档不存在");
const book = getBook(bookId);
if (!book) throw new Error("Book 不存在");
if (!skillPacksMatch(book, runSnapshotSkillPackId(snapshot))) {
throw new Error("存档与当前作品 skill 包不匹配,无法读档");
}
let sessionId = this.activeBookSessions.get(bookId);
if (!sessionId) {
await this.openBook(bookId);
sessionId = this.activeBookSessions.get(bookId);
}
if (!sessionId) throw new Error("无法打开作品");
const s = this.require(sessionId);
if (snapshot.kind === "instance") {
this.persistCreationIfDesign(s);
if (isPlayLayerActive(s.runtime.getSession().slots)) {
this.archivePlayWorking(bookId);
}
this.beginNewPlayFromInstance(s, snapshot.id);
this.savePlayWorking(s);
return this.toView(
sessionId,
true,
`已用产物「${snapshot.label}」开玩。创作流程仍在「创作」里。`,
);
}
this.persistCreationIfDesign(s);
this.restoreSnapshotInPlace(s, snapshot, "play");
this.savePlayWorking(s);
return this.toView(
sessionId,
true,
`已从存档「${snapshot.label}」继续游玩。`,
);
}
/** 删除单个存档 */
deleteGameSnapshot(bookId: string, snapshotId: string): void {
if (!getBook(bookId)) throw new Error("Book 不存在");
if (!deleteRunSnapshotFile(bookId, snapshotId)) {
throw new Error("存档不存在");
}
}
/** 删除 Book 时清理内存中的会话 */
dropBookSessions(bookId: string): void {
for (const [sessionId, managed] of this.sessions) {
if (managed.bookId === bookId) {
this.sessions.delete(sessionId);
}
}
this.activeBookSessions.delete(bookId);
}
/** 将当前 API / 预设设置应用到所有活跃会话 */
reloadAllLlms(): number {
let count = 0;
for (const s of this.sessions.values()) {
const llm = reloadDefaultMainAgentLlm(s.trackingRef, () =>
this.personaForManaged(s),
);
s.runtime.reloadLlm(llm, !hasRealLlmConfig());
count += 1;
}
return count;
}
/**
* 切换/改了全局用户角色:游玩层开场按新 @玩家 重展开,并改用全局角色卡。
*/
rebindPlayOpeningsForActivePersona(): number {
let count = 0;
for (const [sessionId, s] of this.sessions) {
if (!isPlayLayerActive(s.runtime.getSession().slots)) continue;
if (inferLifecycleStage(s.runtime.getSession()) !== "play") continue;
const aux = buildPlayAuxView(
s.runtime.getBlackboard(),
s.runtime.getSession().slots,
);
if (!aux.openings.length) continue;
try {
this.selectPlayOpening(sessionId, aux.selectedOpeningIndex, false);
count += 1;
} catch {
/* 单局失败不挡其它局 */
}
}
return count;
}
async sendMessage(
id: string,
text: string,
opts?: { answers?: QuestionAnswer[] },
): Promise<SessionView> {
const s = this.require(id);
this.sanitizePlayHitl(s);
const waitingBefore = s.runtime.getSession().waitingReason;
if (
inferLifecycleStage(s.runtime.getSession()) === "play" &&
!isPlayLayerActive(s.runtime.getSession().slots) &&
waitingBefore?.kind === "input"
) {
this.enterPlayLayer(s, { resume: true });
if (s.bookId) this.persist(s);
}
this.clearAgentThinking(id);
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
const reason = s.runtime.getSession().waitingReason;
const trimmedRaw = text.trim();
const trimmed = this.expandDirectivesText(
trimmedRaw,
this.directiveContextFor(s),
);
const sidecarAnswers =
reason?.kind === "review_artifact" &&
reason.questions?.length &&
opts?.answers?.length
? opts.answers.filter((a) => a.text?.trim() && a.text !== "(未答)")
: [];
// next_intent 允许留空:不塞空白气泡
if (trimmed || reason?.kind !== "next_intent" || sidecarAnswers.length) {
let recordText = trimmed || text;
let displayBody: string | undefined;
if (sidecarAnswers.length && reason?.kind === "review_artifact") {
const qs = normalizeQuestions(reason.questions ?? []);
recordText = formatQuestionAnswersForAi(
qs,
sidecarAnswers,
trimmed || undefined,
);
displayBody = formatQuestionAnswersForDisplay(
qs,
sidecarAnswers,
trimmed || undefined,
);
}
const userMsg = this.msg("user", recordText);
if (displayBody) userMsg.body = displayBody;
s.messages.push(userMsg);
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
try {
const owned = await this.runExclusive(id, s, async () => {
// 转述提示词只服务创作进料;游玩层必须走 submitInput → GM/叙事转述管线
{
const sess = s.runtime.getSession();
const inPlay =
isPlayLayerActive(sess.slots) ||
inferLifecycleStage(sess) === "play";
if (reason?.kind === "input" && !inPlay) {
if (this.isListenSerialMode(s)) {
await this.runListenSerialTurnForSession(s, trimmed || text);
return;
}
if (this.isDictateMode(s)) {
await this.runDictateTurnForSession(s, trimmed || text);
return;
}
}
}
if (reason?.kind === "approve_step") {
await s.runtime.rejectStep(trimmed || text);
} else if (reason?.kind === "review_artifact") {
// 先把卡上追问(问+选项+补充)写入「用户.worker答复」,再按意见改产物
if (sidecarAnswers.length && reason.questions?.length) {
const qaText = formatQuestionAnswersForAi(
normalizeQuestions(reason.questions),
sidecarAnswers,
trimmed || undefined,
);
await s.runtime.resolveSidecarQuestions(qaText);
}
if (trimmed || sidecarAnswers.length) {
await s.runtime.rejectArtifact(
trimmed || "按追问作答更新产物",
);
}
} else {
await s.runtime.submitInput(trimmed || text);
}
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 发送消息失败", detail);
await this.recoverFailedRun(s, detail);
}
await this.maybeCollectPreferencesAfterPlayTurn(s, {
countedPlayInput: Boolean(
waitingBefore?.kind === "input" &&
isPlayLayerActive(s.runtime.getSession().slots) &&
s.runtime.getSession().waitingReason?.kind === "input",
),
});
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/**
* 游玩回合结束后:计数;到期则采集偏好候选(不改本局用户约束)。
*/
private async maybeCollectPreferencesAfterPlayTurn(
s: ManagedSession,
opts: { countedPlayInput: boolean },
): Promise<void> {
if (!opts.countedPlayInput) return;
if (!isPlayLayerActive(s.runtime.getSession().slots)) return;
if (s.preferenceReview?.length) return;
const every = normalizePreferenceCollectEveryTurns(
loadAppSettings().preferenceCollectEveryTurns,
);
if (every <= 0) return;
const turns = (s.preferencePlayTurns ?? 0) + 1;
s.preferencePlayTurns = turns;
if (turns % every !== 0) return;
if (!hasRealLlmConfig()) return;
const dialogue = formatRecentDialogueForCollect(s.messages);
if (!dialogue.trim()) return;
try {
const llm = createDefaultMainAgentLlm(s.trackingRef, () =>
this.personaForManaged(s),
);
const candidates = await collectPreferenceCandidates({
llm,
recentDialogue: dialogue,
});
if (candidates.length) {
s.preferenceReview = candidates;
s.messages.push(
this.msg(
"system",
`[偏好] 发现 ${candidates.length} 条可记入偏好库的候选,请在卡片上编辑后收下或不要。`,
),
);
}
} catch (err) {
console.warn(
"[偏好] 采集失败",
err instanceof Error ? err.message : String(err),
);
}
}
/** 审核偏好候选:收下(可改正文)/ 不要。不改本局「设计.用户约束」。 */
resolvePreferenceReview(
sessionId: string,
candidateId: string,
action: "accept" | "reject",
content?: string,
): SessionView {
const s = this.require(sessionId);
const list = s.preferenceReview ?? [];
const idx = list.findIndex((c) => c.id === candidateId);
if (idx < 0) throw new Error("没有这条待审偏好");
const cur = list[idx]!;
if (action === "accept") {
const body = (content ?? cur.content).trim();
if (!body) throw new Error("偏好内容不能为空");
acceptPreferenceCandidate(body);
}
const next = list.filter((c) => c.id !== candidateId);
s.preferenceReview = next.length ? next : undefined;
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
/**
* 结构化追问作答:UI 可省略结构,发给 AI 必须含问+答。
* displayText 进气泡;aiText 进 runtime / 黑板。
* review_artifact 挂载题:作答后仍停留在验收态(可再 Accept)。
*/
async answerQuestions(
id: string,
answers: QuestionAnswer[],
note?: string,
): Promise<SessionView> {
const s = this.require(id);
const reason = s.runtime.getSession().waitingReason;
const isSidecar =
reason?.kind === "review_artifact" && Boolean(reason.questions?.length);
const questions =
reason?.kind === "worker_questions"
? reason.questions
: reason?.kind === "input"
? (reason.questions ?? [])
: reason?.kind === "review_artifact"
? (reason.questions ?? [])
: [];
if (!questions.length) {
throw new Error("当前没有待回答的结构化追问");
}
const aiText = formatQuestionAnswersForAi(questions, answers, note);
const displayText = formatQuestionAnswersForDisplay(questions, answers, note);
this.clearAgentThinking(id);
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
const userMsg = this.msg("user", aiText);
userMsg.body = displayText;
s.messages.push(userMsg);
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
const owned = await this.runExclusive(id, s, async () => {
if (isSidecar) {
await s.runtime.resolveSidecarQuestions(aiText);
// 作答必须写回产物,否则验收后进度空转、追问在美学/交互间打转
await s.runtime.rejectArtifact("按追问作答更新产物");
} else {
await s.runtime.submitInput(aiText);
}
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 回答追问失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/**
* 跳过追问:
* - review_artifact 挂载题:只收起题,留在验收
* - input / worker_questions:提交跳过句并继续
*/
async skipQuestions(id: string): Promise<SessionView> {
const s = this.require(id);
const reason = s.runtime.getSession().waitingReason;
const skipText = "(用户跳过追问)请基于已有信息继续。";
if (reason?.kind === "review_artifact" && reason.questions?.length) {
this.clearAgentThinking(id);
s.messages.push(this.msg("user", "跳过可选追问"));
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.resolveSidecarQuestions(),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
if (
(reason?.kind === "input" && reason.questions?.length) ||
reason?.kind === "worker_questions"
) {
return this.sendMessage(id, skipText);
}
throw new Error("当前没有可跳过的追问");
}
/** 编辑用户消息 → 新分支 + 从该点重跑 */
async editMessage(id: string, messageId: string, newText: string): Promise<SessionView> {
const s = this.require(id);
const trimmed = this.expandDirectivesText(
newText.trim(),
this.directiveContextFor(s),
);
if (!trimmed) throw new Error("内容不能为空");
const idx = s.messages.findIndex((m) => m.id === messageId);
if (idx < 0) throw new Error("消息不存在");
const target = s.messages[idx];
if (target.role !== "user") throw new Error("只能编辑用户消息");
const checkpoint =
s.branchState.preMessageCheckpoints[idx] ?? this.captureCheckpoint(s);
const branch = ensureBranchForEdit(s.branchState, s.messages, idx, checkpoint);
s.runtime.restoreFromCheckpoint(checkpoint.runtimeSession, checkpoint.blackboardItems);
s.messages = s.messages.slice(0, idx);
const variantIndex = branch.variants.length;
const userMsg = createUserVariantMessage(trimmed, branch.groupId, variantIndex) as ChatMessage;
appendBranchVariant(branch, userMsg, checkpoint);
s.messages.push(userMsg);
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
const owned = await this.runExclusive(id, s, async () => {
const sess = s.runtime.getSession();
const reason = sess.waitingReason;
const inPlay =
isPlayLayerActive(sess.slots) ||
inferLifecycleStage(sess) === "play";
if (reason?.kind === "input" && !inPlay) {
if (this.isListenSerialMode(s)) {
await this.runListenSerialTurnForSession(s, trimmed);
return;
}
if (this.isDictateMode(s)) {
await this.runDictateTurnForSession(s, trimmed);
return;
}
}
await s.runtime.submitInput(trimmed);
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
updateActiveBranchVariant(
branch,
s.messages.slice(branch.anchorIndex),
this.captureCheckpoint(s),
);
for (const m of s.messages.slice(branch.anchorIndex)) {
m.branchGroupId = branch.groupId;
m.branchIndex = branch.activeIndex;
m.branchTotal = branch.variants.length;
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 刷新 Worker / Skill 回复 → 重 roll */
async refreshMessage(id: string, messageId: string): Promise<SessionView> {
const s = this.require(id);
const idx = s.messages.findIndex((m) => m.id === messageId);
if (idx < 0) throw new Error("消息不存在");
const target = s.messages[idx];
if (!isRefreshableMessage(target)) {
throw new Error("此消息不支持刷新");
}
const workerSnap = s.runtime.getLastWorkerRunSnapshot();
const workerId = target.actor ?? workerSnap?.workerId;
if (!workerId) throw new Error("无法识别要刷新的 Worker");
const groupId = target.branchGroupId ?? target.id;
const existingBranch = s.branchState.branches[groupId];
const branchVariantCp =
existingBranch?.variants[existingBranch.activeIndex]?.checkpoint;
const checkpoint =
workerSnap ??
s.branchState.preMessageCheckpoints[idx] ??
branchVariantCp;
if (!checkpoint) {
throw new Error("缺少重 roll 快照,请尝试编辑上一条用户消息");
}
const branch = ensureBranchForRefresh(
s.branchState,
s.messages,
idx,
{
runtimeSession: checkpoint.runtimeSession,
blackboardItems: checkpoint.blackboardItems,
},
);
s.runtime.restoreFromCheckpoint(checkpoint.runtimeSession, checkpoint.blackboardItems);
s.messages = s.messages.slice(0, idx);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.rerunWorker(workerId),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
const newIndex = branch.variants.length;
branch.variants.push({
messages: structuredClone(s.messages.slice(idx)),
checkpoint: this.captureCheckpoint(s),
});
branch.activeIndex = newIndex;
for (const m of s.messages.slice(branch.anchorIndex)) {
m.branchGroupId = branch.groupId;
m.branchIndex = branch.activeIndex;
m.branchTotal = branch.variants.length;
}
this.persist(s);
return this.toView(id);
}
/** 中止当前生成并重跑卡住的 worker / 总管 */
async abortAndRetry(id: string): Promise<SessionView> {
const s = this.require(id);
const session = s.runtime.getSession();
if (session.phase !== "running" || session.waitingReason) {
throw new Error("当前没有正在执行的任务");
}
const cutoff = s.runMessageCutoff ?? s.messages.length;
s.messages = s.messages.slice(0, cutoff);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
const owned = await this.runExclusive(id, s, async () => {
await s.runtime.retryStuckRun();
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 停止并重试失败", detail);
await this.recoverFailedRun(s, detail);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 中止当前生成,不重跑;回到生成前的等待面或分层图。 */
async abortRun(id: string): Promise<SessionView> {
const s = this.require(id);
const session = s.runtime.getSession();
if (session.phase !== "running" || session.waitingReason) {
return this.toView(id);
}
const cutoff = s.runMessageCutoff ?? s.messages.length;
s.messages = s.messages.slice(0, cutoff);
try {
const owned = await this.runExclusive(id, s, async () => {
s.runtime.abortCurrentRun();
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 停止生成失败", detail);
await this.recoverFailedRun(s, detail);
}
s.messages.push(this.msg("system", "已停止生成。"));
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 删除消息及其后的对话(仅截断,不重生成) */
async deleteMessage(id: string, messageId: string): Promise<SessionView> {
const s = this.require(id);
const idx = s.messages.findIndex((m) => m.id === messageId);
if (idx < 0) throw new Error("消息不存在");
const checkpoint = s.branchState.preMessageCheckpoints[idx];
if (!checkpoint && idx > 0) throw new Error("无法删除此消息");
if (checkpoint) {
s.runtime.restoreFromCheckpoint(
checkpoint.runtimeSession,
checkpoint.blackboardItems,
);
}
s.messages = s.messages.slice(0, idx);
for (const key of Object.keys(s.branchState.preMessageCheckpoints)) {
const n = Number(key);
if (n >= idx) delete s.branchState.preMessageCheckpoints[n];
}
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
this.persist(s);
return this.toView(id);
}
/**
* 从这里重开:丢掉该条及之后内容,并按本轮用户输入重新生成(重 roll)。
* 用户消息 → 同文编辑重跑;可刷新的 Worker 回复 → refresh;其余 → 回溯到上一轮用户输入再 edit。
*/
async restartFromMessage(id: string, messageId: string): Promise<SessionView> {
const s = this.require(id);
const idx = s.messages.findIndex((m) => m.id === messageId);
if (idx < 0) throw new Error("消息不存在");
const target = s.messages[idx];
if (target.role === "user") {
return this.editMessage(id, messageId, target.text);
}
if (isRefreshableMessage(target)) {
return this.refreshMessage(id, messageId);
}
const userIdx = findPrecedingUserIndex(s.messages, idx);
if (userIdx < 0) {
return this.deleteMessage(id, messageId);
}
const userMsg = s.messages[userIdx];
return this.editMessage(id, userMsg.id, userMsg.text);
}
/** 左右切换同位消息版本(swipe / branch) */
async switchMessageVariant(
id: string,
messageId: string,
direction: "prev" | "next",
): Promise<SessionView> {
const s = this.require(id);
const idx = s.messages.findIndex((m) => m.id === messageId);
if (idx < 0) throw new Error("消息不存在");
const target = s.messages[idx];
const groupId = target.branchGroupId ?? target.id;
const delta = direction === "prev" ? -1 : 1;
const switched = switchBranchVariant(s.branchState, s.messages, groupId, delta);
if (!switched) throw new Error("没有更多版本");
s.messages = switched.messages as ChatMessage[];
s.runtime.restoreFromCheckpoint(
switched.checkpoint.runtimeSession,
switched.checkpoint.blackboardItems,
);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
this.persist(s);
return this.toView(id);
}
/**
* 多模型对比:冻结该消息 contextTrace,对各 profile 并行重放;不改会话。
* 采用结果见 adoptModelCompare。
*/
async startModelCompare(
id: string,
messageId: string,
profileIds: string[],
): Promise<ModelCompareJob> {
const s = this.require(id);
const resolved = this.resolveMessageContextTrace(s, messageId);
if (!resolved?.trace?.messages?.length) {
throw new Error("这条消息没有可重放的上下文(可能已被修剪)");
}
const { message, trace } = resolved;
const job = createCompareJob({
sessionId: id,
messageId: message.id,
profileIds,
original: {
text: String(message.body ?? message.text ?? ""),
thinking: message.thinking,
model: trace.model ?? message.tokenUsage?.model,
},
});
void runCompareJob(job, trace).catch((err) => {
job.status = "error";
const detail = err instanceof Error ? err.message : String(err);
for (const c of job.candidates) {
if (c.status === "pending" || c.status === "running") {
c.status = "error";
c.error = detail;
c.finishedAt = new Date().toISOString();
}
}
});
return job;
}
getModelCompare(id: string, jobId: string): ModelCompareJob {
const job = getCompareJob(jobId);
if (!job || job.sessionId !== id) {
throw new Error("对比任务不存在或已过期");
}
return job;
}
/**
* 采用某一 profile 的对比结果:写入消息 branch;
* 选用版为 active,其余成功候选与原文一并留作废案(可 swipe)。
*/
async adoptModelCompare(
id: string,
jobId: string,
profileId: string,
): Promise<SessionView> {
const s = this.require(id);
const job = this.getModelCompare(id, jobId);
const chosen = findCandidate(job, profileId);
if (!chosen || chosen.status !== "done" || chosen.content == null) {
throw new Error("只能采用已成功生成的结果");
}
const idx = s.messages.findIndex((m) => m.id === job.messageId);
if (idx < 0) throw new Error("原消息已不存在,无法采用");
const target = s.messages[idx];
const checkpoint =
s.branchState.preMessageCheckpoints[idx] ??
s.runtime.getLastWorkerRunSnapshot() ??
this.captureCheckpoint(s);
const branch = ensureBranchForRefresh(s.branchState, s.messages, idx, {
runtimeSession: checkpoint.runtimeSession,
blackboardItems: checkpoint.blackboardItems,
});
const baseCheckpoint = this.captureCheckpoint(s);
const successCandidates = job.candidates.filter(
(c) => c.status === "done" && c.content != null,
);
const tailAfter = s.messages.slice(idx + 1).map((m) => ({ ...m }));
let chosenVariantIndex = branch.activeIndex;
for (const candidate of successCandidates) {
const head: ChatMessage = {
...target,
id: randomUUID(),
text: candidate.content!,
body: candidate.content!,
thinking: candidate.thinking,
createdAt: new Date().toISOString(),
tokenUsage: {
totalTokens: candidate.usage?.totalTokens ?? 0,
promptTokens: 0,
completionTokens: 0,
cachedTokens: candidate.usage?.cachedTokens,
cacheMissTokens: candidate.usage?.cacheMissTokens,
caller: `model-compare:${candidate.profileId}`,
model: candidate.model,
},
branchGroupId: branch.groupId,
branchIndex: branch.variants.length,
};
const variantIndex = branch.variants.length;
branch.variants.push({
messages: [head, ...tailAfter.map((m) => ({ ...m }))],
checkpoint: cloneCheckpoint(baseCheckpoint),
});
if (candidate.profileId === profileId) {
chosenVariantIndex = variantIndex;
}
}
branch.activeIndex = chosenVariantIndex;
const active = branch.variants[chosenVariantIndex];
if (!active) throw new Error("采用失败:版本缺失");
s.messages = [
...s.messages.slice(0, branch.anchorIndex),
...(active.messages as ChatMessage[]),
];
s.runtime.restoreFromCheckpoint(
active.checkpoint.runtimeSession,
active.checkpoint.blackboardItems,
);
for (const m of s.messages.slice(branch.anchorIndex)) {
m.branchGroupId = branch.groupId;
m.branchIndex = branch.activeIndex;
m.branchTotal = branch.variants.length;
}
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
this.persist(s);
return this.toView(id);
}
private resolveMessageContextTrace(
s: ManagedSession,
messageId: string,
): { message: ChatMessage; trace: LlmContextTrace } | null {
const msg = s.messages.find((m) => m.id === messageId);
if (msg?.contextTrace?.messages?.length) {
return { message: msg, trace: msg.contextTrace };
}
const review = buildReviewArtifactView(
s.runtime.getSession(),
s.messages,
s.runtime.getBlackboard(),
s.moduleCatalog,
);
if (
review?.contextTrace?.messages?.length &&
(review.sourceMessageId === messageId || review.id === messageId)
) {
const source =
(review.sourceMessageId
? s.messages.find((m) => m.id === review.sourceMessageId)
: undefined) ?? msg;
if (!source) return null;
return { message: source, trace: review.contextTrace };
}
return null;
}
async approve(
id: string,
stepParams?: Record<string, unknown>,
): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.approve(stepParams),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 确认开干失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
return this.toView(id);
}
async confirmIntake(id: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.confirmIntake(),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 确认需求失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
return this.toView(id);
}
async accept(
id: string,
opts?: { openingIndex?: number },
): Promise<SessionView> {
const s = this.require(id);
const pendingId = s.runtime.getSession().pendingArtifactId;
const waiting = s.runtime.getSession().waitingReason;
const hadSidecarQuestions =
waiting?.kind === "review_artifact" && Boolean(waiting.questions?.length);
const artifact = pendingId
? s.runtime.getSession().artifacts.find((a) => a.id === pendingId)
: undefined;
const stageBefore = inferLifecycleStage(s.runtime.getSession());
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.acceptArtifact(undefined, opts),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 接受产物失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
this.persist(s);
return this.toView(id);
}
// 接受产物 = 不再用追问完善;询问模块随 waitingReason 清除而收起
if (hadSidecarQuestions) {
s.messages.push(
this.msg("system", "[验收] 已接受产物,可选追问已收起(无需再完善)。"),
);
}
if (artifact) {
if (stageBefore === "design") {
// 创作验收:messages 全量保留供用户浏览;仅同步拼给 AI 的「创作.对话」
this.syncCreationDialogue(s);
} else {
foldRunProcessMessages(s.messages, artifact.workerId);
}
}
this.persist(s);
return this.toView(id);
}
/** 拒绝当前待确认步骤或待验收产物(无说明时触发重新来 / 回到总管) */
async reject(id: string, reason?: string): Promise<SessionView> {
const s = this.require(id);
const waiting = s.runtime.getSession().waitingReason;
try {
const owned = await this.runExclusive(id, s, async () => {
if (waiting?.kind === "approve_step") {
await s.runtime.leaveCreationStep();
} else if (waiting?.kind === "review_artifact") {
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
} else {
throw new Error("当前没有可拒绝的确认或验收");
}
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 打回失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 离开当前技能步,回到分层图点选 */
async leaveStep(id: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.leaveCreationStep(),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 返回节点失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
/** 开场白等步骤上发现漏了前序节点:再进流程编排,不整局重开 */
async replan(id: string, reason?: string): Promise<SessionView> {
const s = this.require(id);
const note = reason?.trim() || "回到流程编排,追加或改排节点。";
this.clearAgentThinking(id);
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
s.messages.push(this.msg("user", note));
this.syncCreationDialogue(s);
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.replanCreationFlow(note),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 再编排失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
this.syncCreationDialogue(s);
this.persist(s);
return this.toView(id);
}
async pickStep(
id: string,
stepId: string,
stepParams?: Record<string, unknown>,
reenter?: boolean,
): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, async () => {
await this.confirmPendingFlowPlan(s);
const waiting = s.runtime.getSession().waitingReason?.kind;
if (waiting !== "pick_creation_step") {
throw new Error("当前不是点选节点的时机");
}
await s.runtime.pickCreationStep(stepId, stepParams, {
reenter: reenter === true,
});
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 点选节点失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
this.persist(s);
return this.toView(id);
}
async getStepArtifact(
id: string,
stepId: string,
): Promise<{
stepId: string;
name: string;
tag: string | null;
content: string;
}> {
const s = this.require(id);
const catalog = s.moduleCatalog;
const flow = parseCreationFlow(
s.runtime.getBlackboard().getContentByTag(CREATION_FLOW_TAG),
);
if (!flow) throw new Error("还没有工作流计划");
const cleaned = stripReviseSteps(flow);
const step = findStepByRef(cleaned, stepId.trim());
if (!step) throw new Error(`找不到节点:${stepId}`);
const mod = findModuleByName(catalog, step.name);
const tag = mod?.artifact?.trim() || null;
const content = tag
? s.runtime.getBlackboard().getContentByTag(tag)?.trim() || ""
: "";
return { stepId: step.id, name: step.name, tag, content };
}
async spawnStep(id: string, moduleName: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, async () => {
await this.confirmPendingFlowPlan(s);
const waiting = s.runtime.getSession().waitingReason?.kind;
if (waiting !== "pick_creation_step") {
throw new Error("当前不是追加节点的时机");
}
await s.runtime.spawnCreationStep(moduleName);
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 追加节点失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
this.persist(s);
return this.toView(id);
}
async deleteStep(id: string, stepId: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, async () => {
await this.confirmPendingFlowPlan(s);
const waiting = s.runtime.getSession().waitingReason?.kind;
if (waiting !== "pick_creation_step") {
throw new Error("当前不是删除产物的时机");
}
await s.runtime.deleteCreationStep(stepId);
});
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error("[会话] 删除产物失败", detail);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
this.persist(s);
return this.toView(id);
}
/** 已点进技能步、尚未验收:可退回分层图(误点可增殖节点时用) */
private canOfferLeaveCreationStep(
s: ManagedSession,
reason: WaitingReason | undefined,
): boolean {
const session = s.runtime.getSession();
if (
isPlayLayerActive(session.slots) ||
inferLifecycleStage(session) === "play"
) {
return false;
}
const kind = reason?.kind;
if (session.phase === "running" && !kind) {
return Boolean(
s.runtime.getBlackboard().getContentByTag(CREATION_FLOW_TAG)?.trim(),
);
}
if (kind === "review_artifact") {
return Boolean(
s.runtime.getBlackboard().getContentByTag(CREATION_FLOW_TAG)?.trim(),
);
}
if (
kind !== "worker_questions" &&
kind !== "input" &&
kind !== "revision" &&
kind !== "approve_step"
) {
return false;
}
const stepId =
s.runtime.getBlackboard().getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() ||
session.currentStepId?.trim() ||
"";
return Boolean(stepId);
}
/** 流程编排还停在核对态时,点节点先收下计划再进入 */
private async confirmPendingFlowPlan(s: ManagedSession): Promise<void> {
const session = s.runtime.getSession();
if (session.waitingReason?.kind !== "review_artifact") return;
const art = session.pendingArtifactId
? session.artifacts.find((a) => a.id === session.pendingArtifactId)
: undefined;
if (art?.workerId !== DESIGN_FLOW_WORKER_ID) {
throw new Error("当前不是点选节点的时机");
}
await s.runtime.acceptArtifact();
}
async runOutline(id: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.submitDecision(
createDecision({
action: "run_worker",
reason: "根据创作简报生成大纲",
workerId: "outline",
requiresApproval: true,
}),
),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
return this.toView(id);
}
async finish(id: string): Promise<SessionView> {
const s = this.require(id);
try {
const owned = await this.runExclusive(id, s, () =>
s.runtime.submitDecision(
createDecision({
action: "finish",
reason: "创作流程结束",
requiresApproval: false,
}),
),
);
if (!owned) return this.toView(id);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
s.messages.push(this.msg("system", formatRuntimeError(detail)));
}
return this.toView(id);
}
private async restoreFromSnapshot(
snapshot: PersistedBookSession,
): Promise<SessionView> {
return this.mountRestoredSession({
sessionId: snapshot.sessionId,
bookId: snapshot.bookId,
skillPackId: persistedSkillPackId(snapshot) ?? "",
runtimeSession: snapshot.runtimeSession,
blackboardItems: snapshot.blackboardItems,
messages: snapshot.messages.map((m) => ({ ...m })) as ChatMessage[],
messageBranchState: snapshot.messageBranchState,
resumeHint: "已从上次进度恢复,可继续创作。",
});
}
private async mountRestoredSession(params: {
sessionId: string;
bookId: string;
skillPackId: string;
runtimeSession: RuntimeSession;
blackboardItems: import("../types/blackboard.js").BlackboardItem[];
messages: ChatMessage[];
messageBranchState?: import("../types/book-session.js").PersistedMessageBranchState;
resumeHint: string;
}): Promise<SessionView> {
const book = getBook(params.bookId);
const trackingRef: LlmTrackingRef = {
current: {
sessionId: params.sessionId,
bookId: params.bookId,
bookTitle: book?.title,
orchestratorId: params.skillPackId || undefined,
},
};
let managedRef: ManagedSession | null = null;
const onMessage = this.buildOnMessageHandler(() => managedRef);
const thinkingHandlers = this.buildStreamHandlers(
params.sessionId,
() => managedRef,
);
const runtime = new PhaseRuntime({
autoStubWorker: !hasRealLlmConfig(),
llm: createDefaultMainAgentLlm(trackingRef, () =>
managedRef
? this.personaForManaged(managedRef)
: personaForLifecycle("design"),
),
onMessage,
...thinkingHandlers,
initialSession: params.runtimeSession,
initialBlackboardItems: params.blackboardItems,
});
await runtime.ensureAvailableSkills();
const keep = normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest,
);
managedRef = {
runtime,
messages: pruneContextTraces(params.messages, keep),
bookId: params.bookId,
trackingRef,
branchState: this.deserializeBranchState(params.messageBranchState),
};
this.sessions.set(params.sessionId, managedRef);
this.activeBookSessions.set(params.bookId, params.sessionId);
appendBookSession(params.bookId, params.sessionId);
await this.refreshRecipeOptions(managedRef);
runtime.recoverOrphanedRun();
this.persist(managedRef);
await this.maybeKickAgentAfterDemand(runtime);
return this.toView(params.sessionId, true, params.resumeHint);
}
/** 旧会话:用户已写需求但 Agent 未跑过 → 补 invoke 总管 */
private async maybeKickAgentAfterDemand(runtime: PhaseRuntime): Promise<void> {
const session = runtime.getSession();
const active = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
if (!active || effectiveStartupMode(active) !== "agent-first") return;
if (session.slots.startupCompleted) return;
const demandKey = active.startupTargetKey || "用户.需求";
const text = String(
session.slots[demandKey] ?? session.slots.lastUserInput ?? "",
).trim();
if (!text) return;
const reason = session.waitingReason?.kind;
if (reason !== "input" && reason !== "intake") return;
try {
await runtime.dispatch({
type: "user_submitted_input",
payload: { text },
});
} catch (err) {
console.error("[会话] 需求后唤醒总管失败", err);
}
}
private buildOnMessageHandler(getManaged: () => ManagedSession | null) {
return (text: string) => {
const managed = getManaged();
if (!managed) return;
recordPreMessageCheckpoint(
managed.branchState,
managed.messages.length,
this.captureCheckpoint(managed),
);
const usage = managed.trackingRef.current.pendingUsage;
const sessionId = managed.trackingRef.current.sessionId;
const liveThinking = sessionId
? this.workerLive.get(sessionId)?.thinking?.trim()
: "";
managed.trackingRef.current.pendingUsage = undefined;
const pendingReasoning = managed.trackingRef.current.pendingReasoning;
managed.trackingRef.current.pendingReasoning = undefined;
const classified =
text.trim().length > 0 ? classifyAgentMessage(text) : null;
const thinking =
pendingReasoning ||
(classified?.kind === "worker_output" ||
classified?.kind === "worker_questions"
? liveThinking
: "") ||
undefined;
const pendingTrace = managed.trackingRef.current.pendingContextTrace;
let contextTrace: LlmContextTrace | undefined;
let requestOutlines: RequestOutline[] | undefined;
if (
pendingTrace &&
classified &&
CONTEXT_TRACE_MESSAGE_KINDS.has(classified.kind)
) {
const keep = normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest,
);
if (keep > 0) {
contextTrace = pendingTrace;
}
managed.trackingRef.current.pendingContextTrace = undefined;
requestOutlines = managed.trackingRef.current.pendingOutlines;
managed.trackingRef.current.pendingOutlines = undefined;
}
managed.messages.push(
this.msg(
"system",
text,
usage,
thinking,
contextTrace,
requestOutlines?.length ? requestOutlines : undefined,
),
);
managed.messages = pruneContextTraces(
managed.messages,
normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest,
),
);
this.syncCreationDialogue(managed);
if (managed.bookId) {
this.syncBookPreview(managed.bookId, managed.messages);
this.persist(managed);
}
};
}
/** 创作模式:从全量 messages 拼「创作.对话」(AI 只留最后一次),不改用户可见消息 */
private syncCreationDialogue(s: ManagedSession): void {
try {
if (inferLifecycleStage(s.runtime.getSession()) !== "design") return;
const transcript = buildCreationDialogueTranscript(s.messages);
s.runtime.writeCreationDialogue(transcript);
} catch (err) {
console.warn("[会话] 同步创作对话失败", err);
}
}
/** 游玩中只更新工作副本,不覆盖创作过程 */
private persist(s: ManagedSession): void {
if (!s.bookId) return;
const book = getBook(s.bookId);
if (!book) return;
const session = s.runtime.getSession();
if (
isPlayLayerActive(session.slots) ||
inferLifecycleStage(session) === "play"
) {
this.savePlayWorking(s);
this.syncBookSkill(s.bookId, session);
updateBook(s.bookId, { activeSessionId: session.id });
return;
}
this.writeBookSession(s);
}
/** 切到游玩前把当前创作过程写入 session.json */
private persistCreationIfDesign(s: ManagedSession): void {
if (!s.bookId) return;
if (isPlayLayerActive(s.runtime.getSession().slots)) return;
this.writeBookSession(s);
}
private writeBookSession(s: ManagedSession): void {
if (!s.bookId) return;
const book = getBook(s.bookId);
if (!book) return;
const session = s.runtime.getSession();
const skillPackId =
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined) ?? "";
const snapshot: PersistedBookSession = {
version: 1,
sessionId: session.id,
bookId: s.bookId,
orchestratorId: skillPackId || undefined,
runtimeSession: session,
blackboardItems: s.runtime.getBlackboard().exportItems(),
messages: s.messages,
messageBranchState: this.serializeBranchState(s.branchState),
savedAt: new Date().toISOString(),
};
saveBookSession(snapshot);
this.syncBookSkill(s.bookId, session);
updateBook(s.bookId, { activeSessionId: session.id });
}
private syncBookSkill(bookId: string, session: RuntimeSession): void {
const snap = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
if (!snap?.name) return;
try {
updateBook(bookId, {
activeSkillId: snap.name,
activeSkillName: snap.name,
});
} catch {
/* book may have been deleted */
}
}
private syncBookPreview(bookId: string, messages: ChatMessage[]): void {
const preview = this.previewFromMessages(messages);
try {
updateBook(bookId, { preview });
} catch {
/* book may have been deleted */
}
}
private previewFromMessages(messages: ChatMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const t = messages[i].text?.trim();
if (t) return t.slice(0, 80);
}
return "等待开始…";
}
private clearAgentThinking(sessionId: string): void {
this.agentThinkingLive.delete(sessionId);
this.agentThinkingClosed.delete(sessionId);
this.workerLive.delete(sessionId);
}
private buildStreamHandlers(
sessionId: string,
_getManaged: () => ManagedSession | null,
): Pick<
import("../runtime/phase-runtime.js").PhaseRuntimeOptions,
| "onAgentThinkingDelta"
| "onAgentThinkingDone"
| "onWorkerStreamStart"
| "onWorkerThinkingDelta"
| "onWorkerOutputDelta"
> {
return {
onAgentThinkingDelta: (delta) => {
const prev = this.agentThinkingClosed.has(sessionId)
? ""
: (this.agentThinkingLive.get(sessionId) ?? "");
this.agentThinkingClosed.delete(sessionId);
this.agentThinkingLive.set(sessionId, prev + delta);
},
onAgentThinkingDone: (text) => {
// 正文仍等完整后再渲染;思考收束后继续挂在 liveStream,避免中间只剩「…」
this.agentThinkingClosed.add(sessionId);
const done = text.trim();
if (done) this.agentThinkingLive.set(sessionId, done);
},
onWorkerStreamStart: (workerId) => {
this.workerLive.set(sessionId, { workerId, thinking: "", output: "" });
},
onWorkerThinkingDelta: (workerId, delta) => {
const prev =
this.workerLive.get(sessionId) ?? {
workerId,
thinking: "",
output: "",
};
prev.workerId = workerId;
prev.thinking += delta;
this.workerLive.set(sessionId, prev);
},
onWorkerOutputDelta: (workerId, delta) => {
const prev =
this.workerLive.get(sessionId) ?? {
workerId,
thinking: "",
output: "",
};
prev.workerId = workerId;
prev.output += delta;
this.workerLive.set(sessionId, prev);
},
};
}
private buildLiveStreamView(sessionId: string): LiveStreamView | undefined {
const worker = this.workerLive.get(sessionId);
if (worker) {
return {
actor: "worker",
actorId: worker.workerId,
label: formatWorkerDisplayTitle(worker.workerId, "running"),
thinking: worker.thinking || undefined,
output: worker.output || undefined,
};
}
const agentThinking = this.agentThinkingLive.get(sessionId);
if (agentThinking) {
return {
actor: "orchestrator",
label: "总管 · 思考",
thinking: agentThinking,
};
}
return undefined;
}
private require(id: string): ManagedSession {
const s = this.sessions.get(id);
if (!s) throw new Error("会话不存在");
return s;
}
private beginRun(s: ManagedSession, sessionId: string): number {
s.runAbort?.abort();
s.runAbort = new AbortController();
s.runGen = (s.runGen ?? 0) + 1;
s.runMessageCutoff = s.messages.length;
this.clearAgentThinking(sessionId);
return s.runGen;
}
private isCurrentRun(s: ManagedSession, gen: number): boolean {
return s.runGen === gen;
}
/** true = 本轮仍有效;false = 已被停止并重试取代 */
private async runExclusive(
id: string,
s: ManagedSession,
fn: () => Promise<unknown>,
): Promise<boolean> {
const gen = this.beginRun(s, id);
try {
await runWithAbortSignal(s.runAbort!.signal, fn);
return this.isCurrentRun(s, gen);
} catch (err) {
if (isAbortError(err) && !this.isCurrentRun(s, gen)) {
return false;
}
throw err;
} finally {
if (this.isCurrentRun(s, gen)) {
this.clearAgentThinking(id);
s.runAbort = undefined;
}
}
}
/** 执行失败后离开 running,避免界面一直转圈。 */
private async recoverFailedRun(
s: ManagedSession,
detail: string,
): Promise<void> {
const formatted = formatRuntimeError(detail);
if (s.runtime.getSession().phase === "running") {
await s.runtime.failRun(formatted);
} else {
s.messages.push(this.msg("system", formatted));
}
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
}
private enrichDisplayMessages(
session: RuntimeSession,
messages: ChatMessage[],
): ChatMessage[] {
const reason = session.waitingReason;
const activeSkill = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
if (
!session.slots.startupCompleted &&
activeSkill &&
effectiveStartupMode(activeSkill) === "agent-first" &&
(reason?.kind === "input" || reason?.kind === "intake")
) {
return messages;
}
if (reason?.kind === "intake" && !session.slots.startupCompleted) {
const prompt = reason.prompt?.trim();
if (!prompt) return messages;
const needle = prompt.slice(0, 48);
const hasStartup = messages.some(
(m) =>
m.kind === "orchestrator_prompt" ||
(typeof m.text === "string" && m.text.includes(needle)),
);
if (hasStartup) return messages;
return [...messages, this.msg("system", prompt)];
}
if (reason?.kind !== "input" || session.slots.startupCompleted) {
return messages;
}
const prompt = reason.message?.trim();
if (!prompt) return messages;
const needle = prompt.slice(0, 48);
const hasStartup = messages.some(
(m) =>
m.kind === "orchestrator_prompt" ||
(typeof m.text === "string" && m.text.includes(needle)),
);
if (hasStartup) return messages;
return [...messages, this.msg("system", prompt)];
}
private msg(
role: ChatMessage["role"],
text: string,
tokenUsage?: MessageTokenUsage,
thinking?: string,
contextTrace?: LlmContextTrace,
requestOutlines?: RequestOutline[],
): ChatMessage {
const base = {
id: randomUUID(),
role,
text,
createdAt: new Date().toISOString(),
...(tokenUsage ? { tokenUsage } : {}),
...(thinking ? { thinking } : {}),
...(contextTrace ? { contextTrace } : {}),
...(requestOutlines?.length ? { requestOutlines } : {}),
};
if (role === "user") {
return {
...base,
kind: "user_input",
title: "你的输入",
body: text,
};
}
const classified = classifyAgentMessage(text);
return { ...base, ...classified };
}
/** 按设置修剪本会话上下文痕迹;返回仍保留痕迹的条数 */
pruneSessionContextTraces(sessionId: string): { kept: number; cleared: number } {
const s = this.require(sessionId);
const before = s.messages.filter((m) => m.contextTrace).length;
const keep = normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest,
);
s.messages = pruneContextTraces(s.messages, keep);
const after = s.messages.filter((m) => m.contextTrace).length;
if (s.bookId) this.persist(s);
return { kept: after, cleared: Math.max(0, before - after) };
}
/** 清空本会话全部上下文痕迹 */
clearSessionContextTraces(sessionId: string): { cleared: number } {
const s = this.require(sessionId);
const before = s.messages.filter((m) => m.contextTrace).length;
s.messages = pruneContextTraces(s.messages, 0);
if (s.bookId) this.persist(s);
return { cleared: before };
}
/** 按当前设置修剪所有内存中的会话 */
pruneAllOpenContextTraces(): {
sessions: number;
kept: number;
cleared: number;
} {
let sessions = 0;
let kept = 0;
let cleared = 0;
for (const id of this.sessions.keys()) {
const r = this.pruneSessionContextTraces(id);
sessions += 1;
kept += r.kept;
cleared += r.cleared;
}
return { sessions, kept, cleared };
}
/** 清空所有内存会话的上下文痕迹 */
clearAllOpenContextTraces(): { sessions: number; cleared: number } {
let sessions = 0;
let cleared = 0;
for (const id of this.sessions.keys()) {
const r = this.clearSessionContextTraces(id);
sessions += 1;
cleared += r.cleared;
}
return { sessions, cleared };
}
/** 兼容旧会话 string[] questions → QuestionItem[] */
private normalizeWaitingReasonForView(
reason: WaitingReason | undefined,
): WaitingReason | undefined {
if (!reason) return reason;
if (reason.kind === "worker_questions") {
return { ...reason, questions: normalizeQuestions(reason.questions) };
}
if (reason.kind === "input" && reason.questions?.length) {
return { ...reason, questions: normalizeQuestions(reason.questions) };
}
if (reason.kind === "review_artifact" && reason.questions?.length) {
return {
...reason,
questions: normalizeQuestions(reason.questions).map((q) => ({
...q,
required: false,
})),
};
}
return reason;
}
private toView(id: string, resumed = false, resumeHint?: string): SessionView {
const s = this.require(id);
this.sanitizePlayHitl(s);
this.sealOpeningPersonaFromBoard(s);
const session = s.runtime.getSession();
const reason = session.waitingReason;
const book = s.bookId ? getBook(s.bookId) : null;
const hints: string[] = [];
const actions: SessionAction[] = [];
let intake: IntakeProgress | undefined;
let intakePrompt: string | undefined;
const activeSnap = session.slots.activeSkill as ActiveSkillSnapshot | undefined;
if (resumeHint) {
hints.push(resumeHint);
} else if (resumed) {
hints.push("已从上次进度恢复,可继续创作。");
}
if (reason?.kind === "intake" && activeSnap?.intakeFields?.length) {
intake = buildIntakeProgress(
activeSnap.intakeFields,
readIntakeValues(session.slots),
);
intakePrompt = reason.prompt;
if (intake.ready) {
hints.push("必要项已齐,可确认进入实例化;也可继续补充可选项。");
actions.push({
type: "confirm_intake",
label: "确认,进入实例化",
});
actions.push({
type: "send_message",
label: "继续补充",
placeholder: "补充可选项或修正已填内容…",
});
} else {
hints.push(
`填写必要项(${intake.requiredFilled}/${intake.requiredTotal})后可确认进入实例化`,
);
actions.push({
type: "send_message",
label: "发送",
placeholder: "描述你想创作什么…",
});
}
} else if (reason?.kind === "skill_selection") {
hints.push("旧版会话处于选包阶段;建议新建作品直接描述需求");
actions.push({
type: "send_message",
label: "发送",
placeholder: "skill id(如 world-simulator)或列表编号",
});
} else if (reason?.kind === "input" && !session.slots.startupCompleted) {
actions.push({
type: "send_message",
label: "发送",
placeholder: "描述你想做的作品与体验…",
});
} else if (reason?.kind === "intake") {
const isAgentFirst =
activeSnap && effectiveStartupMode(activeSnap) === "agent-first";
hints.push(
isAgentFirst
? "描述你想做的体验;发送后总管将开始:创作 · 核心"
: (reason.prompt?.trim() ?? "请按填空项补充创作信息"),
);
actions.push({
type: "send_message",
label: "发送",
placeholder: isAgentFirst
? "例如:网恋对象对话、西幻升级交互、规则怪谈……"
: "描述你想写什么…",
});
} else if (reason?.kind === "input") {
const playNow =
inferLifecycleStage(session) === "play" || isPlayLayerActive(session.slots);
if (playNow) {
actions.push({
type: "send_message",
label: "发送",
placeholder: "说你要做什么…",
});
} else if (reason.questions?.length) {
const qs = normalizeQuestions(reason.questions);
const hasAssessment = Boolean(reason.message?.trim());
hints.push(
hasAssessment
? "请阅读内容评价;下方追问可选,也可跳过并直接回复继续"
: `总管追问(可跳过):${qs.map((q) => q.prompt).join(" ")}`,
);
actions.push({ type: "answer_questions", label: "提交作答" });
actions.push({ type: "skip_questions", label: "跳过追问" });
actions.push({
type: "send_message",
label: "自由补充",
placeholder: "也可在此自由补充…",
});
} else {
if (reason.message?.trim()) {
hints.push(reason.message.trim());
} else {
hints.push("请回答启动问题,或补充创作目标");
}
actions.push({
type: "send_message",
label: "发送",
placeholder: "描述你想写什么…",
});
}
} else if (reason?.kind === "worker_questions") {
const qs = normalizeQuestions(reason.questions);
if (isModuleOpeningQuestions(qs)) {
// 能力默认问题:对齐美学纲领 → 自由书写,不是可跳过追问卡
hints.push("按主栏引导先说几句(想到什么写什么,不必整齐)");
actions.push({
type: "send_message",
label: "发送",
placeholder: "想到什么写什么…",
});
} else if (qs.length) {
hints.push(
`${displayWorkerLabel(reason.workerId)} · 提问(请在询问卡作答):${qs.map((q) => q.prompt).join(" ")}`,
);
actions.push({ type: "answer_questions", label: "提交作答" });
actions.push({ type: "skip_questions", label: "跳过追问" });
actions.push({
type: "send_message",
label: "自由补充",
placeholder: "也可在此自由补充…",
});
} else {
hints.push(
`${displayWorkerLabel(reason.workerId)} 需要更多信息,请补充说明`,
);
actions.push({
type: "send_message",
label: "发送",
placeholder: "补充说明…",
});
}
} else if (reason?.kind === "approve_step") {
const proposed = readProposedNextStep(
s.runtime.getSession().slots,
s.runtime.getBlackboard(),
);
if (proposed) {
hints.push(
proposed.mode === "revise"
? `回头修改「${proposed.name}」${
proposed.paramsMissing.length
? `(请先补齐:${proposed.paramsMissing.join("、")})`
: "(继承既有产物继续改,不是从零再生成)"
}`
: `确认开始「${proposed.name}」${
proposed.paramsMissing.length
? `(请先补齐:${proposed.paramsMissing.join("、")})`
: ""
}`,
);
actions.push({
type: "approve",
label: proposed.paramsMissing.length ? "补参后开始" : "同意并开始",
});
} else {
actions.push({ type: "approve", label: "确认执行" });
}
actions.push({ type: "reject", label: "返回节点" });
actions.push({
type: "send_message",
label: "说明意见",
placeholder: "改排、补节点(如 NPC)、回头改某步,或说明不要这一步…",
});
} else if (reason?.kind === "pick_creation_step") {
hints.push(
"点图上可进入的节点即开始。要看技能池点上方「技能」。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
);
actions.push({ type: "replan", label: "发送" });
actions.push({
type: "send_message",
label: "发送",
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
});
} else if (reason?.kind === "next_intent") {
hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。");
actions.push({
type: "send_message",
label: "继续",
placeholder: "下一步想写什么?(可留空)…",
});
} else if (reason?.kind === "review_artifact") {
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
const copy = reviewComposerCopy(art?.workerId, {
hasQuestions: Boolean(reason.questions?.length),
});
if (art?.workerId === DESIGN_FLOW_WORKER_ID) {
hints.push(
"点图上可进入的节点即开始。要看技能池点上方「技能」。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
);
actions.push({
type: "send_message",
label: "发送",
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
});
} else {
actions.push({ type: "accept", label: copy.acceptLabel });
actions.push({ type: "reject", label: "不接受" });
actions.push({
type: "send_message",
label: copy.submitLabel,
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
});
}
if (reason.questions?.length) {
hints.push(
art?.workerId === DESIGN_FLOW_WORKER_ID
? "下方追问可选;点节点进入即收起"
: `下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`,
);
actions.push({ type: "answer_questions", label: "提交作答" });
actions.push({ type: "skip_questions", label: "跳过追问" });
}
} else if (reason?.kind === "revision") {
hints.push(
reason.instruction?.trim() ||
"请说明要改哪里;发送后在现有产物上修改",
);
actions.push({
type: "send_message",
label: "按意见修改",
placeholder: "说明要改哪里…",
});
} else if (session.phase === "error") {
hints.push("上一轮执行出错。可直接发消息让总管重试,或刷新作品后继续。");
actions.push({
type: "send_message",
label: "重试 / 继续",
placeholder: "例如:重试上一步,或补充说明…",
});
} else if (
session.phase === "running" &&
s.runtime.needsMainAgentDecision() &&
!s.runtime.hasMainAgent()
) {
hints.push("简报已就绪,可手动生成大纲(未配置总管模型)");
actions.push({ type: "run_outline", label: "生成大纲" });
actions.push({
type: "send_message",
label: "发送",
placeholder: "补充说明…",
});
} else if (session.phase === "running" && s.runtime.needsMainAgentDecision()) {
hints.push("总管正在调度…");
} else if (session.phase === "running" && session.pendingArtifactId) {
/* worker 运行中 */
} else if (
session.phase === "running" &&
session.slots.startupCompleted &&
!session.pendingDecision
) {
actions.push({ type: "finish", label: "结束流程" });
} else if (session.phase === "done") {
hints.push("流程已完成");
} else {
actions.push({
type: "send_message",
label: "发送",
placeholder: "输入消息…",
});
}
if (this.canOfferLeaveCreationStep(s, reason)) {
actions.push({ type: "leave_step", label: "返回节点" });
}
if (s.bookId) {
this.persist(s);
}
const tokens = getSessionTokenTotals(id);
const lastWorkerSnap = s.runtime.getLastWorkerRunSnapshot();
const messages = this.enrichDisplayMessages(session, [...s.messages]).map(
(m, idx) => ({
...m,
canRefresh: canAttemptRefresh(m, {
lastWorkerId: lastWorkerSnap?.workerId,
hasLastWorkerSnapshot: Boolean(lastWorkerSnap),
hasMessageCheckpoint: Boolean(
s.branchState.preMessageCheckpoints[idx],
),
hasBranchVariants: Boolean(
(typeof m.branchTotal === "number" && m.branchTotal > 1) ||
(m.branchGroupId &&
(s.branchState.branches[m.branchGroupId]?.variants.length ?? 0) >
1),
),
}),
}),
);
const lifecycleStage = inferLifecycleStage(session);
const skillPackId =
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined);
const workerSetYaml = readWorkerSetYaml(s.runtime.getBlackboard());
const skillCatalog = buildSkillCatalog(
session,
skillPackId,
lifecycleStage,
{ workerSetYaml },
);
const replyFormatRaw =
s.runtime.getBlackboard().getContentByTag("设计.正文组成") ??
s.runtime.getBlackboard().getContentByTag("设计.0层正文组成") ??
s.runtime.getBlackboard().getContentByTag("设计.回复格式");
const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw);
const replyContract = parseReplyDataContract(replyFormatRaw);
return {
id,
bookId: s.bookId,
bookTitle: book?.title,
activeSkill: s.runtime.getActiveSkill()?.name ?? skillPackId,
phase: session.phase,
waitingReason: this.normalizeWaitingReasonForView(reason),
startupCompleted: Boolean(session.slots.startupCompleted),
skills: s.runtime.getAvailableSkills(),
messages,
hints,
actions,
pipeline: buildPipeline(session),
lifecycleStage,
creationMode: this.creationIntakeMode(s),
dictateProducts: this.isDictateMode(s)
? this.listDictateProducts(s)
: undefined,
listenSerial: this.isListenSerialMode(s)
? toListenView(readListenState(s.runtime.getBlackboard()))
: undefined,
playLayerActive: isPlayLayerActive(session.slots),
playReady: canEnterPlay(session),
hasProduct: Boolean(
s.bookId &&
this.listGameSnapshots(s.bookId).some((item) => item.kind === "instance"),
),
playInstanceId: isPlayLayerActive(session.slots)
? this.currentPlayInstanceId(s)
: undefined,
skillCatalog,
toolTrace: buildToolTrace(messages),
burst: buildBurstState(messages, session),
focus: buildFocus(session, reason, intake, lifecycleStage),
intake,
intakePrompt,
uiPrompt:
!session.slots.startupCompleted &&
activeSnap &&
effectiveStartupMode(activeSnap) === "agent-first"
? activeSnap.uiPrompt?.trim() || DEFAULT_UI_PROMPT
: undefined,
reviewArtifact: buildReviewArtifactView(
session,
messages,
s.runtime.getBlackboard(),
s.moduleCatalog,
),
workerSetView: buildWorkerSetUserView(
s.runtime.getBlackboard(),
s.runtime.getSession(),
),
creationFlowView: buildCreationFlowUserView(
s.runtime.getBlackboard(),
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
s.moduleCatalog,
),
proposedNextStep:
reason?.kind === "approve_step"
? readProposedNextStep(
session.slots,
s.runtime.getBlackboard(),
)
: undefined,
recipes: s.recipeOptions?.map((r) => ({
id: r.id,
name: r.name,
declaration: r.declaration,
})),
selectedRecipe: resolveSelectedRecipeView(
s.runtime.getBlackboard(),
s.recipeOptions,
),
openingGuide: this.resolveOpeningGuideForView(s, reason),
boardPanel: buildBoardPanelFromRuntime(s.runtime.getBlackboard()),
presentationTweaks: shellAdapt
? {
shell_id: shellAdapt.shell_id,
...shellAdapt.tweaks,
}
: undefined,
presentationFrontend: replyContract?.frontend,
presentationSlots: replyContract
? [...replyContract.modelBlocks, ...replyContract.programBlocks].map((block) => ({
id: block.id,
type: block.type,
}))
: undefined,
presentationProjection: (() => {
if (!replyContract?.programBlocks.length) return undefined;
const board = s.runtime.getBlackboard();
const projected = projectPresentBlocks({
contract: replyContract,
statusBarRaw:
board.getContentByTag("设计.监控栏") ??
board.getContentByTag("设计.回复呈现"),
currentVarsRaw: board.getContentByTag("变量.当前"),
topologyRaw: board.getContentByTag("运行.拓扑"),
readTag: (tag) => board.getContentByTag(tag),
});
return Object.keys(projected).length ? projected : undefined;
})(),
liveStream: this.buildLiveStreamView(id),
agentThinking: this.agentThinkingLive.get(id),
runActive: Boolean(s.runAbort),
resumed: resumed || undefined,
tokenStats: {
sessionTotal: tokens.totalTokens,
sessionCached: tokens.totalCached || undefined,
sessionCacheMiss: tokens.totalCacheMiss || undefined,
lastCaller: tokens.last?.caller,
lastTotal: tokens.last?.totalTokens,
byCaller: tokens.byCaller,
},
personas: listPersonas().map((p) => ({ id: p.id, name: p.name })),
activePersona: (() => {
const p = getActivePersona();
return p
? { id: p.id, name: p.name, description: p.description }
: null;
})(),
sessionProtagonist: readSessionProtagonist(s.runtime.getBlackboard()),
openingPersonaChoice: ((): "opening" | "global" | null => {
const raw = s.runtime
.getBlackboard()
.getContentByTag(OPENING_PERSONA_CHOICE_TAG)
?.trim();
if (raw === "opening" || raw === "global") return raw;
return null;
})(),
playAux: buildPlayAuxView(
s.runtime.getBlackboard(),
s.runtime.getSession().slots,
),
preferenceReview: s.preferenceReview?.length
? s.preferenceReview.map((c) => ({ id: c.id, content: c.content }))
: undefined,
};
}
private captureCheckpoint(s: ManagedSession): SessionCheckpoint {
return {
runtimeSession: structuredClone(s.runtime.getSession()),
blackboardItems: s.runtime.getBlackboard().exportItems(),
};
}
private serializeBranchState(
state: MessageBranchState,
): import("../types/book-session.js").PersistedMessageBranchState {
const preMessageCheckpoints: Record<
string,
{ runtimeSession: RuntimeSession; blackboardItems: BlackboardItem[] }
> = {};
for (const [key, cp] of Object.entries(state.preMessageCheckpoints)) {
preMessageCheckpoints[key] = {
runtimeSession: structuredClone(cp.runtimeSession),
blackboardItems: structuredClone(cp.blackboardItems),
};
}
const branches: import("../types/book-session.js").PersistedMessageBranchState["branches"] =
{};
for (const [groupId, branch] of Object.entries(state.branches)) {
branches[groupId] = {
anchorIndex: branch.anchorIndex,
groupId: branch.groupId,
activeIndex: branch.activeIndex,
variants: branch.variants.map((v) => ({
messages: v.messages.map((m) => ({ ...m })),
checkpoint: {
runtimeSession: structuredClone(v.checkpoint.runtimeSession),
blackboardItems: structuredClone(v.checkpoint.blackboardItems),
},
})),
};
}
return { branches, preMessageCheckpoints };
}
private deserializeBranchState(
raw?: import("../types/book-session.js").PersistedMessageBranchState,
): MessageBranchState {
const state = createMessageBranchState();
if (!raw) return state;
for (const [key, cp] of Object.entries(raw.preMessageCheckpoints ?? {})) {
state.preMessageCheckpoints[Number(key)] = {
runtimeSession: structuredClone(cp.runtimeSession),
blackboardItems: structuredClone(cp.blackboardItems),
};
}
for (const [groupId, branch] of Object.entries(raw.branches ?? {})) {
state.branches[groupId] = {
anchorIndex: branch.anchorIndex,
groupId: branch.groupId,
activeIndex: branch.activeIndex,
variants: branch.variants.map((v) => ({
messages: v.messages.map((m) => ({ ...m })),
checkpoint: {
runtimeSession: structuredClone(v.checkpoint.runtimeSession),
blackboardItems: structuredClone(v.checkpoint.blackboardItems),
},
})),
};
}
return state;
}
private async refreshRecipeOptions(
s: ManagedSession,
family?: "recipe" | "dictate",
): Promise<void> {
const skillName = s.runtime.getActiveSkill()?.name;
if (!skillName) {
s.recipeOptions = [];
s.moduleCatalog = null;
s.openingGuide = null;
return;
}
try {
const skill = await loadSkill(skillName);
if (!skill.skillPackRoot) {
s.recipeOptions = [];
s.moduleCatalog = null;
s.openingGuide = null;
return;
}
const intake =
family ?? (this.isDictateMode(s) ? "dictate" : "recipe");
const [catalog, modules] = await Promise.all([
loadRecipeCatalog(skill.skillPackRoot, undefined, intake),
loadModuleCatalog(skill.skillPackRoot),
]);
s.recipeOptions = catalog?.recipes ?? [];
s.moduleCatalog = modules;
} catch {
s.recipeOptions = [];
s.moduleCatalog = null;
}
await this.ensureRecipeSeededFlow(s);
await this.refreshOpeningGuide(s);
}
/** 已选配方但尚无流程 → 写入配方近期起点(兼容旧会话) */
private async ensureRecipeSeededFlow(s: ManagedSession): Promise<void> {
if (this.isDictateMode(s)) return;
const board = s.runtime.getBlackboard();
if (board.getContentByTag(CREATION_FLOW_TAG)?.trim()) return;
const ref = parseSelectedRecipeRef(
board.getContentByTag(CREATION_SELECTED_RECIPE_TAG),
);
if (!ref) return;
const entry = findRecipeCatalogEntry(
s.recipeOptions?.length ? { recipes: s.recipeOptions } : null,
ref,
);
if (!entry) return;
const skillName = s.runtime.getActiveSkill()?.name;
if (!skillName) return;
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) return;
const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail);
if (!seeded) return;
await this.commitSeededCreationFlow(s, seeded);
} catch (err) {
console.error("[会话] 配方预置失败", err);
}
}
/**
* 下一待做开局步(无 depends_on)的 opening → 意图页引导。
* 有起点模块就展示;无则 null(再走 design-flow 生成)。
* 中段步骤的默认问题改由 resolveOpeningGuideForView 在 waiting 时覆盖。
*/
private async refreshOpeningGuide(s: ManagedSession): Promise<void> {
const skillName = s.runtime.getActiveSkill()?.name;
if (!skillName) {
s.openingGuide = null;
return;
}
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) {
s.openingGuide = null;
return;
}
const board = s.runtime.getBlackboard();
const session = s.runtime.getSession();
const accepted = parseAcceptedUnits(
session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
board.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
const binding = await resolveDesignStepBinding({
skillPackRoot: packRoot,
flowRaw: board.getContentByTag(CREATION_FLOW_TAG),
acceptedStepNames: accepted,
});
if (
!binding?.opening?.trim() ||
(binding.step.depends_on?.length ?? 0) > 0
) {
s.openingGuide = null;
return;
}
s.openingGuide = {
stepId: binding.step.id,
stepName: binding.module.name || binding.step.name,
text: binding.opening.trim(),
};
} catch {
s.openingGuide = null;
}
}
/**
* 视图用开场引导:等待能力默认问题时用题干(对齐美学纲领说话面);
* 中段步骤(有 depends_on)也会覆盖,避免只剩空答题壳。
*/
private resolveOpeningGuideForView(
s: ManagedSession,
reason: WaitingReason | undefined,
): { stepId: string; stepName: string; text: string } | null {
if (reason?.kind === "pick_creation_step") return null;
if (reason?.kind === "worker_questions") {
const qs = normalizeQuestions(reason.questions);
if (isModuleOpeningQuestions(qs)) {
const text = qs[0]?.prompt?.trim() ?? "";
if (text) {
const board = s.runtime.getBlackboard();
const stepKey =
board.getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() || "";
const flow = parseCreationFlow(board.getContentByTag(CREATION_FLOW_TAG));
const step = flow?.steps?.find(
(st) =>
stepUnitId(st) === stepKey ||
st.id === stepKey ||
st.name === stepKey,
);
const stepName =
step?.name?.trim() ||
s.openingGuide?.stepName?.trim() ||
stepKey ||
"按引导先说几句";
return {
stepId: qs[0]!.id,
stepName,
text,
};
}
}
}
return s.openingGuide ?? null;
}
/** 写入剧本 seed,并立刻进入点选图(开局节点跟配方走)。 */
private async commitSeededCreationFlow(
s: ManagedSession,
seeded: CreationFlow,
): Promise<void> {
this.writeSeededCreationFlow(s, seeded);
await s.runtime.enterSeededCreationPick();
}
/** 配方预置 DAG:写入黑板并留下可回退的编排检查点(不必先跑 LLM) */
private writeSeededCreationFlow(s: ManagedSession, seeded: CreationFlow): void {
s.runtime.getBlackboard().write({
tag: CREATION_FLOW_TAG,
content: stringifyCreationFlow(seeded),
source: "runtime",
});
const alreadyRecorded = s.messages.some(
(m) =>
m.kind === "worker_output" &&
m.actor === "design-flow" &&
(m.text ?? "").includes("配方预置近期起点"),
);
if (alreadyRecorded) return;
const names = seeded.steps.map((step) => step.name).join(" → ");
recordPreMessageCheckpoint(
s.branchState,
s.messages.length,
this.captureCheckpoint(s),
);
s.messages.push(
this.msg(
"system",
`[Worker] design-flow 已完成\n\n配方预置近期起点${names ? `:${names}` : ""}。\n这是可回退的编排检查点;之后可再编排追加节点(如生成规则 / 具体实例)。`,
),
);
this.syncCreationDialogue(s);
}
private async applySelectedRecipe(
s: ManagedSession,
recipeId: string,
opts?: { seedFlow?: boolean },
): Promise<void> {
const options = s.recipeOptions ?? [];
const entry = findRecipeCatalogEntry(
options.length ? { recipes: options } : null,
recipeId,
);
if (!entry) {
throw new Error(`未知初始配方:${recipeId}`);
}
const board = s.runtime.getBlackboard();
board.write({
tag: CREATION_SELECTED_RECIPE_TAG,
content: JSON.stringify({
id: entry.id,
name: entry.name,
family: entry.family ?? (opts?.seedFlow === false ? "dictate" : "recipe"),
}),
source: "user",
});
const seedFlow = opts?.seedFlow !== false;
// 开局节点跟剧本 seed:写入近期起点并进入点选图(不必先跑 design-flow / 先描述需求)
// 转述进料可只钉配方、不种 DAG
const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (seedFlow && !existingFlow) {
const skillName = s.runtime.getActiveSkill()?.name;
if (skillName) {
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (packRoot) {
const detail = await loadRecipeDetail(packRoot, entry);
const seeded = creationFlowFromRecipeSeed(detail);
if (seeded) {
await this.commitSeededCreationFlow(s, seeded);
}
}
} catch (err) {
console.error("[会话] 从配方写入创作流程失败", err);
}
}
}
await this.refreshOpeningGuide(s);
}
}
export const sessionManager = new SessionManager();
function readWorkerSetYaml(blackboard: Blackboard): string | undefined {
for (const tag of ["设计.worker集", "设计.worker集.草稿"]) {
const content = blackboard.getContentByTag(tag)?.trim();
if (content) return content;
}
return undefined;
}
function collectFilledTags(blackboard: Blackboard): string[] {
return blackboard.listTagIndex().map((item) => item.tag);
}
function buildCreationFlowUserView(
blackboard: Blackboard,
acceptedRaw?: unknown,
catalog?: ModuleCatalog | null,
): CreationFlowUserView | undefined {
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
if (!raw) return undefined;
const flow = parseCreationFlow(raw);
if (!flow) {
return { steps: [], parseError: "创作流程无法解析为 JSON(需要 steps 数组)" };
}
const accepted = parseAcceptedUnits(
acceptedRaw ?? blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
);
const titleHints: Record<string, FlowStepTitleHint> = {};
const store = parseAcceptedContentStore(
blackboard.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG),
);
for (const [id, entry] of Object.entries(store.units)) {
if (!entry.summary && entry.content == null) continue;
titleHints[id] = {
...(entry.summary ? { summary: entry.summary } : {}),
...(entry.content != null ? { content: entry.content } : {}),
};
}
return formatCreationFlowForUser(
flow,
catalog,
accepted,
collectFilledTags(blackboard),
titleHints,
);
}
function readProposedNextStep(
slots: Record<string, unknown>,
blackboard: Blackboard,
): ProposedNextStepView | undefined {
const raw =
(typeof slots[CREATION_PROPOSED_STEP_TAG] === "string"
? String(slots[CREATION_PROPOSED_STEP_TAG])
: null) || blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
if (!raw?.trim()) return undefined;
try {
const doc = JSON.parse(raw) as Partial<ProposedNextStepView>;
if (typeof doc.stepId !== "string" || typeof doc.name !== "string") {
return undefined;
}
return {
stepId: doc.stepId,
name: doc.name,
declaration:
typeof doc.declaration === "string" ? doc.declaration : undefined,
params:
doc.params && typeof doc.params === "object" && !Array.isArray(doc.params)
? (doc.params as Record<string, unknown>)
: {},
paramsMissing: Array.isArray(doc.paramsMissing)
? doc.paramsMissing.map(String)
: [],
paramSpecs: Array.isArray(doc.paramSpecs)
? doc.paramSpecs
.map((p) => ({
key: String((p as { key?: string }).key ?? ""),
label: String((p as { label?: string }).label ?? ""),
required: Boolean((p as { required?: boolean }).required),
hint:
typeof (p as { hint?: string }).hint === "string"
? (p as { hint: string }).hint
: undefined,
}))
.filter((p) => p.key)
: [],
kind: typeof doc.kind === "string" ? doc.kind : undefined,
intent: typeof doc.intent === "string" ? doc.intent : undefined,
mode: doc.mode === "revise" ? "revise" : undefined,
revises: typeof doc.revises === "string" ? doc.revises : undefined,
closer: doc.closer === true ? true : undefined,
};
} catch {
return undefined;
}
}
function resolveSelectedRecipeView(
blackboard: Blackboard,
options?: RecipeCatalogEntry[],
): { id: string; name: string; declaration: string } | null {
const ref = parseSelectedRecipeRef(
blackboard.getContentByTag(CREATION_SELECTED_RECIPE_TAG),
);
if (!ref) return null;
const entry = findRecipeCatalogEntry(
options?.length ? { recipes: options } : null,
ref,
);
if (entry) {
return {
id: entry.id,
name: entry.name,
declaration: entry.declaration,
};
}
return { id: ref, name: ref, declaration: "" };
}
/** 对话落盘:优先注入 recipe.yaml 方法论,缺则退回 catalog declaration */
async function resolveDictateRecipeBriefForSession(params: {
skillName?: string | null;
recipeView: { id: string; name: string; declaration: string } | null;
recipeOptions?: RecipeCatalogEntry[];
}): Promise<string | undefined> {
const view = params.recipeView;
if (!view) return undefined;
const entry = findRecipeCatalogEntry(
params.recipeOptions?.length ? { recipes: params.recipeOptions } : null,
view.id,
);
const skillName = params.skillName?.trim();
if (entry && skillName) {
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (packRoot) {
const detail = await loadRecipeDetail(packRoot, entry);
const brief = formatDictateRecipeBrief(detail);
if (brief) return brief;
}
} catch (err) {
console.error("[会话] 加载转述配方要点失败", err);
}
}
return view.declaration?.trim() || undefined;
}
/** 对话落盘:加载可混合的参考案例,不把任何案例当作已选路径。 */
async function resolveDictateRecipeExamplesForSession(params: {
skillName?: string | null;
recipeOptions?: RecipeCatalogEntry[];
}): Promise<string | undefined> {
const entries = (params.recipeOptions ?? []).filter(
(entry) => entry.family === "dictate",
);
const skillName = params.skillName?.trim();
if (!skillName || entries.length === 0) return undefined;
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) return undefined;
const details = await Promise.all(
entries.map((entry) => loadRecipeDetail(packRoot, entry)),
);
return formatRecipeExamplesForCreationPlan(details) || undefined;
} catch (err) {
console.error("[会话] 加载本局创作方案参考案例失败", err);
return undefined;
}
}
/** 对话落盘:取得含 prompt meta 的能力目录;优先复用会话已载目录 */
async function resolveDictateModuleCatalogForSession(params: {
skillName?: string | null;
moduleCatalog?: ModuleCatalog | null;
}): Promise<ModuleCatalog | null> {
let catalog = params.moduleCatalog ?? null;
if (!catalog?.modules?.length) {
const skillName = params.skillName?.trim();
if (!skillName) return null;
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) return null;
catalog = await loadModuleCatalog(packRoot);
} catch (err) {
console.error("[会话] 加载转述能力目录失败", err);
return null;
}
}
return catalog?.modules?.length ? catalog : null;
}
async function resolveDictateModuleReadIndexForSession(params: {
skillName?: string | null;
moduleCatalog?: ModuleCatalog | null;
}) {
const skillName = params.skillName?.trim();
if (!skillName || !params.moduleCatalog?.modules.length) return undefined;
try {
const skill = await loadSkill(skillName);
const packRoot = skill.skillPackRoot?.trim();
if (!packRoot) return undefined;
return await buildDictateModuleReadIndex({
skillPackRoot: packRoot,
catalog: params.moduleCatalog,
});
} catch (err) {
console.error("[会话] 构建能力读取索引失败", err);
return undefined;
}
}
/** 对话落盘:insert(tag) → 用户可见规范索引 */
async function resolveDictateInsertFeedbackIndexForSession(params: {
skillName?: string | null;
moduleCatalog?: ModuleCatalog | null;
}): Promise<Map<string, string> | undefined> {
let catalog = params.moduleCatalog ?? null;
let packRoot: string | undefined;
const skillName = params.skillName?.trim();
if (skillName) {
try {
const skill = await loadSkill(skillName);
packRoot = skill.skillPackRoot?.trim() || undefined;
if (!catalog?.modules?.length && packRoot) {
catalog = await loadModuleCatalog(packRoot);
}
} catch (err) {
console.error("[会话] 加载 insert 反馈索引失败", err);
return undefined;
}
}
if (!catalog?.modules?.length || !packRoot) return undefined;
try {
return await buildDictateInsertFeedbackIndex({
catalog,
skillPackRoot: packRoot,
});
} catch (err) {
console.error("[会话] 构建 insert 反馈索引失败", err);
return undefined;
}
}
function buildWorkerSetUserView(
blackboard: Blackboard,
session?: RuntimeSession,
): WorkerSetUserView | undefined {
const yaml = readWorkerSetYaml(blackboard);
if (!yaml) return undefined;
const parsed = parseWorkerSetYaml(yaml);
if (!parsed) return undefined;
const acceptedUnitIds = parseAcceptedUnits(
session?.slots?.creationAcceptedUnits ??
blackboard.getContentByTag("创作.已验收单位"),
);
const currentUnitId =
(typeof session?.slots?.creationCurrentUnitId === "string"
? session.slots.creationCurrentUnitId
: null) ||
blackboard.getContentByTag("创作.当前单位")?.trim() ||
null;
return (
formatWorkerSetForUser(parsed, {
filledTags: collectFilledTags(blackboard),
acceptedUnitIds,
currentUnitId,
}) ?? undefined
);
}
function buildReviewArtifactView(
session: RuntimeSession,
messages: ChatMessage[],
blackboard: Blackboard,
catalog?: ModuleCatalog | null,
): ReviewArtifactView | undefined {
if (session.waitingReason?.kind !== "review_artifact") return undefined;
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
if (!art) return undefined;
const workerSetTags = ["设计.worker集", "设计.worker集.草稿"];
const tagBodies: string[] = [];
for (const tag of workerSetTags) {
const content = blackboard.getContentByTag(tag);
if (content?.trim()) {
tagBodies.push(`## ${tag}\n\n${content.trim()}`);
}
}
for (const tag of art.outputTags) {
if (workerSetTags.includes(tag)) continue;
if (isProgressPointerTag(tag)) continue;
const content = blackboard.getContentByTag(tag);
if (content?.trim()) {
tagBodies.push(`## ${tag}\n\n${content.trim()}`);
}
}
// 回退消息里也可能夹带「创作.当前步骤」段落,验收正文里去掉
const stripPointerSections = (text: string): string => {
const trimmed = text.trim();
if (!trimmed) return "";
const re = /^##\s+(.+?)\s*$/gm;
const hits: Array<{ title: string; index: number; headerEnd: number }> = [];
let m: RegExpExecArray | null;
while ((m = re.exec(trimmed))) {
hits.push({
title: m[1]!.trim(),
index: m.index,
headerEnd: m.index + m[0].length,
});
}
if (!hits.length) return trimmed;
return hits
.filter((h) => !isProgressPointerTag(h.title) && !/当前步骤|当前单位/.test(h.title))
.map((h, i) => {
const end = i + 1 < hits.length ? hits[i + 1]!.index : trimmed.length;
const chunk = trimmed.slice(h.headerEnd, end).trim();
return chunk ? `## ${h.title}\n\n${chunk}` : "";
})
.filter(Boolean)
.join("\n\n");
};
const outputMsg = [...messages]
.reverse()
.find((m) => m.kind === "worker_output" && m.actor === art.workerId);
const body =
tagBodies.join("\n\n") ||
stripPointerSections((outputMsg?.body ?? outputMsg?.text ?? "").trim()) ||
art.summary?.trim() ||
"(Worker 未写入可读正文,请检查黑板 tag 或重试创作 skill)";
const isFinalSet = art.outputTags.includes("设计.worker集");
const isFlowArt =
art.workerId === "design-flow" || art.outputTags.includes(CREATION_FLOW_TAG);
const currentUnit =
blackboard.getContentByTag("创作.当前单位")?.trim() ||
(typeof session.slots.creationCurrentUnitId === "string"
? session.slots.creationCurrentUnitId
: "");
const isDesignArt = isDesignDiskWorker(art.workerId);
const summary =
art.summary?.trim() ||
(isFlowArt
? "创作流程(步骤与依赖)"
: isDesignArt
? isFinalSet
? "完整 Worker 集(终稿)"
: currentUnit
? `创作单位 · ${currentUnit}`
: "创作单位草稿"
: `${art.workerId} 产物`);
const workerSetYaml = readWorkerSetYaml(blackboard);
const workerSet =
isDesignArt && !isFlowArt && workerSetYaml
? parseWorkerSetYaml(workerSetYaml) ?? undefined
: undefined;
const acceptedUnitIds = parseAcceptedUnits(
session.slots.creationAcceptedUnits ??
blackboard.getContentByTag("创作.已验收单位"),
);
const workerSetView =
isDesignArt && workerSet
? formatWorkerSetForUser(workerSet, {
filledTags: collectFilledTags(blackboard),
acceptedUnitIds,
currentUnitId: currentUnit || null,
}) ?? undefined
: undefined;
const creationFlowView = isFlowArt
? buildCreationFlowUserView(
blackboard,
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
catalog,
)
: undefined;
return {
id: art.id,
workerId: art.workerId,
summary,
body,
outputTags: art.outputTags,
sourceMessageId: outputMsg?.id,
contextTrace: outputMsg?.contextTrace,
workerSet,
workerSetView,
creationFlowView,
};
}
function buildBoardPanelFromRuntime(
blackboard: import("../blackboard/blackboard.js").Blackboard,
): BoardPanelView {
return buildBoardPanel(blackboard);
}
function formatRuntimeError(detail: string): string {
if (detail.includes("401") || detail.toLowerCase().includes("authentication")) {
return (
"[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL," +
"或点击「探测」验证。"
);
}
if (detail.startsWith("LLM request failed")) {
return `[请求失败] ${detail.replace(/^LLM request failed \(\d+\): /, "").slice(0, 300)}`;
}
if (detail.startsWith("Main Agent returned invalid JSON")) {
return "[请求失败] 总管返回了无效 JSON,请检查模型是否支持 json 输出,或暂时关闭预设后重试。";
}
return `[请求失败] ${detail.slice(0, 300)}`;
}