会话与前端接上听书、呈现投影、请求轮廓和旁观气泡偏好。

新建作品可选听书连载,朗读与消费进度走专用接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 23:49:44 +08:00
co-authored by Cursor
parent cf6cb68429
commit 66ed1eaca8
21 changed files with 2395 additions and 357 deletions
+26 -3
View File
@@ -9,7 +9,20 @@ import {
writeFileSync,
} from "node:fs";
import path from "node:path";
import type { BookProject, BookSummary } from "../types/book.js";
import type {
BookCreationMode,
BookProject,
BookSummary,
} from "../types/book.js";
function normalizeBookCreationMode(
raw: string | undefined | null,
): BookCreationMode {
if (raw === "listen_serial" || raw === "recipe" || raw === "dictate") {
return raw;
}
return "dictate";
}
import type { PersistedBookSession } from "../types/book-session.js";
import type { RunSnapshot } from "../types/run-snapshot.js";
import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js";
@@ -57,6 +70,7 @@ export function listBooks(): BookSummary[] {
title: b.title,
activeSkillId: b.activeSkillId ?? b.orchestratorId,
activeSkillName: b.activeSkillName ?? b.orchestratorName,
creationMode: normalizeBookCreationMode(b.creationMode),
preview: b.preview,
updatedAt: b.updatedAt,
orchestratorId: b.orchestratorId,
@@ -67,19 +81,27 @@ export function listBooks(): BookSummary[] {
export function getBook(id: string): BookProject | null {
try {
const raw = readFileSync(bookPath(id), "utf8");
return JSON.parse(raw) as BookProject;
const book = JSON.parse(raw) as BookProject;
return {
...book,
creationMode: normalizeBookCreationMode(book.creationMode),
};
} catch {
return null;
}
}
export function createBook(input: { title?: string }): BookProject {
export function createBook(input: {
title?: string;
creationMode?: BookCreationMode;
}): BookProject {
ensureUserDataDirs();
mkdirSync(booksDir(), { recursive: true });
const now = new Date().toISOString();
const book: BookProject = {
id: randomUUID(),
title: input.title?.trim() || "未命名作品",
creationMode: normalizeBookCreationMode(input.creationMode),
preview: "新建作品,描述你想创作什么…",
sessionIds: [],
createdAt: now,
@@ -100,6 +122,7 @@ export function updateBook(
| "activeSessionId"
| "activeSkillId"
| "activeSkillName"
| "creationMode"
| "orchestratorId"
| "orchestratorName"
>
+1
View File
@@ -26,6 +26,7 @@ export type AgentMessageKind =
| "orchestrator_prompt"
| "orchestrator_assessment"
| "dictate_reply"
| "listen_reply"
| "agent_tool"
| "worker_running"
| "worker_output"
+83 -33
View File
@@ -1,5 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { listSkills } from "../skills/loader.js";
import type { ModuleCatalogEntry } from "../skills/creation-flow.js";
import { createBook, deleteBook, duplicateBook, getBook, listBooks, updateBook } from "../book/store.js";
import { sessionManager } from "./session-manager.js";
@@ -39,17 +40,64 @@ function moduleStatusFromSections(
return depth >= 2 ? "ready" : "partial";
}
type ModuleBrowseRow = {
id: string;
name: string;
declaration: string;
artifact: string;
hasPrompt: boolean;
sectionsPresent: string[];
status: ModuleStatus;
group: string;
presentMode: "message" | "zero_layer" | null;
layer: "intermediate" | "final" | null;
repeatable: boolean;
closer: boolean;
priorArtifact: boolean;
programSide: boolean;
libraries: string[];
land: string;
};
async function moduleBrowseFields(
entry: ModuleCatalogEntry,
): Promise<
Omit<
ModuleBrowseRow,
| "id"
| "name"
| "declaration"
| "artifact"
| "hasPrompt"
| "sectionsPresent"
| "status"
>
> {
const { dictateModuleLandHint, isDictateProgramModule } = await import(
"../skills/creation-flow.js"
);
const { moduleBrowseGroup, modulePresentationMode } = await import(
"../dictate/creation-plan.js"
);
const { getLibrary } = await import("../libraries/registry.js");
return {
group: moduleBrowseGroup(entry),
presentMode: modulePresentationMode(entry.id),
layer: entry.layer ?? null,
repeatable: Boolean(entry.repeatable),
closer: Boolean(entry.closer),
priorArtifact: entry.kind === "prior-artifact",
programSide: isDictateProgramModule(entry),
libraries: (entry.libraries ?? []).map(
(id) => getLibrary(id)?.label ?? id,
),
land: dictateModuleLandHint(entry),
};
}
async function loadModulesPayload(skillId: string): Promise<{
skillPackId: string;
modules: Array<{
id: string;
name: string;
declaration: string;
artifact: string;
hasPrompt: boolean;
sectionsPresent: string[];
status: ModuleStatus;
}>;
groups: Array<{ id: string; label: string; hint: string }>;
modules: ModuleBrowseRow[];
}> {
const { loadSkill } = await import("../skills/loader.js");
const {
@@ -59,13 +107,14 @@ async function loadModulesPayload(skillId: string): Promise<{
catalogModulesForIntake,
MODULE_SECTION_IDS,
} = await import("../skills/creation-flow.js");
const { MODULE_BROWSE_GROUPS } = await import("../dictate/creation-plan.js");
const skill = await loadSkill(skillId);
if (!skill.skillPackRoot) {
return { skillPackId: skillId, modules: [] };
return { groups: [...MODULE_BROWSE_GROUPS], modules: [] };
}
const catalog = await loadModuleCatalog(skill.skillPackRoot);
const modules = [];
for (const m of catalogModulesForIntake(catalog ?? { modules: [] }, "recipe")) {
const modules: ModuleBrowseRow[] = [];
for (const m of catalogModulesForIntake(catalog ?? { modules: [] }, "dictate")) {
const prompt = await loadModulePrompt(skill.skillPackRoot, m.id);
const sections = prompt
? parseModulePromptSections(prompt)
@@ -83,27 +132,23 @@ async function loadModulesPayload(skillId: string): Promise<{
hasPrompt,
sectionsPresent: [...sectionsPresent],
status: moduleStatusFromSections(hasPrompt, sectionsPresent, taskBody),
...(await moduleBrowseFields(m)),
});
}
return { skillPackId: skillId, modules };
return { groups: [...MODULE_BROWSE_GROUPS], modules };
}
async function loadModuleDetailPayload(
skillId: string,
moduleId: string,
): Promise<{
skillPackId: string;
id: string;
name: string;
declaration: string;
artifact: string;
hasPrompt: boolean;
sectionsPresent: string[];
status: ModuleStatus;
sections: Record<string, string>;
meta: Record<string, unknown> | null;
raw: string;
} | null> {
): Promise<
| (ModuleBrowseRow & {
sections: Record<string, string>;
meta: Record<string, unknown> | null;
raw: string;
})
| null
> {
const { loadSkill } = await import("../skills/loader.js");
const {
loadModuleCatalog,
@@ -141,7 +186,6 @@ async function loadModuleDetailPayload(
}
}
return {
skillPackId: skillId,
id: entry.id,
name: entry.name,
declaration: entry.declaration,
@@ -153,6 +197,7 @@ async function loadModuleDetailPayload(
sectionsPresent,
sections.task,
),
...(await moduleBrowseFields(entry)),
sections,
meta,
raw: prompt ?? "",
@@ -254,7 +299,7 @@ export async function handleBooksApi(
return true;
}
/** 默认配方包的技能池(编排备选) */
/** 对话落盘节点:按角色分组,不含旧配方包 */
if (pathname === "/api/modules" && req.method === "GET") {
const { DEFAULT_ORCHESTRATOR_ID } = await import(
"../config/default-orchestrator.js"
@@ -343,8 +388,8 @@ export async function handleBooksApi(
orchestratorId?: string;
/** 旧客户端可传;新建作品改由创作对话判断路径 */
recipeId?: string;
/** 旧客户端兼容;新建作品统一使用 dictate */
creationMode?: "recipe" | "dictate";
/** 进料方式:对话落盘(默认)| 听书连载 | 旧工序编排 */
creationMode?: "recipe" | "dictate" | "listen_serial";
};
const skills = await listSkills();
const requested = body.orchestratorId?.trim();
@@ -356,14 +401,19 @@ export async function handleBooksApi(
json(res, 400, { error: "没有可用的编排器技能包" });
return true;
}
const book = createBook({ title: body.title });
const rawMode = body.creationMode;
const creationMode =
rawMode === "listen_serial" || rawMode === "recipe" || rawMode === "dictate"
? rawMode
: "dictate";
const book = createBook({ title: body.title, creationMode });
updateBook(book.id, {
activeSkillId: director.name,
activeSkillName: director.description?.split("\n")[0]?.slice(0, 80) || director.name,
orchestratorId: director.name,
orchestratorName: director.name,
creationMode,
});
const creationMode = "dictate";
const session = await sessionManager.createForBook(
book.id,
director.name,
+1
View File
@@ -55,6 +55,7 @@ const PHASE_UNIT_LABELS: Record<string, string> = {
const CREATION_MODE_LABELS: Record<string, string> = {
recipe: "工序编排",
dictate: "对话落盘",
listen_serial: "听书连载",
};
const SKILL_PACK_LABELS: Record<string, string> = {
+299 -35
View File
@@ -93,6 +93,7 @@ import {
sessionSkillPackId,
skillPacksMatch,
} from "../book/skill-id.js";
import type { RequestOutline } from "../llm/context-outline.js";
import {
pruneContextTraces,
type LlmContextTrace,
@@ -102,6 +103,8 @@ import type { BlackboardItem } from "../types/blackboard.js";
import { Blackboard } from "../blackboard/blackboard.js";
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
import { parseReplyDataContract } from "../skills/reply-contract.js";
import { projectPresentBlocks } from "../skills/present-project.js";
import {
CREATION_ACCEPTED_CONTENT_TAG,
CREATION_ACCEPTED_UNITS_TAG,
@@ -182,6 +185,16 @@ import {
sortDictateProducts,
type DictateProduct,
} from "../dictate/index.js";
import {
LISTEN_SERIAL_MODE_VALUE,
isListenSerialModeValue,
markListenConsumed,
readListenState,
runListenSerialTurn,
toListenView,
type ListenSerialView,
} from "../listen-serial/index.js";
import { synthesizeSpeech, type TtsSynthesizeResult } from "../tts/index.js";
import {
formatQuestionAnswersForAi,
formatQuestionAnswersForDisplay,
@@ -255,6 +268,8 @@ export type ChatMessage = {
thinking?: string;
/** 该次 LLM 请求实际携带的全量上下文(观察用;按设置只保留最新 N 条) */
contextTrace?: LlmContextTrace;
/** 本条回复对应的各次请求标签轮廓(选题、填写都在;不含正文) */
requestOutlines?: RequestOutline[];
/** run 验收后过程讨论折叠(主 feed 隐藏);创作 messages 不删不藏 */
compressed?: boolean;
tokenUsage?: MessageTokenUsage;
@@ -323,6 +338,14 @@ export type SessionView = {
block_labels?: Record<string, string>;
empty_states?: Record<string, string>;
};
/** 正文组成固定前端。游玩和开场把 present.v1 灌进去,替换示例内容。 */
presentationFrontend?: { html: string; css: string; js: string };
/** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */
presentationSlots?: Array<{ id: string; type: string }>;
/**
* 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并,随真值刷新。
*/
presentationProjection?: Record<string, unknown>;
/** 总管正在输出的思维链(流式,轮询用) */
agentThinking?: string;
/** Agent / Worker 流式输出(轮询用) */
@@ -333,10 +356,12 @@ export type SessionView = {
*/
runActive?: boolean;
resumed?: boolean;
/** 创作进料:recipe=配方节点流;dictate=转述整理直填 */
creationMode?: "recipe" | "dictate";
/** 创作进料:recipe=配方节点流;dictate=转述整理;listen_serial=听书连载 */
creationMode?: "recipe" | "dictate" | "listen_serial";
/** 转述式已写入黑板的产物(按相对序) */
dictateProducts?: Array<{ tag: string; content: string; order?: number }>;
/** 听书连载投影(大纲/正文缓冲/未读字数) */
listenSerial?: ListenSerialView;
tokenStats?: {
sessionTotal: number;
sessionCached?: number;
@@ -555,8 +580,16 @@ export class SessionManager {
this.writeOpeningPersonaChoice(s, true);
}
// 与进游玩一致:用完整文档(含 present.v1),勿用侧栏预览的压扁正文
const rawOnBoard = board.getContentByTag(picked.tag)?.trim() ?? "";
const body =
splitOpeningDocument(
rawOnBoard || picked.document || picked.text,
).text.trim() ||
picked.document?.trim() ||
picked.text.trim();
const expanded = this.expandDirectivesText(
picked.text,
body,
this.playDirectiveContext(s),
);
board.write({
@@ -566,7 +599,13 @@ export class SessionManager {
});
const openingAt = s.messages.findIndex((m) => m.title === "开场白");
if (openingAt >= 0) {
s.messages[openingAt] = this.playOpeningMessage(expanded);
// 保留 id,前端才能 patch 呈现壳 forceFill,而不是拆 iframe 后软灌样本槽
s.messages[openingAt] = this.playOpeningMessage(
expanded,
s.messages[openingAt]!.id,
);
} else {
s.messages.unshift(this.playOpeningMessage(expanded));
}
if (s.bookId) this.persist(s);
return this.toView(sessionId);
@@ -613,11 +652,13 @@ export class SessionManager {
const next = applyContextOrderEdit(current!, edit);
const serialized = serializeContextOrder(next);
const orderMeta = bb.getLatestByTag(CONTEXT_ORDER_TAG)?.metadata;
bb.write({
tag: CONTEXT_ORDER_TAG,
content: serialized,
source: "user",
metadata: orderMeta,
});
if (workerSetRaw) {
@@ -631,6 +672,7 @@ export class SessionManager {
tag: targetTag,
content: mergeContextOrderIntoWorkerSetJson(workerSetRaw, next),
source: "user",
metadata: bb.getLatestByTag(targetTag)?.metadata,
});
}
} else {
@@ -639,6 +681,7 @@ export class SessionManager {
tag: CONTEXT_ORDER_TAG,
content: serialized,
source: "user",
metadata: orderMeta,
});
}
@@ -670,7 +713,7 @@ export class SessionManager {
bookId?: string,
preselectSkillId?: string,
selectedRecipeId?: string,
opts?: { creationMode?: "recipe" | "dictate" },
opts?: { creationMode?: "recipe" | "dictate" | "listen_serial" },
): Promise<SessionView> {
const id = randomUUID();
const messages: ChatMessage[] = [];
@@ -713,18 +756,22 @@ export class SessionManager {
} else {
await runtime.start();
}
const useDictate = opts?.creationMode === "dictate";
const creationMode = opts?.creationMode ?? "recipe";
const useDictate = creationMode === "dictate";
const useListen = creationMode === "listen_serial";
await this.refreshRecipeOptions(
managed,
useDictate ? "dictate" : "recipe",
useDictate || useListen ? "dictate" : "recipe",
);
if (selectedRecipeId?.trim()) {
await this.applySelectedRecipe(managed, selectedRecipeId.trim(), {
seedFlow: !useDictate,
seedFlow: !useDictate && !useListen,
});
}
if (useDictate) {
if (useListen) {
this.enterListenSerialMode(managed);
} else if (useDictate) {
this.enterDictateMode(managed);
} else {
await this.maybeKickAgentAfterDemand(runtime);
@@ -780,6 +827,145 @@ export class SessionManager {
);
}
/** 听书连载:薄上下文连续产文,不走对话落盘工具环 */
private enterListenSerialMode(s: ManagedSession): void {
const board = s.runtime.getBlackboard();
board.write({
tag: CREATION_INTAKE_MODE_TAG,
content: LISTEN_SERIAL_MODE_VALUE,
source: "runtime",
});
s.runtime.enterDictateIntake(
"听书连载:用几句话描述题材与爽点;确认后我会生成大纲并自动补正文。不满意就说一句意见改后面。",
);
const welcome =
"听书连载:说几句你想听的故事(题材、人设、爽点即可)。我会轻回问或给出草案;确认后自动出大纲和正文缓冲。听的过程中随时一句意见,只改后面的走向。";
s.messages.push({
id: randomUUID(),
role: "system",
text: welcome,
createdAt: new Date().toISOString(),
kind: "orchestrator_prompt",
actor: "orchestrator",
title: "听书连载",
body: welcome,
});
}
private isListenSerialMode(s: ManagedSession): boolean {
return isListenSerialModeValue(
s.runtime.getBlackboard().getContentByTag(CREATION_INTAKE_MODE_TAG),
);
}
private creationIntakeMode(
s: ManagedSession,
): "recipe" | "dictate" | "listen_serial" {
if (this.isListenSerialMode(s)) return "listen_serial";
if (this.isDictateMode(s)) return "dictate";
return "recipe";
}
private extractListenDialogue(
messages: ChatMessage[],
): Array<{ role: "user" | "assistant"; text: string }> {
const out: Array<{ role: "user" | "assistant"; text: string }> = [];
for (const m of messages) {
if (m.kind === "user_input" || m.role === "user") {
const t = (m.body ?? m.text ?? "").trim();
if (t) out.push({ role: "user", text: t });
continue;
}
if (m.kind === "listen_reply") {
const t = (m.body ?? m.text ?? "").trim();
if (t) out.push({ role: "assistant", text: t });
}
}
return out;
}
private async runListenSerialTurnForSession(
s: ManagedSession,
userText: string,
): Promise<void> {
const llm = createDefaultMainAgentLlm(s.trackingRef, () =>
this.personaForManaged(s),
);
const dialogue = this.extractListenDialogue(s.messages);
const result = await runListenSerialTurn({
llm,
board: s.runtime.getBlackboard(),
dialogue,
userText,
});
const keep = normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest,
);
const requestOutlines = s.trackingRef.current.pendingOutlines;
const pendingTrace = s.trackingRef.current.pendingContextTrace;
const tokenUsage = s.trackingRef.current.pendingUsage;
s.trackingRef.current.pendingOutlines = undefined;
s.trackingRef.current.pendingContextTrace = undefined;
s.trackingRef.current.pendingUsage = undefined;
s.messages.push({
id: randomUUID(),
role: "system",
text: result.reply,
createdAt: new Date().toISOString(),
kind: "listen_reply",
actor: "orchestrator",
title: "听书连载",
body: result.reply,
...(tokenUsage ? { tokenUsage } : {}),
...(keep > 0 && pendingTrace ? { contextTrace: pendingTrace } : {}),
...(requestOutlines?.length ? { requestOutlines } : {}),
});
s.messages = pruneContextTraces(s.messages, keep);
s.runtime.enterDictateIntake(
result.softStop
? "本段已告一段落。输入「下一波」或「收工」,或一句意见改向。"
: "继续听/读,或说「继续」补货;不满意直接一句意见。",
);
}
/** 听书:标记已消费字数(阅读进度) */
markListenSerialConsumed(
sessionId: string,
consumedChars: number,
): SessionView {
const s = this.require(sessionId);
if (!this.isListenSerialMode(s)) {
throw new Error("当前不是听书连载模式");
}
markListenConsumed(s.runtime.getBlackboard(), consumedChars);
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
/** 听书:按节合成语音(默认 Edge) */
async synthesizeListenSection(
sessionId: string,
sectionId: string,
): Promise<TtsSynthesizeResult & { sectionId: string; title: string }> {
const s = this.require(sessionId);
if (!this.isListenSerialMode(s)) {
throw new Error("当前不是听书连载模式");
}
const id = sectionId.trim();
if (!id) throw new Error("需要 sectionId");
const state = readListenState(s.runtime.getBlackboard());
const section = state.sections.find((x) => x.id === id);
if (!section?.text?.trim()) {
throw new Error("未找到该节正文");
}
const spoken = `【${section.title}】\n${section.text}`.trim();
const result = await synthesizeSpeech({
text: spoken,
cacheKey: `${sessionId}:${id}`,
});
return { ...result, sectionId: id, title: section.title };
}
private listDictateProducts(s: ManagedSession): DictateProduct[] {
const board = s.runtime.getBlackboard();
const products: DictateProduct[] = [];
@@ -897,11 +1083,10 @@ export class SessionManager {
if (dictateProductFamily(tag) === OPENING_PRODUCT_FAMILY) {
const split = splitOpeningDocument(content);
if (split.persona) {
ensureProtagonistFromPersona(
board,
split.persona,
protagonistTagForOpeningSlot(tag),
);
const personaTag = protagonistTagForOpeningSlot(tag);
ensureProtagonistFromPersona(board, split.persona, personaTag, {
overwrite: personaTag !== PROTAGONIST_TAG,
});
}
// 仅有用户角色 meta、无叙事:抬到主角设定,不污染开场白
if (split.persona && !split.text.trim()) {
@@ -1009,6 +1194,15 @@ export class SessionManager {
s.messages.length,
this.captureCheckpoint(s),
);
const keep = normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest,
);
const requestOutlines = s.trackingRef.current.pendingOutlines;
const pendingTrace = s.trackingRef.current.pendingContextTrace;
const tokenUsage = s.trackingRef.current.pendingUsage;
s.trackingRef.current.pendingOutlines = undefined;
s.trackingRef.current.pendingContextTrace = undefined;
s.trackingRef.current.pendingUsage = undefined;
s.messages.push({
id: randomUUID(),
role: "system",
@@ -1019,7 +1213,11 @@ export class SessionManager {
title: "转述整理",
body: result.reply,
...(thinking ? { thinking } : {}),
...(tokenUsage ? { tokenUsage } : {}),
...(keep > 0 && pendingTrace ? { contextTrace: pendingTrace } : {}),
...(requestOutlines?.length ? { requestOutlines } : {}),
});
s.messages = pruneContextTraces(s.messages, keep);
s.runtime.enterDictateIntake();
}
@@ -1494,6 +1692,7 @@ export class SessionManager {
// 转述聊天常夹确认/建议,不能当开场白
if (
kind === "dictate_reply" ||
kind === "listen_reply" ||
kind === "agent_tool" ||
kind === "orchestrator_thinking" ||
kind === "worker_questions"
@@ -1830,9 +2029,9 @@ export class SessionManager {
);
}
private playOpeningMessage(text: string): ChatMessage {
private playOpeningMessage(text: string, keepId?: string): ChatMessage {
return {
id: randomUUID(),
id: keepId || randomUUID(),
role: "system",
text,
createdAt: new Date().toISOString(),
@@ -1917,6 +2116,29 @@ export class SessionManager {
return count;
}
/**
* 切换/改了全局用户角色:游玩层开场按新 @玩家 重展开,并改用全局角色卡。
*/
rebindPlayOpeningsForActivePersona(): number {
let count = 0;
for (const [sessionId, s] of this.sessions) {
if (!isPlayLayerActive(s.runtime.getSession().slots)) continue;
if (inferLifecycleStage(s.runtime.getSession()) !== "play") continue;
const aux = buildPlayAuxView(
s.runtime.getBlackboard(),
s.runtime.getSession().slots,
);
if (!aux.openings.length) continue;
try {
this.selectPlayOpening(sessionId, aux.selectedOpeningIndex, false);
count += 1;
} catch {
/* 单局失败不挡其它局 */
}
}
return count;
}
async sendMessage(
id: string,
text: string,
@@ -1983,13 +2205,15 @@ export class SessionManager {
const inPlay =
isPlayLayerActive(sess.slots) ||
inferLifecycleStage(sess) === "play";
if (
this.isDictateMode(s) &&
reason?.kind === "input" &&
!inPlay
) {
await this.runDictateTurnForSession(s, trimmed || text);
return;
if (reason?.kind === "input" && !inPlay) {
if (this.isListenSerialMode(s)) {
await this.runListenSerialTurnForSession(s, trimmed || text);
return;
}
if (this.isDictateMode(s)) {
await this.runDictateTurnForSession(s, trimmed || text);
return;
}
}
}
if (reason?.kind === "approve_step") {
@@ -2237,13 +2461,15 @@ export class SessionManager {
const inPlay =
isPlayLayerActive(sess.slots) ||
inferLifecycleStage(sess) === "play";
if (
this.isDictateMode(s) &&
reason?.kind === "input" &&
!inPlay
) {
await this.runDictateTurnForSession(s, trimmed);
return;
if (reason?.kind === "input" && !inPlay) {
if (this.isListenSerialMode(s)) {
await this.runListenSerialTurnForSession(s, trimmed);
return;
}
if (this.isDictateMode(s)) {
await this.runDictateTurnForSession(s, trimmed);
return;
}
}
await s.runtime.submitInput(trimmed);
});
@@ -3100,6 +3326,7 @@ export class SessionManager {
undefined;
const pendingTrace = managed.trackingRef.current.pendingContextTrace;
let contextTrace: LlmContextTrace | undefined;
let requestOutlines: RequestOutline[] | undefined;
if (
pendingTrace &&
classified &&
@@ -3112,10 +3339,19 @@ export class SessionManager {
contextTrace = pendingTrace;
}
managed.trackingRef.current.pendingContextTrace = undefined;
requestOutlines = managed.trackingRef.current.pendingOutlines;
managed.trackingRef.current.pendingOutlines = undefined;
}
managed.messages.push(
this.msg("system", text, usage, thinking, contextTrace),
this.msg(
"system",
text,
usage,
thinking,
contextTrace,
requestOutlines?.length ? requestOutlines : undefined,
),
);
managed.messages = pruneContextTraces(
managed.messages,
@@ -3405,6 +3641,7 @@ export class SessionManager {
tokenUsage?: MessageTokenUsage,
thinking?: string,
contextTrace?: LlmContextTrace,
requestOutlines?: RequestOutline[],
): ChatMessage {
const base = {
id: randomUUID(),
@@ -3414,6 +3651,7 @@ export class SessionManager {
...(tokenUsage ? { tokenUsage } : {}),
...(thinking ? { thinking } : {}),
...(contextTrace ? { contextTrace } : {}),
...(requestOutlines?.length ? { requestOutlines } : {}),
};
if (role === "user") {
return {
@@ -3812,10 +4050,12 @@ export class SessionManager {
lifecycleStage,
{ workerSetYaml },
);
const shellAdapt = parseShellAdaptationFromReplyFormat(
const replyFormatRaw =
s.runtime.getBlackboard().getContentByTag("设计.正文组成") ??
s.runtime.getBlackboard().getContentByTag("设计.回复格式"),
);
s.runtime.getBlackboard().getContentByTag("设计.0层正文组成") ??
s.runtime.getBlackboard().getContentByTag("设计.回复格式");
const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw);
const replyContract = parseReplyDataContract(replyFormatRaw);
return {
id,
@@ -3831,10 +4071,13 @@ export class SessionManager {
actions,
pipeline: buildPipeline(session),
lifecycleStage,
creationMode: this.isDictateMode(s) ? "dictate" : "recipe",
creationMode: this.creationIntakeMode(s),
dictateProducts: this.isDictateMode(s)
? this.listDictateProducts(s)
: undefined,
listenSerial: this.isListenSerialMode(s)
? toListenView(readListenState(s.runtime.getBlackboard()))
: undefined,
playLayerActive: isPlayLayerActive(session.slots),
playReady: canEnterPlay(session),
hasProduct: Boolean(
@@ -3895,6 +4138,27 @@ export class SessionManager {
...shellAdapt.tweaks,
}
: undefined,
presentationFrontend: replyContract?.frontend,
presentationSlots: replyContract
? [...replyContract.modelBlocks, ...replyContract.programBlocks].map((block) => ({
id: block.id,
type: block.type,
}))
: undefined,
presentationProjection: (() => {
if (!replyContract?.programBlocks.length) return undefined;
const board = s.runtime.getBlackboard();
const projected = projectPresentBlocks({
contract: replyContract,
statusBarRaw:
board.getContentByTag("设计.监控栏") ??
board.getContentByTag("设计.回复呈现"),
currentVarsRaw: board.getContentByTag("变量.当前"),
topologyRaw: board.getContentByTag("运行.拓扑"),
readTag: (tag) => board.getContentByTag(tag),
});
return Object.keys(projected).length ? projected : undefined;
})(),
liveStream: this.buildLiveStreamView(id),
agentThinking: this.agentThinkingLive.get(id),
runActive: Boolean(s.runAbort),
+16
View File
@@ -613,8 +613,13 @@ export async function handleSettingsApi(
activate: body.activate,
creationDefault: body.creationDefault,
});
const reboundSessions =
body.activate === false
? 0
: sessionManager.rebindPlayOpeningsForActivePersona();
json(res, 201, {
...personasPayload({ persona }),
reboundSessions,
});
} catch (e) {
json(res, 400, {
@@ -634,8 +639,11 @@ export async function handleSettingsApi(
if (activate && req.method === "POST") {
try {
const persona = setActivePersonaId(id);
const reboundSessions =
sessionManager.rebindPlayOpeningsForActivePersona();
json(res, 200, {
...personasPayload({ persona, activeId: persona.id }),
reboundSessions,
});
} catch (e) {
json(res, 404, {
@@ -653,8 +661,13 @@ export async function handleSettingsApi(
creationDefault?: boolean;
};
const persona = updatePersona(id, body);
const reboundSessions =
getActivePersona()?.id === persona.id
? sessionManager.rebindPlayOpeningsForActivePersona()
: 0;
json(res, 200, {
...personasPayload({ persona }),
reboundSessions,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -666,8 +679,11 @@ export async function handleSettingsApi(
if (req.method === "DELETE") {
try {
deletePersona(id);
const reboundSessions =
sessionManager.rebindPlayOpeningsForActivePersona();
json(res, 200, {
...personasPayload({ ok: true }),
reboundSessions,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
+48
View File
@@ -230,6 +230,54 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === "POST" && sub === "/listen/consume") {
const body = JSON.parse(await readBody(req)) as {
consumedChars?: number;
};
const n = Number(body.consumedChars);
if (!Number.isFinite(n) || n < 0) {
json(res, 400, { error: "需要 consumedChars ≥ 0" });
return;
}
try {
const view = sessionManager.markListenSerialConsumed(sessionId, n);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "进度更新失败",
});
}
return;
}
if (req.method === "GET" && sub === "/listen/tts") {
const sectionId = (url.searchParams.get("sectionId") ?? "").trim();
if (!sectionId) {
json(res, 400, { error: "需要 sectionId" });
return;
}
try {
const audio = await sessionManager.synthesizeListenSection(
sessionId,
sectionId,
);
res.writeHead(200, {
"Content-Type": audio.contentType,
"Content-Length": audio.audio.length,
"Cache-Control": audio.cached ? "private, max-age=86400" : "no-store",
"X-TTS-Provider": audio.provider,
"X-TTS-Voice": audio.voice,
"X-TTS-Cached": audio.cached ? "1" : "0",
});
res.end(audio.audio);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "语音合成失败",
});
}
return;
}
if (req.method === "POST" && sub === "/answers") {
const body = JSON.parse(await readBody(req)) as {
answers?: Array<{
+15
View File
@@ -39,7 +39,22 @@ export type PersistedChatMessage = {
messages: Array<{ role: string; content: string }>;
charCount: number;
model?: string;
segments?: Array<{ tag: string; chars: number; hash: string }>;
};
/** 各次请求的标签轮廓,不含正文 */
requestOutlines?: Array<{
at: string;
caller: string;
step?: string;
turnId?: string;
model?: string;
promptTokens?: number;
completionTokens?: number;
cachedTokens?: number;
cacheMissTokens?: number;
cacheWriteTokens?: number;
segments: Array<{ tag: string; chars: number; hash: string }>;
}>;
branchGroupId?: string;
branchIndex?: number;
branchTotal?: number;
+6
View File
@@ -1,9 +1,14 @@
/** 进料/创作方式:一书一方式,目录按此分夹 */
export type BookCreationMode = "recipe" | "dictate" | "listen_serial";
export type BookProject = {
id: string;
title: string;
/** 用户选定或 agent 确认的 skill 包 */
activeSkillId?: string;
activeSkillName?: string;
/** 新建时选定;缺省视为 dictate(旧书) */
creationMode?: BookCreationMode;
preview: string;
sessionIds: string[];
/** 最近一次持久化的 sessionId,用于续作 */
@@ -22,6 +27,7 @@ export type BookSummary = Pick<
| "title"
| "activeSkillId"
| "activeSkillName"
| "creationMode"
| "preview"
| "updatedAt"
| "orchestratorId"