Probe API profiles for json_schema/tools, flatten worker artifacts onto a strict schema, and let users spawn or revise graph nodes instead of a one-shot DAG. Co-authored-by: Cursor <cursoragent@cursor.com>
3202 lines
107 KiB
TypeScript
3202 lines
107 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 {
|
||
CREATION_SEALED_WAITING_MESSAGE,
|
||
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
||
OPENING_OUTPUT_TAG,
|
||
SLOT_CREATION_SEALED_BY_OPENING,
|
||
} from "../skills/opening-seal.js";
|
||
import {
|
||
PLAY_WORKING_SNAPSHOT_ID,
|
||
PLAY_WORKING_SNAPSHOT_LABEL,
|
||
SLOT_PLAY_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 { 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 {
|
||
loadAppSettings,
|
||
normalizeContextTraceKeepLatest,
|
||
} from "../config/settings.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 {
|
||
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,
|
||
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 {
|
||
formatQuestionAnswersForAi,
|
||
formatQuestionAnswersForDisplay,
|
||
isModuleOpeningQuestions,
|
||
normalizeQuestions,
|
||
} from "../skills/question-protocol.js";
|
||
import type { QuestionAnswer } from "../types/questions.js";
|
||
import {
|
||
appendBranchVariant,
|
||
createMessageBranchState,
|
||
createUserVariantMessage,
|
||
ensureBranchForEdit,
|
||
ensureBranchForRefresh,
|
||
isRefreshableMessage,
|
||
recordPreMessageCheckpoint,
|
||
switchBranchVariant,
|
||
updateActiveBranchVariant,
|
||
type MessageBranchState,
|
||
type SessionCheckpoint,
|
||
} from "./message-branch.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;
|
||
/** run 验收后过程讨论折叠(主 feed 隐藏);创作 messages 不删不藏 */
|
||
compressed?: boolean;
|
||
tokenUsage?: MessageTokenUsage;
|
||
/** 消息分支:同位多版本(编辑 / 重 roll) */
|
||
branchGroupId?: string;
|
||
branchIndex?: number;
|
||
branchTotal?: number;
|
||
};
|
||
|
||
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;
|
||
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>;
|
||
};
|
||
/** 总管正在输出的思维链(流式,轮询用) */
|
||
agentThinking?: string;
|
||
/** Agent / Worker 流式输出(轮询用) */
|
||
liveStream?: LiveStreamView;
|
||
resumed?: boolean;
|
||
tokenStats?: {
|
||
sessionTotal: number;
|
||
sessionCached?: number;
|
||
sessionCacheMiss?: number;
|
||
lastCaller?: string;
|
||
lastTotal?: number;
|
||
byCaller?: Record<
|
||
string,
|
||
{ totalTokens: number; cachedTokens: number; cacheMissTokens: number; calls: number }
|
||
>;
|
||
};
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
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): SessionView {
|
||
const s = this.require(sessionId);
|
||
const session = s.runtime.getSession();
|
||
if (stage === "play" && !canEnterPlay(session)) {
|
||
throw new Error("实例尚未就绪,无法进入游玩");
|
||
}
|
||
if (stage === "play") {
|
||
this.enterPlayLayer(s, { resume: true });
|
||
} 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);
|
||
}
|
||
|
||
/**
|
||
* 编排上下文投影排序:写入 设计.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);
|
||
|
||
bb.write({
|
||
tag: CONTEXT_ORDER_TAG,
|
||
content: serialized,
|
||
source: "user",
|
||
});
|
||
|
||
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",
|
||
});
|
||
}
|
||
} else {
|
||
// 尚无规格:至少落下排序表,供细化终稿合并
|
||
bb.write({
|
||
tag: CONTEXT_ORDER_TAG,
|
||
content: serialized,
|
||
source: "user",
|
||
});
|
||
}
|
||
|
||
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,
|
||
): 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);
|
||
const runtime = new PhaseRuntime({
|
||
autoStubWorker: !hasRealLlmConfig(),
|
||
llm,
|
||
onMessage,
|
||
...thinkingHandlers,
|
||
initialSession,
|
||
});
|
||
managed.runtime = runtime;
|
||
|
||
if (preselectSkillId) {
|
||
await runtime.startWithOrchestrator(preselectSkillId);
|
||
} else {
|
||
await runtime.start();
|
||
}
|
||
await this.refreshRecipeOptions(managed);
|
||
if (selectedRecipeId?.trim()) {
|
||
await this.applySelectedRecipe(managed, selectedRecipeId.trim());
|
||
}
|
||
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);
|
||
}
|
||
|
||
/** 用户手动选定 / 更换初始配方 */
|
||
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") {
|
||
if (!canEnterPlay(runtimeSession) && !runtimeSession.slots.startupCompleted) {
|
||
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,
|
||
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): Promise<SessionView> {
|
||
const s = this.require(sessionId);
|
||
if (!s.bookId) throw new Error("仅绑定作品时可新建游玩");
|
||
const session = s.runtime.getSession();
|
||
if (!canEnterPlay(session)) {
|
||
throw new Error("须先验收 Worker 集,才能开始游玩");
|
||
}
|
||
this.archivePlayWorking(s.bookId);
|
||
this.enterPlayLayer(s, { resume: false });
|
||
this.persist(s);
|
||
return this.toView(sessionId);
|
||
}
|
||
|
||
/** 进入游玩层:resume 时恢复「当前游玩」,否则从定稿新开一条聊天 */
|
||
private enterPlayLayer(
|
||
s: ManagedSession,
|
||
opts: { resume: boolean },
|
||
): void {
|
||
const session = s.runtime.getSession();
|
||
if (!canEnterPlay(session)) {
|
||
throw new Error("实例尚未就绪,无法进入游玩");
|
||
}
|
||
if (
|
||
opts.resume &&
|
||
isPlayLayerActive(session.slots) &&
|
||
inferLifecycleStage(session) === "play"
|
||
) {
|
||
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");
|
||
return;
|
||
}
|
||
}
|
||
this.beginNewPlayFromInstance(s);
|
||
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);
|
||
}
|
||
const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null;
|
||
if (instance) {
|
||
this.restoreSnapshotInPlace(s, instance, "design");
|
||
return;
|
||
}
|
||
let runtimeSession = structuredClone(s.runtime.getSession());
|
||
let blackboardItems = s.runtime.getBlackboard().exportItems();
|
||
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
||
runtimeSession,
|
||
blackboardItems,
|
||
}));
|
||
runtimeSession.id = session.id;
|
||
runtimeSession.slots = {
|
||
...runtimeSession.slots,
|
||
uiLifecycleStage: "design",
|
||
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
|
||
};
|
||
runtimeSession.phase = "waiting_user";
|
||
runtimeSession.waitingReason = { kind: "input" };
|
||
s.runtime.restoreFromCheckpoint(runtimeSession, blackboardItems);
|
||
}
|
||
|
||
private beginNewPlayFromInstance(s: ManagedSession): void {
|
||
const keepId = s.runtime.getSession().id;
|
||
let runtimeSession: RuntimeSession;
|
||
let blackboardItems: import("../types/blackboard.js").BlackboardItem[];
|
||
const instance = s.bookId ? this.latestInstanceSnapshot(s.bookId) : null;
|
||
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,
|
||
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);
|
||
const opening =
|
||
s.runtime.getBlackboard().getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? "";
|
||
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();
|
||
}
|
||
|
||
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,
|
||
startupCompleted: true,
|
||
};
|
||
if (layer === "play") {
|
||
runtimeSession.phase = runtimeSession.phase === "done"
|
||
? "waiting_user"
|
||
: runtimeSession.phase;
|
||
if (!runtimeSession.waitingReason) {
|
||
runtimeSession.waitingReason = { kind: "input" };
|
||
}
|
||
} else {
|
||
const sealed = Boolean(runtimeSession.slots[SLOT_CREATION_SEALED_BY_OPENING]);
|
||
runtimeSession.phase = "waiting_user";
|
||
runtimeSession.waitingReason = {
|
||
kind: "input",
|
||
...(sealed ? { message: CREATION_SEALED_WAITING_MESSAGE } : {}),
|
||
};
|
||
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);
|
||
}
|
||
|
||
/** 游玩层不保留创作 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"
|
||
) {
|
||
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",
|
||
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 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 playOpeningMessage(text: string): ChatMessage {
|
||
return {
|
||
id: 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 包不匹配,无法读档");
|
||
}
|
||
|
||
this.dropBookSessions(bookId);
|
||
|
||
const newSessionId = randomUUID();
|
||
const messages: ChatMessage[] = snapshot.messages.map((m) => ({ ...m })) as ChatMessage[];
|
||
let runtimeSession = structuredClone(snapshot.runtimeSession);
|
||
let blackboardItems = snapshot.blackboardItems;
|
||
|
||
if (snapshot.kind === "instance") {
|
||
({ runtimeSession, blackboardItems } = materializeInstanceSnapshotPayload({
|
||
runtimeSession,
|
||
blackboardItems,
|
||
}));
|
||
runtimeSession.slots = {
|
||
...runtimeSession.slots,
|
||
uiLifecycleStage: "design",
|
||
[SLOT_PLAY_LAYER_ACTIVE]: undefined,
|
||
};
|
||
} else {
|
||
runtimeSession.slots = {
|
||
...runtimeSession.slots,
|
||
uiLifecycleStage: "play",
|
||
[SLOT_PLAY_LAYER_ACTIVE]: true,
|
||
};
|
||
}
|
||
|
||
runtimeSession.id = newSessionId;
|
||
|
||
const resumeHint =
|
||
snapshot.kind === "instance"
|
||
? `已加载实例「${snapshot.label}」。可开始运行,或调整角色设定后再模拟。`
|
||
: `已从存档「${snapshot.label}」读档,可继续创作。`;
|
||
|
||
return this.mountRestoredSession({
|
||
sessionId: newSessionId,
|
||
bookId,
|
||
skillPackId: runSnapshotSkillPackId(snapshot) ?? "",
|
||
runtimeSession,
|
||
blackboardItems,
|
||
messages,
|
||
resumeHint,
|
||
});
|
||
}
|
||
|
||
/** 删除单个存档 */
|
||
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);
|
||
s.runtime.reloadLlm(llm, !hasRealLlmConfig());
|
||
count += 1;
|
||
}
|
||
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 trimmed = text.trim();
|
||
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 () => {
|
||
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);
|
||
}
|
||
this.syncCreationDialogue(s);
|
||
this.persist(s);
|
||
return this.toView(id);
|
||
}
|
||
|
||
/**
|
||
* 结构化追问作答: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 = newText.trim();
|
||
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, () =>
|
||
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 checkpoint = workerSnap ?? s.branchState.preMessageCheckpoints[idx];
|
||
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 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];
|
||
}
|
||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||
this.persist(s);
|
||
return this.toView(id);
|
||
}
|
||
|
||
/** 左右切换同位消息版本(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);
|
||
}
|
||
|
||
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());
|
||
const sealedBefore = Boolean(
|
||
s.runtime.getSession().slots[SLOT_CREATION_SEALED_BY_OPENING],
|
||
);
|
||
try {
|
||
const owned = await this.runExclusive(id, s, () =>
|
||
s.runtime.acceptArtifact(undefined, opts),
|
||
);
|
||
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);
|
||
this.maybeSaveOpeningInstance(s, sealedBefore);
|
||
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);
|
||
}
|
||
|
||
/** 已点进技能步、尚未验收:可退回分层图(误点可增殖节点时用) */
|
||
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 (
|
||
kind !== "worker_questions" &&
|
||
kind !== "input" &&
|
||
kind !== "revision"
|
||
) {
|
||
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),
|
||
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 thinking = managed.trackingRef.current.pendingReasoning;
|
||
managed.trackingRef.current.pendingUsage = undefined;
|
||
managed.trackingRef.current.pendingReasoning = undefined;
|
||
|
||
const classified =
|
||
text.trim().length > 0 ? classifyAgentMessage(text) : null;
|
||
const pendingTrace = managed.trackingRef.current.pendingContextTrace;
|
||
let contextTrace: LlmContextTrace | 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;
|
||
}
|
||
|
||
managed.messages.push(
|
||
this.msg("system", text, usage, thinking, contextTrace),
|
||
);
|
||
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);
|
||
}
|
||
}
|
||
|
||
/** 选定开场后正式保存一份 instance 定稿(可进游玩的规格+开局) */
|
||
private maybeSaveOpeningInstance(
|
||
s: ManagedSession,
|
||
sealedBefore: boolean,
|
||
): void {
|
||
if (sealedBefore) return;
|
||
if (!s.bookId) return;
|
||
const session = s.runtime.getSession();
|
||
if (!session.slots[SLOT_CREATION_SEALED_BY_OPENING]) return;
|
||
if (!canEnterPlay(session)) {
|
||
s.messages.push(
|
||
this.msg(
|
||
"system",
|
||
"[创作收口] 开场已选定,但 Worker 集尚未验收,未写入创作定稿存档。",
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
try {
|
||
const meta = this.saveGameSnapshot(
|
||
session.id,
|
||
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
||
"instance",
|
||
"选定开场后自动保存",
|
||
);
|
||
s.messages.push(
|
||
this.msg("system", `[创作收口] 已保存创作定稿「${meta.label}」。`),
|
||
);
|
||
} catch (err) {
|
||
const detail = err instanceof Error ? err.message : String(err);
|
||
s.messages.push(
|
||
this.msg("system", `[创作收口] 开场已选定,定稿存档未写入:${detail}`),
|
||
);
|
||
}
|
||
}
|
||
|
||
private persist(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,
|
||
): ChatMessage {
|
||
const base = {
|
||
id: randomUUID(),
|
||
role,
|
||
text,
|
||
createdAt: new Date().toISOString(),
|
||
...(tokenUsage ? { tokenUsage } : {}),
|
||
...(thinking ? { thinking } : {}),
|
||
...(contextTrace ? { contextTrace } : {}),
|
||
};
|
||
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);
|
||
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 messages = this.enrichDisplayMessages(session, [...s.messages]);
|
||
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 shellAdapt = parseShellAdaptationFromReplyFormat(
|
||
s.runtime.getBlackboard().getContentByTag("设计.正文组成") ??
|
||
s.runtime.getBlackboard().getContentByTag("设计.回复格式"),
|
||
);
|
||
|
||
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,
|
||
playLayerActive: isPlayLayerActive(session.slots),
|
||
playReady: canEnterPlay(session),
|
||
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,
|
||
liveStream: this.buildLiveStreamView(id),
|
||
agentThinking: this.agentThinkingLive.get(id),
|
||
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,
|
||
},
|
||
};
|
||
}
|
||
|
||
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): 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 [catalog, modules] = await Promise.all([
|
||
loadRecipeCatalog(skill.skillPackRoot),
|
||
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> {
|
||
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,
|
||
): 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,
|
||
}),
|
||
source: "user",
|
||
});
|
||
|
||
// 开局节点跟剧本 seed:写入近期起点并进入点选图(不必先跑 design-flow / 先描述需求)
|
||
const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim();
|
||
if (!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: "" };
|
||
}
|
||
|
||
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)}`;
|
||
}
|