新建作品可选听书连载,朗读与消费进度走专用接口。 Co-authored-by: Cursor <cursoragent@cursor.com>
4926 lines
166 KiB
TypeScript
4926 lines
166 KiB
TypeScript
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)}`;
|
||
}
|