Compare commits

2 Commits
29 changed files with 993 additions and 929 deletions
@@ -0,0 +1,17 @@
# 创作对话降本:取消同轮 retry + 2 轮 history
> 状态:已落地。承接 `token-cost-report-2026-10-06.md` / `prompt-cache-audit-2026-10-07.md`。
> 记录:2026-10-08。
## 做了什么
1. **取消同轮 `fill-retry`。** fill 只打一枪;未通过项写入 `设计.对话落盘待补`(readTag,不进产物列表),checklist 标 **失败待补**,下轮 select/fill 自动并入。
2. **select / fill 更早对话默认只留 2 轮**(1 轮 = 一问一答,不含本轮末句)。可用 `DICTATE_HISTORY_TURNS` 或 `historyTurns` 参数改;负数 = 不限制。
3. **产物全黑板暂不动**(质量 / play 传导风险;内容侧优化另议)。
4. Gemini 路径上跨轮缓存 ROI 很低;本改动主攻 **少开枪 + 砍无用 history**,不为保缓存去裁产物。
## 验收
- 同书同对话长度:`fill-retry` 调用应接近 0;每轮模型调用中位趋向 **2**(澄清 1;缺 reply_user 时 lean reply 仍可能 3)。
- checklist 可见:待写 / 失败待补;失败回复含「失败待补(下轮继续)」。
- `DICTATE_HISTORY_TURNS=0` 时更早对话段应接近空。
-4
View File
@@ -6,7 +6,6 @@
> 各壳介绍与规格:`skills/dialogue/world-simulator/present-shells/`(正文组成执行时动态注入)。 > 各壳介绍与规格:`skills/dialogue/world-simulator/present-shells/`(正文组成执行时动态注入)。
> **创作定位:按回复呈现清单选壳并分区;正文语法见【正文安全子集】(程序动态装载)。** > **创作定位:按回复呈现清单选壳并分区;正文语法见【正文安全子集】(程序动态装载)。**
> 日常折叠/表格走 Markdown 安全子集;一般不写本局任意 CSS/HTML/JS。 > 日常折叠/表格走 Markdown 安全子集;一般不写本局任意 CSS/HTML/JS。
> 预览:`/shells.html`
## 1. 创作怎么做(权威口径) ## 1. 创作怎么做(权威口径)
@@ -30,8 +29,6 @@
| 局面 + 选项 | `choice_dock` | | 局面 + 选项 | `choice_dock` |
| 章正文 + 进度 | `chapter_reader` | | 章正文 + 进度 | `chapter_reader` |
预览页 `/shells.html`:主工作面是壳画布;侧栏只换壳;长度/语气在顶栏。
## 2. 信息层级与长文容量 ## 2. 信息层级与长文容量
所有壳共用同一优先级——**改壳只是改谁占主面积**: 所有壳共用同一优先级——**改壳只是改谁占主面积**:
@@ -127,7 +124,6 @@
| 序 | 项 | 状态 | | 序 | 项 | 状态 |
|----|----|------| |----|----|------|
| 1 | `web/` 壳渲染器 + 层级样式 | **已接** `present-shells.js` + `styles.css` | | 1 | `web/` 壳渲染器 + 层级样式 | **已接** `present-shells.js` + `styles.css` |
| 1b | 壳预览页 | **已接** `/shells.html` |
| 2 | 正文组成微调进 SessionView | **已接** | | 2 | 正文组成微调进 SessionView | **已接** |
| 3 | present.v1 解析 | **已接** `present-packet.ts` | | 3 | present.v1 解析 | **已接** `present-packet.ts` |
| 4 | narrator 按壳输出 | **已接** | | 4 | narrator 按壳输出 | **已接** |
+1 -1
View File
@@ -5,7 +5,7 @@
> **标准范例**:`modules/aesthetics-interaction/prompt.md`。 > **标准范例**:`modules/aesthetics-interaction/prompt.md`。
> **真值 / Data / Progressive(变量与门控)**:**`docs/progressive-data-design.md`**。 > **真值 / Data / Progressive(变量与门控)**:**`docs/progressive-data-design.md`**。
> **上下文片段 / 槽位 / 投影排序**:**`docs/context-fragment-design.md`**。 > **上下文片段 / 槽位 / 投影排序**:**`docs/context-fragment-design.md`**。
> **游玩呈现壳**:**`docs/play-presentation-shells.md`**(预览页 `/shells.html`)。 > **游玩呈现壳**:**`docs/play-presentation-shells.md`**。
> **主世界层↔旁观怎么配合**:**`docs/play-dm-auditor.md`**。 > **主世界层↔旁观怎么配合**:**`docs/play-dm-auditor.md`**。
> **提示词待改清单**:**`docs/briefs/prompt-revision-inventory.md`**。 > **提示词待改清单**:**`docs/briefs/prompt-revision-inventory.md`**。
> **design-flow 提示词人工审查**:**`docs/briefs/design-flow-prompt-review.md`**。 > **design-flow 提示词人工审查**:**`docs/briefs/design-flow-prompt-review.md`**。
+13 -2
View File
@@ -1,4 +1,8 @@
import type { ChatMessage } from "../llm/client.js"; import type { ChatMessage } from "../llm/client.js";
import {
clipDictateDialogue,
resolveDictateHistoryTurns,
} from "./history-window.js";
import { import {
effectiveDictateOrder, effectiveDictateOrder,
sortDictateProducts, sortDictateProducts,
@@ -25,7 +29,7 @@ export type DictateBuiltContext = {
/** /**
* 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。 * 创作请求的块顺序:固定规则、更早的对话、全部产物、本轮注入、末尾。
* 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。 * 选题不带产物正文和本轮注入。回复步只带本轮刚落盘产物与末句用户话。
* 更早对话只追加,最后一轮用户输入放在末尾。 * 更早对话按 historyTurns 裁剪(默认 2 轮),最后一轮用户输入放在末尾。
*/ */
export function buildDictateContext(params: { export function buildDictateContext(params: {
systemPrompt: string; systemPrompt: string;
@@ -34,12 +38,18 @@ export function buildDictateContext(params: {
phase?: DictateContextPhase; phase?: DictateContextPhase;
injected?: string; injected?: string;
tail?: string; tail?: string;
/** 更早对话保留轮数;默认见 resolveDictateHistoryTurns */
historyTurns?: number;
}): DictateBuiltContext { }): DictateBuiltContext {
const phase = params.phase ?? "fill"; const phase = params.phase ?? "fill";
if (phase === "reply") { if (phase === "reply") {
return buildReplyContext(params); return buildReplyContext(params);
} }
const { earlier, lastUser } = splitEarlierDialogue(params.dialogue); const clipped = clipDictateDialogue(
params.dialogue,
resolveDictateHistoryTurns(params.historyTurns),
);
const { earlier, lastUser } = splitEarlierDialogue(clipped);
const history = formatEarlierDialogue(earlier); const history = formatEarlierDialogue(earlier);
const products = const products =
phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : ""; phase === "fill" ? formatProducts(sortDictateProducts(params.products)) : "";
@@ -138,6 +148,7 @@ export function buildDictateMessages(params: {
phase?: DictateContextPhase; phase?: DictateContextPhase;
injected?: string; injected?: string;
tail?: string; tail?: string;
historyTurns?: number;
}): ChatMessage[] { }): ChatMessage[] {
return buildDictateContext(params).messages; return buildDictateContext(params).messages;
} }
+50
View File
@@ -0,0 +1,50 @@
import type { DictateChatTurn } from "./types.js";
/**
* 创作上下文保留的更早对话轮数。
* 1 轮 = 一次用户输入 + 一次助手输出(不含本轮正在处理的末句用户话)。
* 默认 2;0 = 不保留更早对话;负数 = 不限制。
* 可用环境变量 `DICTATE_HISTORY_TURNS` 或调用参数覆盖。
*/
export const DEFAULT_DICTATE_HISTORY_TURNS = 2;
export function resolveDictateHistoryTurns(override?: number): number {
if (typeof override === "number" && Number.isFinite(override)) {
return Math.trunc(override);
}
const raw = process.env.DICTATE_HISTORY_TURNS?.trim();
if (raw) {
const n = Number(raw);
if (Number.isFinite(n)) return Math.trunc(n);
}
return DEFAULT_DICTATE_HISTORY_TURNS;
}
/**
* 裁剪进模型的对话:保留末句用户输入 + 最近 `historyTurns` 轮更早问答。
* `historyTurns < 0` 时原样返回。
*/
export function clipDictateDialogue(
dialogue: DictateChatTurn[],
historyTurns: number,
): DictateChatTurn[] {
if (historyTurns < 0) return dialogue;
const last = dialogue.at(-1);
const hasCurrentUser = last?.role === "user";
const earlier = hasCurrentUser ? dialogue.slice(0, -1) : [...dialogue];
const current = hasCurrentUser && last ? [last] : [];
if (historyTurns === 0) return current;
let userCount = 0;
let start = 0;
for (let i = earlier.length - 1; i >= 0; i -= 1) {
if (earlier[i]?.role === "user") {
userCount += 1;
if (userCount === historyTurns) {
start = i;
break;
}
}
}
return [...earlier.slice(start), ...current];
}
+14
View File
@@ -10,6 +10,20 @@ export {
buildDictateContext, buildDictateContext,
extractDictateDialogue, extractDictateDialogue,
} from "./context.js"; } from "./context.js";
export {
DEFAULT_DICTATE_HISTORY_TURNS,
clipDictateDialogue,
resolveDictateHistoryTurns,
} from "./history-window.js";
export {
DICTATE_REMEDIATE_TAG,
DICTATE_REMEDIATE_SCHEMA,
parseDictateRemediate,
serializeDictateRemediate,
formatRemediateChecklist,
type DictateRemediateDoc,
type DictateRemediateItem,
} from "./remediate.js";
export { export {
CREATION_INTAKE_MODE_TAG, CREATION_INTAKE_MODE_TAG,
DICTATE_MODE_VALUE, DICTATE_MODE_VALUE,
+1
View File
@@ -26,6 +26,7 @@ export function buildDictateSelectPrompt(): string {
return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。 return `你只做选题。对照能力目录和用户这句话,调用 select_nodes,选出已经够写的节点。
nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。 nodes 里的 module_id 必须来自目录。通常选这段话已经说清的 2~4 个。尚无本局方案、且这句话已经给出核心体验时,把 creation-plan 放进 nodes。
若末尾 checklist 标了「失败待补」,这些节点必须再次纳入 nodes(可与新选的一起)。
用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。 用户的体验目标、举例和比方只说明要追求什么、像什么。范围还没和用户确认时,不为把它推成世界常态而选舞台骨架或实现机制。
这一步不写产物正文,不回复用户。`; 这一步不写产物正文,不回复用户。`;
} }
+83
View File
@@ -0,0 +1,83 @@
/**
* 同轮不再 fill-retry:失败项写入「待补」状态,下轮 select/fill 继续补。
* 存 readTag/writeTag,不进产物列表,避免污染黑板正文。
*/
export const DICTATE_REMEDIATE_TAG = "设计.对话落盘待补";
export const DICTATE_REMEDIATE_SCHEMA = "dictate-remediate.v1" as const;
export type DictateRemediateKind = "insert" | "delete";
export type DictateRemediateItem = {
tag: string;
moduleId?: string;
error: string;
kind: DictateRemediateKind;
};
export type DictateRemediateDoc = {
schema: typeof DICTATE_REMEDIATE_SCHEMA;
items: DictateRemediateItem[];
};
export function parseDictateRemediate(raw: string | undefined | null): DictateRemediateDoc {
if (!raw?.trim()) return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] };
try {
const row = JSON.parse(raw) as Record<string, unknown>;
const items = Array.isArray(row.items) ? row.items : [];
const parsed: DictateRemediateItem[] = [];
for (const item of items) {
if (!item || typeof item !== "object") continue;
const rec = item as Record<string, unknown>;
const tag = String(rec.tag ?? "").trim();
if (!tag) continue;
const kind = rec.kind === "delete" ? "delete" : "insert";
const moduleId = String(rec.moduleId ?? rec.module_id ?? "").trim();
const error = String(rec.error ?? "尚未通过").trim() || "尚未通过";
parsed.push({
tag,
error,
kind,
...(moduleId ? { moduleId } : {}),
});
}
return { schema: DICTATE_REMEDIATE_SCHEMA, items: dedupeRemediate(parsed) };
} catch {
return { schema: DICTATE_REMEDIATE_SCHEMA, items: [] };
}
}
export function serializeDictateRemediate(doc: DictateRemediateDoc): string {
return JSON.stringify(
{
schema: DICTATE_REMEDIATE_SCHEMA,
items: dedupeRemediate(doc.items),
},
null,
2,
);
}
export function formatRemediateChecklist(items: readonly DictateRemediateItem[]): string {
if (!items.length) return "";
return [
"失败待补(须继续写入,勿忽略):",
...items.map((item) => {
const kind = item.kind === "delete" ? "删除" : "写入";
const mod = item.moduleId ? ` / ${item.moduleId}` : "";
return `- 〔${kind}〕${item.tag}${mod}:${item.error}`;
}),
].join("\n");
}
function dedupeRemediate(items: DictateRemediateItem[]): DictateRemediateItem[] {
const seen = new Set<string>();
const out: DictateRemediateItem[] = [];
for (const item of items) {
const key = `${item.kind}:${item.tag}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
+159 -43
View File
@@ -1,13 +1,14 @@
/** /**
* 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法, * 一句用户话:先用 select_nodes 定目标节点,程序注入这些节点的写法,
* 再在同一条消息前缀上循环 insert。已通过的不再覆盖。 * 再单次 fill 写入。已通过的不再覆盖。
* 同轮不再 fill-retry:失败项记入「失败待补」,下轮继续。
* 没有未通过项时,同一次请求里的 reply_user 才给用户。 * 没有未通过项时,同一次请求里的 reply_user 才给用户。
* 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。 * 填写步若未调用 reply_user,程序再打一轮瘦回复(只带本轮刚落盘产物);填写提示不提及此事。
*/ */
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js"; import type { DriverStepOutcome, DriverToolCall } from "../runtime/driver.js";
import type { AgentDriver } from "../runtime/driver.js"; import type { AgentDriver } from "../runtime/driver.js";
import type { ChatMessage, LlmProvider, UsageDetail, UsageSink } from "../llm/client.js"; import type { LlmProvider, UsageDetail, UsageSink } from "../llm/client.js";
import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js"; import { DICTATE_REPLY_TOOL, DICTATE_SELECT_TOOL, DICTATE_WRITE_LOOP_TOOLS } from "./tools.js";
import { buildDictateContext, type DictateSegmentChars } from "./context.js"; import { buildDictateContext, type DictateSegmentChars } from "./context.js";
import { import {
@@ -18,6 +19,7 @@ import {
parseCreationPlan, parseCreationPlan,
} from "./creation-plan.js"; } from "./creation-plan.js";
import { isDictateOpeningTag } from "./experience-anchor.js"; import { isDictateOpeningTag } from "./experience-anchor.js";
import { resolveDictateHistoryTurns } from "./history-window.js";
import { import {
buildDictateReplyPrompt, buildDictateReplyPrompt,
buildDictateSelectPrompt, buildDictateSelectPrompt,
@@ -32,6 +34,14 @@ import {
DICTATE_MODULE_READ_STATE_TAG, DICTATE_MODULE_READ_STATE_TAG,
type DictateModuleReadPayload, type DictateModuleReadPayload,
} from "./read-module.js"; } from "./read-module.js";
import {
DICTATE_REMEDIATE_SCHEMA,
DICTATE_REMEDIATE_TAG,
formatRemediateChecklist,
parseDictateRemediate,
serializeDictateRemediate,
type DictateRemediateItem,
} from "./remediate.js";
import { resolveModuleForInsertTag } from "./insert-feedback.js"; import { resolveModuleForInsertTag } from "./insert-feedback.js";
import type { DictateChatTurn, DictateProduct } from "./types.js"; import type { DictateChatTurn, DictateProduct } from "./types.js";
import { isAllowedProductTag } from "./types.js"; import { isAllowedProductTag } from "./types.js";
@@ -55,6 +65,7 @@ export type PlannedRoundParams = {
listProducts: () => DictateProduct[]; listProducts: () => DictateProduct[];
readTag: (tag: string) => string | undefined; readTag: (tag: string) => string | undefined;
writeTag: (tag: string, content: string) => void; writeTag: (tag: string, content: string) => void;
deleteTag?: (tag: string) => boolean;
prepareOpening?: () => void; prepareOpening?: () => void;
/** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */ /** 本轮刚写入 tag 的能力回复口径(探测/原则/自评关注) */
lookupInsertFeedback?: (tag: string) => string | undefined; lookupInsertFeedback?: (tag: string) => string | undefined;
@@ -68,6 +79,8 @@ export type PlannedRoundParams = {
recipeName?: string; recipeName?: string;
recipeBrief?: string; recipeBrief?: string;
systemPrompt?: string; systemPrompt?: string;
/** 更早对话保留轮数;默认 2,见 resolveDictateHistoryTurns */
historyTurns?: number;
}; };
type PlanInsert = { type PlanInsert = {
@@ -87,10 +100,7 @@ type SelectPlan = {
}; };
type ItemFailure = { tag: string; error: string }; type ItemFailure = { tag: string; error: string; moduleId?: string; kind?: "insert" | "delete" };
const INSERT_ATTEMPTS = 4;
export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver { export function createPlannedDictateDriver(params: PlannedRoundParams): AgentDriver {
return { return {
@@ -106,15 +116,23 @@ async function runPlannedDictateRound(
apply: Apply, apply: Apply,
): Promise<{ text: string; modelCalls: number }> { ): Promise<{ text: string; modelCalls: number }> {
const turnId = randomUUID(); const turnId = randomUUID();
const historyTurns = resolveDictateHistoryTurns(params.historyTurns);
const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt(); const fillSystem = params.systemPrompt?.trim() || buildDictateSystemPrompt();
const remediations = parseDictateRemediate(params.handlers.readTag(DICTATE_REMEDIATE_TAG));
const remediateChecklist = formatRemediateChecklist(remediations.items);
const tailOpts = tailState(params); const tailOpts = tailState(params);
const selectTail = formatDictateRoundTail({ ...tailOpts, phase: "select" }); const selectTail = formatDictateRoundTail({
...tailOpts,
phase: "select",
checklist: remediateChecklist,
});
const selectCtx = buildDictateContext({ const selectCtx = buildDictateContext({
systemPrompt: buildDictateSelectPrompt(), systemPrompt: buildDictateSelectPrompt(),
products: params.handlers.listProducts(), products: params.handlers.listProducts(),
dialogue: params.dialogue, dialogue: params.dialogue,
phase: "select", phase: "select",
tail: selectTail, tail: selectTail,
historyTurns,
}); });
const selected = await params.llm.completeWithTools(selectCtx.messages, { const selected = await params.llm.completeWithTools(selectCtx.messages, {
caller: "dictate_agent", caller: "dictate_agent",
@@ -127,7 +145,12 @@ async function runPlannedDictateRound(
if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim()); if (selected.reasoning?.trim()) params.handlers.onThinkingDone?.(selected.reasoning.trim());
const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0]; const selectCall = selected.toolCalls.find((call) => call.name === "select_nodes") ?? selected.toolCalls[0];
const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params); const plan = parseSelect(selectCall ? safeJson(selectCall.arguments) : {}, params);
if (plan.clarify.length >= 2 && plan.inserts.length === 0 && plan.deletes.length === 0) { mergeRemediationsIntoPlan(plan, remediations.items, params);
if (
plan.clarify.length >= 2 &&
plan.inserts.length === 0 &&
plan.deletes.length === 0
) {
return { return {
modelCalls: 1, modelCalls: 1,
text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"), text: plan.clarify.map((item, index) => `${index + 1}. ${item}`).join("\n"),
@@ -150,10 +173,23 @@ async function runPlannedDictateRound(
); );
if (openings.length > 0) params.handlers.prepareOpening?.(); if (openings.length > 0) params.handlers.prepareOpening?.();
const injected = injectGroup(params, [...rest, ...openings]); const injected = injectGroup(params, [...rest, ...openings]);
const priorFailures = remediations.items.filter((item) =>
[...rest, ...openings].some((row) => row.tag === item.tag) ||
plan.deletes.includes(item.tag),
);
const tail = formatDictateRoundTail({ const tail = formatDictateRoundTail({
...tailOpts, ...tailOpts,
phase: "fill", phase: "fill",
checklist: formatChecklist([...rest, ...openings], plan.deletes, []), checklist: formatChecklist(
[...rest, ...openings],
plan.deletes,
priorFailures.map((item) => ({
tag: item.tag,
error: item.error,
moduleId: item.moduleId,
kind: item.kind,
})),
),
}); });
const ctx = buildDictateContext({ const ctx = buildDictateContext({
systemPrompt: fillSystem, systemPrompt: fillSystem,
@@ -162,8 +198,8 @@ async function runPlannedDictateRound(
phase: "fill", phase: "fill",
injected: injected.text, injected: injected.text,
tail, tail,
historyTurns,
}); });
const messages: ChatMessage[] = [...ctx.messages];
const passed = new Set<string>(); const passed = new Set<string>();
const pending = new Set([...rest, ...openings].map((item) => item.tag)); const pending = new Set([...rest, ...openings].map((item) => item.tag));
const pendingDeletes = new Set(plan.deletes); const pendingDeletes = new Set(plan.deletes);
@@ -172,40 +208,47 @@ async function runPlannedDictateRound(
let lastProse = ""; let lastProse = "";
let modelCalls = 1; let modelCalls = 1;
for (let attempt = 0; attempt < INSERT_ATTEMPTS && (pending.size > 0 || pendingDeletes.size > 0); attempt++) { // 单次 fill:失败记入待补,留给下轮,不再同轮 fill-retry。
const step = attempt === 0 ? "fill" : "fill-retry"; if (pending.size > 0 || pendingDeletes.size > 0) {
const filled = await params.llm.completeWithTools(messages, { const filled = await params.llm.completeWithTools(ctx.messages, {
caller: "dictate_agent", caller: "dictate_agent",
label: attempt === 0 ? "对话落盘 填写" : "对话落盘 重写", label: "对话落盘 填写",
tools: DICTATE_WRITE_LOOP_TOOLS, tools: DICTATE_WRITE_LOOP_TOOLS,
toolChoice: "required", toolChoice: "required",
usageDetail: { usageDetail: {
...detail(turnId, step, attempt, ctx.segmentChars), ...detail(turnId, "fill", 0, ctx.segmentChars),
plannedTags: [...pending, ...pendingDeletes], plannedTags: [...pending, ...pendingDeletes],
}, },
usageSink: sinkFor(turnId, step), usageSink: sinkFor(turnId, "fill"),
}); });
modelCalls += 1; modelCalls += 1;
if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim()); if (filled.reasoning?.trim()) params.handlers.onThinkingDone?.(filled.reasoning.trim());
const prose = filled.content?.trim() ?? ""; const prose = filled.content?.trim() ?? "";
if (prose) lastProse = prose; if (prose) lastProse = prose;
const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({ const calls: DriverToolCall[] = filled.toolCalls.map((call, index) => ({
id: call.id || `call-${attempt}-${index}`, id: call.id || `call-0-${index}`,
name: call.name, name: call.name,
arguments: call.arguments, arguments: call.arguments,
})); }));
if (calls.length === 0) break; if (calls.length > 0) {
const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors); const settled = await settleWriteStep(params, apply, calls, passed, pending, pendingDeletes, errors);
messages.push(assistantToolMessage(filled.content, calls)); if (pending.size === 0 && pendingDeletes.size === 0) {
for (const item of settled.results) { replyFromTool = settled.reply;
messages.push({ role: "tool", content: item.content, tool_call_id: item.callId }); }
}
if (pending.size === 0 && pendingDeletes.size === 0) {
replyFromTool = settled.reply;
break;
} }
} }
const moduleByTag = new Map(
[...rest, ...openings].map((item) => [item.tag, item.moduleId] as const),
);
persistRemediations(params, {
pending,
pendingDeletes,
errors,
moduleByTag,
prior: remediations.items,
});
let reply = replyFromTool; let reply = replyFromTool;
if ( if (
!replyFromTool && !replyFromTool &&
@@ -224,7 +267,12 @@ async function runPlannedDictateRound(
); );
const pendingLines = [...blockedLines, ...failed]; const pendingLines = [...blockedLines, ...failed];
const text = pendingLines.length const text = pendingLines.length
? [reply, `尚未完成:${pendingLines.join("、")}。`].filter(Boolean).join("\n") ? [
reply,
`失败待补(下轮继续):${pendingLines.join("、")}。`,
]
.filter(Boolean)
.join("\n")
: reply; : reply;
return { text, modelCalls }; return { text, modelCalls };
} }
@@ -303,18 +351,6 @@ async function runLeanReply(
return filled.content?.trim() || fallbackProse; return filled.content?.trim() || fallbackProse;
} }
function assistantToolMessage(content: string | null, calls: DriverToolCall[]): ChatMessage {
return {
role: "assistant",
content,
tool_calls: calls.map((call) => ({
id: call.id,
type: "function" as const,
function: { name: call.name, arguments: call.arguments },
})),
};
}
async function settleWriteStep( async function settleWriteStep(
params: PlannedRoundParams, params: PlannedRoundParams,
apply: Apply, apply: Apply,
@@ -665,17 +701,97 @@ function canonicalProductTag(
function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string { function formatChecklist(group: PlanInsert[], deletes: string[], errors: ItemFailure[]): string {
const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean); const nodes = group.map((item) => item.moduleId || item.tag).filter(Boolean);
const failedTags = new Set(errors.map((item) => item.tag));
const pendingWrite = group.filter((item) => !failedTags.has(item.tag)).map((item) => item.tag);
const pendingDelete = deletes.filter((tag) => !failedTags.has(tag));
const lines = [ const lines = [
nodes.length ? `第一轮选定的节点:${nodes.join("、")}` : "", nodes.length ? `本轮选定的节点:${nodes.join("、")}` : "",
group.length ? `本轮写入:${group.map((item) => item.tag).join("、")}` : "", pendingWrite.length ? `待写:${pendingWrite.join("、")}` : "",
deletes.length ? `本轮删除:${deletes.join("、")}` : "", pendingDelete.length ? `待删:${pendingDelete.join("、")}` : "",
errors.length errors.length
? `只重写未成功项:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}` ? `失败待补:\n${errors.map((item) => `- ${item.tag}:${item.error}`).join("\n")}`
: "", : "",
"状态说明:待写/待删=本轮目标;失败待补=上轮或本轮未通过,须补上;已落的产物以黑板为准,不要无故整份重写。",
]; ];
return lines.filter(Boolean).join("\n"); return lines.filter(Boolean).join("\n");
} }
function mergeRemediationsIntoPlan(
plan: SelectPlan,
items: readonly DictateRemediateItem[],
params: PlannedRoundParams,
): void {
for (const item of items) {
if (item.kind === "delete") {
if (!plan.deletes.includes(item.tag) && isAllowedProductTag(item.tag)) {
plan.deletes.push(item.tag);
}
continue;
}
if (plan.inserts.some((row) => row.tag === item.tag || row.tag === `!${item.tag}`)) {
continue;
}
const canonical = canonicalProductTag(item.tag, item.moduleId ?? "", params.moduleCatalog);
const tag = canonical?.tag ?? item.tag;
if (!isAllowedProductTag(tag) && tag !== VARIABLE_CATALOG_TAG && tag !== VALUE_MAP_TAG) {
continue;
}
plan.inserts.push({
tag,
moduleId: canonical?.moduleId || item.moduleId || "",
entryIds: [],
reason: item.error ? `失败待补:${item.error}` : "失败待补",
});
}
}
function persistRemediations(
params: PlannedRoundParams,
state: {
pending: Set<string>;
pendingDeletes: Set<string>;
errors: Map<string, string>;
moduleByTag: Map<string, string>;
prior: readonly DictateRemediateItem[];
},
): void {
const priorByKey = new Map(
state.prior.map((item) => [`${item.kind}:${item.tag}`, item] as const),
);
const next: DictateRemediateItem[] = [];
for (const tag of state.pending) {
const prev = priorByKey.get(`insert:${tag}`);
next.push({
tag,
kind: "insert",
error: state.errors.get(tag) ?? prev?.error ?? "尚未通过",
...(state.moduleByTag.get(tag) || prev?.moduleId
? { moduleId: state.moduleByTag.get(tag) || prev?.moduleId }
: {}),
});
}
for (const tag of state.pendingDeletes) {
const prev = priorByKey.get(`delete:${tag}`);
next.push({
tag,
kind: "delete",
error: state.errors.get(tag) ?? prev?.error ?? "尚未通过",
});
}
if (next.length === 0) {
if (params.handlers.deleteTag) {
params.handlers.deleteTag(DICTATE_REMEDIATE_TAG);
} else {
params.handlers.writeTag(DICTATE_REMEDIATE_TAG, "");
}
return;
}
params.handlers.writeTag(
DICTATE_REMEDIATE_TAG,
serializeDictateRemediate({ schema: DICTATE_REMEDIATE_SCHEMA, items: next }),
);
}
function asItemList(value: unknown): unknown[] { function asItemList(value: unknown): unknown[] {
if (Array.isArray(value)) return value; if (Array.isArray(value)) return value;
const rec = asRecord(value); const rec = asRecord(value);
+4
View File
@@ -171,6 +171,8 @@ export async function runDictateTurn(params: {
moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>; moduleReadIndex?: ReadonlyMap<string, DictateModuleReadPayload>;
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */ /** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
repeatableFamilies?: ReadonlySet<string> | readonly string[]; repeatableFamilies?: ReadonlySet<string> | readonly string[];
/** 更早对话保留轮数;默认 2,可用 DICTATE_HISTORY_TURNS 覆盖 */
historyTurns?: number;
driver?: AgentDriver; driver?: AgentDriver;
}): Promise<DictateTurnResult> { }): Promise<DictateTurnResult> {
const driver = const driver =
@@ -182,6 +184,7 @@ export async function runDictateTurn(params: {
listProducts: params.handlers.listProducts, listProducts: params.handlers.listProducts,
readTag: params.handlers.readTag, readTag: params.handlers.readTag,
writeTag: params.handlers.writeTag, writeTag: params.handlers.writeTag,
deleteTag: params.handlers.deleteTag,
prepareOpening: params.handlers.prepareOpening, prepareOpening: params.handlers.prepareOpening,
lookupInsertFeedback: params.handlers.lookupInsertFeedback, lookupInsertFeedback: params.handlers.lookupInsertFeedback,
onThinkingDelta: params.handlers.onThinkingDelta, onThinkingDelta: params.handlers.onThinkingDelta,
@@ -194,6 +197,7 @@ export async function runDictateTurn(params: {
recipeName: params.recipeName, recipeName: params.recipeName,
recipeBrief: params.recipeBrief, recipeBrief: params.recipeBrief,
systemPrompt: params.systemPrompt, systemPrompt: params.systemPrompt,
historyTurns: params.historyTurns,
}); });
const wroteTags: string[] = []; const wroteTags: string[] = [];
const deletedTags: string[] = []; const deletedTags: string[] = [];
+4
View File
@@ -2,6 +2,7 @@ export {
DEFAULT_BUFFER_TARGET_CHARS, DEFAULT_BUFFER_TARGET_CHARS,
LISTEN_SERIAL_MODE_VALUE, LISTEN_SERIAL_MODE_VALUE,
LISTEN_PHASE_TAG, LISTEN_PHASE_TAG,
LISTEN_PLAY_LAYER_TAG,
LISTEN_THRILL_TAG, LISTEN_THRILL_TAG,
LISTEN_WORLD_TAG, LISTEN_WORLD_TAG,
LISTEN_STRUCTURE_TAG, LISTEN_STRUCTURE_TAG,
@@ -31,10 +32,13 @@ export {
writeListenPhase, writeListenPhase,
writeListenSections, writeListenSections,
writeListenSummaries, writeListenSummaries,
isListenPlayActive,
setListenPlayActive,
} from "./state.js"; } from "./state.js";
export { export {
runListenSerialTurn, runListenSerialTurn,
ensureListenPlayContent,
markListenConsumed, markListenConsumed,
type ListenChatTurn, type ListenChatTurn,
type ListenSerialTurnResult, type ListenSerialTurnResult,
+15 -1
View File
@@ -6,6 +6,7 @@ import {
LISTEN_CONSUMED_CHARS_TAG, LISTEN_CONSUMED_CHARS_TAG,
LISTEN_OUTLINE_TAG, LISTEN_OUTLINE_TAG,
LISTEN_PHASE_TAG, LISTEN_PHASE_TAG,
LISTEN_PLAY_LAYER_TAG,
LISTEN_SECTIONS_TAG, LISTEN_SECTIONS_TAG,
LISTEN_STRUCTURE_TAG, LISTEN_STRUCTURE_TAG,
LISTEN_SUMMARIES_TAG, LISTEN_SUMMARIES_TAG,
@@ -33,6 +34,7 @@ function parsePhase(raw: string | undefined): ListenPhase {
if ( if (
v === "intake" || v === "intake" ||
v === "awaiting_confirm" || v === "awaiting_confirm" ||
v === "ready" ||
v === "running" || v === "running" ||
v === "soft_stop" v === "soft_stop"
) { ) {
@@ -41,6 +43,14 @@ function parsePhase(raw: string | undefined): ListenPhase {
return "intake"; return "intake";
} }
export function isListenPlayActive(board: Blackboard): boolean {
return (board.getContentByTag(LISTEN_PLAY_LAYER_TAG) ?? "").trim() === "1";
}
export function setListenPlayActive(board: Blackboard, on: boolean): void {
writeTag(board, LISTEN_PLAY_LAYER_TAG, on ? "1" : "0");
}
export function readListenState(board: Blackboard): ListenSerialState { export function readListenState(board: Blackboard): ListenSerialState {
const bufferRaw = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG); const bufferRaw = board.getContentByTag(LISTEN_BUFFER_TARGET_TAG);
const bufferTargetChars = Math.max( const bufferTargetChars = Math.max(
@@ -122,9 +132,13 @@ export function unreadChars(state: ListenSerialState): number {
return Math.max(0, total - state.consumedChars); return Math.max(0, total - state.consumedChars);
} }
export function toListenView(state: ListenSerialState): ListenSerialView { export function toListenView(
state: ListenSerialState,
playActive = false,
): ListenSerialView {
return { return {
phase: state.phase, phase: state.phase,
playActive,
thrill: state.thrill, thrill: state.thrill,
world: state.world, world: state.world,
structure: state.structure, structure: state.structure,
+95 -22
View File
@@ -147,19 +147,104 @@ function formatOutlineLine(state: ListenSerialState): string {
/** /**
* 听书连载单轮:开书 / 确认 / 补货 / 插嘴 / 软停续波。 * 听书连载单轮:开书 / 确认 / 补货 / 插嘴 / 软停续波。
*/ */
/**
* 进入播放层时:若尚无大纲/正文,则生成到缓冲。
*/
export async function ensureListenPlayContent(params: {
llm: LlmProvider;
board: Blackboard;
}): Promise<ListenSerialTurnResult> {
const { llm, board } = params;
ensureBufferTarget(board);
let state = readListenState(board);
if (!state.thrill || !state.world) {
throw new Error("请先在创作里确认核心爽点与一句世界");
}
if (
state.phase === "running" ||
state.phase === "soft_stop"
) {
if (state.outline.length && unreadChars(state) > 0) {
return {
reply: [
"继续播放。",
formatOutlineLine(state),
formatUnreadLine(state),
]
.filter(Boolean)
.join("\n\n"),
softStop: state.phase === "soft_stop",
};
}
}
if (!state.outline.some((o) => o.status === "pending" || o.status === "done")) {
const outlined = await llmOutline(llm, state);
const outline = makeOutlineItems(outlined.titles, outlined.payoffTitle);
state = persistState(board, state, { outline, phase: "running" });
const filled = await fillBuffer(llm, board, state);
state = filled.state;
return {
reply: [
outlined.reply,
formatOutlineLine(state),
filled.notes.join("\n"),
formatUnreadLine(state),
filled.softStop
? "本段已兑现。可「下一波」或「收工」。"
: "不满意就直接一句意见,我改后面的走向。",
]
.filter(Boolean)
.join("\n\n"),
softStop: filled.softStop,
};
}
const filled = await fillBuffer(llm, board, state);
state = filled.state;
return {
reply: [
filled.notes.join("\n") || "已进入播放。",
formatOutlineLine(state),
formatUnreadLine(state),
]
.filter(Boolean)
.join("\n\n"),
softStop: filled.softStop || state.phase === "soft_stop",
};
}
export async function runListenSerialTurn(params: { export async function runListenSerialTurn(params: {
llm: LlmProvider; llm: LlmProvider;
board: Blackboard; board: Blackboard;
dialogue: ListenChatTurn[]; dialogue: ListenChatTurn[];
userText: string; userText: string;
/** 是否在播放层;创作层只允许开书/确认 */
inPlay?: boolean;
}): Promise<ListenSerialTurnResult> { }): Promise<ListenSerialTurnResult> {
const { llm, board, dialogue, userText } = params; const { llm, board, dialogue, userText } = params;
const inPlay = Boolean(params.inPlay);
ensureBufferTarget(board); ensureBufferTarget(board);
let state = readListenState(board); let state = readListenState(board);
const text = userText.trim(); const text = userText.trim();
// 创作层:已进播放态的正文不在这里改;ready 时可空提或改 brief
if (!inPlay && (state.phase === "running" || state.phase === "soft_stop")) {
return {
reply: "大纲与正文在「播放」里。请从目录点「播放」继续听/改向。",
softStop: state.phase === "soft_stop",
};
}
if (!inPlay && state.phase === "ready") {
if (!text || isConfirmText(text)) {
return {
reply:
"本局已确认。请点「播放」开始写大纲与正文;想改爽点直接说新需求。",
};
}
// 有新需求 → 重新提案
}
// —— 软停:收工 / 下一波 —— // —— 软停:收工 / 下一波 ——
if (state.phase === "soft_stop") { if (inPlay && state.phase === "soft_stop") {
if (isEndText(text)) { if (isEndText(text)) {
return { return {
reply: "本局收工。想再开可以新建作品,或再说「下一波」。", reply: "本局收工。想再开可以新建作品,或再说「下一波」。",
@@ -208,33 +293,21 @@ export async function runListenSerialTurn(params: {
structure: intake.structure, structure: intake.structure,
}); });
return { return {
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${intake.structure}\n\n回复「确认」开始生成大纲与正文。`, reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${intake.structure}\n\n回复「确认」锁定本局;确认后点「播放」写大纲与正文。`,
}; };
} }
const outlined = await llmOutline(llm, state); state = persistState(board, state, { phase: "ready" });
const outline = makeOutlineItems(outlined.titles, outlined.payoffTitle);
state = persistState(board, state, { outline, phase: "running" });
const filled = await fillBuffer(llm, board, state);
state = filled.state;
return { return {
reply: [ reply: `已确认本局。\n\n【核心爽点】${state.thrill}\n【一句世界】${state.world}\n【结构】${state.structure}\n\n请点「播放」开始生成大纲与正文。`,
outlined.reply,
formatOutlineLine(state),
filled.notes.join("\n"),
formatUnreadLine(state),
filled.softStop
? "本段已兑现。可「下一波」或「收工」。"
: "不满意某桥段就直接说一句意见,我会改后面的走向。",
]
.filter(Boolean)
.join("\n\n"),
softStop: filled.softStop,
}; };
} }
// —— 连载中:空输入/继续 = 补货;有意见 = 改向 —— // —— 播放层连载:继续 = 补货;有意见 = 改向 ——
if (state.phase === "running" || state.phase === "soft_stop") { if (inPlay && (state.phase === "running" || state.phase === "soft_stop" || state.phase === "ready")) {
if (state.phase === "ready" || !state.outline.length) {
return ensureListenPlayContent({ llm, board });
}
const isContinue = const isContinue =
!text || !text ||
/^(继续|接着写|补货|下一节)([!!。.\s]*)$/u.test(text); /^(继续|接着写|补货|下一节)([!!。.\s]*)$/u.test(text);
@@ -319,7 +392,7 @@ export async function runListenSerialTurn(params: {
structure: intake.structure || "三段式:铺垫冲突 → 对抗升级 → 爽点兑现", structure: intake.structure || "三段式:铺垫冲突 → 对抗升级 → 爽点兑现",
}); });
return { return {
reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${state.structure}\n\n回复「确认」开始生成大纲与正文。`, reply: `${intake.reply}\n\n【核心爽点】${intake.thrill}\n【一句世界】${intake.world}\n【结构】${state.structure}\n\n回复「确认」锁定本局;确认后点「播放」写大纲与正文。`,
}; };
} }
+6
View File
@@ -11,6 +11,8 @@ export const LISTEN_SECTIONS_TAG = "听书.正文节";
export const LISTEN_SUMMARIES_TAG = "听书.小总结"; export const LISTEN_SUMMARIES_TAG = "听书.小总结";
export const LISTEN_BUFFER_TARGET_TAG = "听书.缓冲目标字数"; export const LISTEN_BUFFER_TARGET_TAG = "听书.缓冲目标字数";
export const LISTEN_CONSUMED_CHARS_TAG = "听书.已消费字数"; export const LISTEN_CONSUMED_CHARS_TAG = "听书.已消费字数";
/** 播放层开关:创作只定 brief,播放层才写大纲/正文 */
export const LISTEN_PLAY_LAYER_TAG = "听书.播放层";
/** 默认未读/未听缓冲目标(字)——实现默认值,可后续调 */ /** 默认未读/未听缓冲目标(字)——实现默认值,可后续调 */
export const DEFAULT_BUFFER_TARGET_CHARS = 4000; export const DEFAULT_BUFFER_TARGET_CHARS = 4000;
@@ -22,6 +24,8 @@ export const MAX_SUMMARIES_IN_CONTEXT = 8;
export type ListenPhase = export type ListenPhase =
| "intake" | "intake"
| "awaiting_confirm" | "awaiting_confirm"
/** 创作已确认,等待进入播放写大纲正文 */
| "ready"
| "running" | "running"
| "soft_stop"; | "soft_stop";
@@ -59,6 +63,8 @@ export type ListenSerialState = {
export type ListenSerialView = { export type ListenSerialView = {
phase: ListenPhase; phase: ListenPhase;
/** 是否在播放层(大纲/正文/TTS) */
playActive: boolean;
thrill: string; thrill: string;
world: string; world: string;
structure: string; structure: string;
+97 -7
View File
@@ -187,10 +187,13 @@ import {
} from "../dictate/index.js"; } from "../dictate/index.js";
import { import {
LISTEN_SERIAL_MODE_VALUE, LISTEN_SERIAL_MODE_VALUE,
ensureListenPlayContent,
isListenPlayActive,
isListenSerialModeValue, isListenSerialModeValue,
markListenConsumed, markListenConsumed,
readListenState, readListenState,
runListenSerialTurn, runListenSerialTurn,
setListenPlayActive,
toListenView, toListenView,
type ListenSerialView, type ListenSerialView,
} from "../listen-serial/index.js"; } from "../listen-serial/index.js";
@@ -343,7 +346,8 @@ export type SessionView = {
/** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */ /** 正文组成数据契约里的区。格式化时按 id 填进对应 data-block。 */
presentationSlots?: Array<{ id: string; type: string }>; presentationSlots?: Array<{ id: string; type: string }>;
/** /**
* 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并,随真值刷新。 * 程序投影块(来自 变量.当前 等)。前端灌 present 时与消息正文合并。
* 每轮 toView 现算:旁观副 LLM 改表后会变,供主列气泡 patch 重渲染(主世界出稿时投影必然偏旧)。
*/ */
presentationProjection?: Record<string, unknown>; presentationProjection?: Record<string, unknown>;
/** 总管正在输出的思维链(流式,轮询用) */ /** 总管正在输出的思维链(流式,轮询用) */
@@ -504,6 +508,14 @@ export class SessionManager {
opts?: { useOpeningPersona?: boolean }, opts?: { useOpeningPersona?: boolean },
): SessionView { ): SessionView {
const s = this.require(sessionId); const s = this.require(sessionId);
if (this.isListenSerialMode(s)) {
if (stage === "play") {
throw new Error("听书请用「播放」入口(会生成大纲与正文)");
}
this.leaveListenPlay(s);
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
if (stage === "play") { if (stage === "play") {
s.runtime.prepareEnterPlay(); s.runtime.prepareEnterPlay();
if (!canEnterPlay(s.runtime.getSession())) { if (!canEnterPlay(s.runtime.getSession())) {
@@ -520,6 +532,62 @@ export class SessionManager {
return this.toView(sessionId); return this.toView(sessionId);
} }
/** 听书:进入播放层,必要时生成大纲与正文 */
async enterListenPlay(sessionId: string): Promise<SessionView> {
const s = this.require(sessionId);
if (!this.isListenSerialMode(s)) {
throw new Error("当前不是听书连载");
}
const board = s.runtime.getBlackboard();
const state = readListenState(board);
if (!state.thrill?.trim() || !state.world?.trim()) {
throw new Error("请先在创作里确认核心爽点与一句世界");
}
if (state.phase === "intake" || state.phase === "awaiting_confirm") {
throw new Error("请先回复「确认」锁定本局,再进入播放");
}
setListenPlayActive(board, true);
s.runtime.setLifecycleStage("play");
const llm = createDefaultMainAgentLlm(s.trackingRef, () =>
this.personaForManaged(s),
);
const result = await ensureListenPlayContent({ llm, board });
const keep = normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest,
);
const tokenUsage = s.trackingRef.current.pendingUsage;
s.trackingRef.current.pendingUsage = undefined;
s.trackingRef.current.pendingOutlines = undefined;
s.trackingRef.current.pendingContextTrace = 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 } : {}),
});
s.messages = pruneContextTraces(s.messages, keep);
s.runtime.enterDictateIntake(
result.softStop
? "本段已告一段落。输入「下一波」或「收工」,或一句意见改向。"
: "继续听/读,或说「继续」补货;不满意直接一句意见。",
);
if (s.bookId) this.persist(s);
return this.toView(sessionId);
}
private leaveListenPlay(s: ManagedSession): void {
setListenPlayActive(s.runtime.getBlackboard(), false);
s.runtime.setLifecycleStage("design");
s.runtime.enterDictateIntake(
"听书创作:可继续改爽点与设定;确认后点「播放」写大纲正文。",
);
}
/** 用户手改黑板 tag(创作期改设定/表;尊重后续 worker 的表 rev 合并) */ /** 用户手改黑板 tag(创作期改设定/表;尊重后续 worker 的表 rev 合并) */
writeUserBoardTag( writeUserBoardTag(
sessionId: string, sessionId: string,
@@ -892,11 +960,13 @@ export class SessionManager {
this.personaForManaged(s), this.personaForManaged(s),
); );
const dialogue = this.extractListenDialogue(s.messages); const dialogue = this.extractListenDialogue(s.messages);
const board = s.runtime.getBlackboard();
const result = await runListenSerialTurn({ const result = await runListenSerialTurn({
llm, llm,
board: s.runtime.getBlackboard(), board,
dialogue, dialogue,
userText, userText,
inPlay: isListenPlayActive(board),
}); });
const keep = normalizeContextTraceKeepLatest( const keep = normalizeContextTraceKeepLatest(
loadAppSettings().contextTraceKeepLatest, loadAppSettings().contextTraceKeepLatest,
@@ -4040,7 +4110,18 @@ export class SessionManager {
}), }),
}), }),
); );
const lifecycleStage = inferLifecycleStage(session); const listenMode = this.isListenSerialMode(s);
const listenState = listenMode
? readListenState(s.runtime.getBlackboard())
: null;
const listenPlay = listenMode
? isListenPlayActive(s.runtime.getBlackboard())
: false;
const lifecycleStage: LifecycleStage = listenMode
? listenPlay
? "play"
: "design"
: inferLifecycleStage(session);
const skillPackId = const skillPackId =
sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined); sessionSkillPackId(session) ?? (book ? bookSkillPackId(book) : undefined);
const workerSetYaml = readWorkerSetYaml(s.runtime.getBlackboard()); const workerSetYaml = readWorkerSetYaml(s.runtime.getBlackboard());
@@ -4056,6 +4137,13 @@ export class SessionManager {
s.runtime.getBlackboard().getContentByTag("设计.回复格式"); s.runtime.getBlackboard().getContentByTag("设计.回复格式");
const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw); const shellAdapt = parseShellAdaptationFromReplyFormat(replyFormatRaw);
const replyContract = parseReplyDataContract(replyFormatRaw); const replyContract = parseReplyDataContract(replyFormatRaw);
const listenPlayReady = Boolean(
listenState?.thrill &&
listenState.world &&
(listenState.phase === "ready" ||
listenState.phase === "running" ||
listenState.phase === "soft_stop"),
);
return { return {
id, id,
@@ -4075,11 +4163,13 @@ export class SessionManager {
dictateProducts: this.isDictateMode(s) dictateProducts: this.isDictateMode(s)
? this.listDictateProducts(s) ? this.listDictateProducts(s)
: undefined, : undefined,
listenSerial: this.isListenSerialMode(s) listenSerial: listenState
? toListenView(readListenState(s.runtime.getBlackboard())) ? toListenView(listenState, listenPlay)
: undefined, : undefined,
playLayerActive: isPlayLayerActive(session.slots), playLayerActive: listenMode
playReady: canEnterPlay(session), ? listenPlay
: isPlayLayerActive(session.slots),
playReady: listenMode ? listenPlayReady : canEnterPlay(session),
hasProduct: Boolean( hasProduct: Boolean(
s.bookId && s.bookId &&
this.listGameSnapshots(s.bookId).some((item) => item.kind === "instance"), this.listGameSnapshots(s.bookId).some((item) => item.kind === "instance"),
+12
View File
@@ -250,6 +250,18 @@ const server = createServer(async (req, res) => {
return; return;
} }
if (req.method === "POST" && sub === "/listen/play") {
try {
const view = await sessionManager.enterListenPlay(sessionId);
json(res, 200, view);
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "无法进入播放",
});
}
return;
}
if (req.method === "GET" && sub === "/listen/tts") { if (req.method === "GET" && sub === "/listen/tts") {
const sectionId = (url.searchParams.get("sectionId") ?? "").trim(); const sectionId = (url.searchParams.get("sectionId") ?? "").trim();
if (!sectionId) { if (!sectionId) {
+131 -24
View File
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
DICTATE_MODULE_READ_STATE_TAG, DICTATE_MODULE_READ_STATE_TAG,
DICTATE_REMEDIATE_TAG,
buildDictateContext, buildDictateContext,
clipDictateDialogue,
parseDictateModuleReadState, parseDictateModuleReadState,
parseDictateRemediate,
runDictateTurn, runDictateTurn,
type DictateModuleReadPayload, type DictateModuleReadPayload,
} from "../src/dictate/index.js"; } from "../src/dictate/index.js";
@@ -179,7 +182,37 @@ describe("dictate planned round", () => {
expect(selectBody.indexOf("第一句")).toBeLessThan(selectBody.indexOf("改成细雨")); expect(selectBody.indexOf("第一句")).toBeLessThan(selectBody.indexOf("改成细雨"));
}); });
it("injects the spec, stamps the read credential, and rewrites only the missing tag", async () => { it("clips earlier dialogue to the configured history turn window", () => {
const dialogue = [
{ role: "user" as const, text: "零" },
{ role: "assistant" as const, text: "零回" },
{ role: "user" as const, text: "一" },
{ role: "assistant" as const, text: "一回" },
{ role: "user" as const, text: "二" },
{ role: "assistant" as const, text: "二回" },
{ role: "user" as const, text: "本轮" },
];
expect(clipDictateDialogue(dialogue, 2).map((item) => item.text)).toEqual([
"一",
"一回",
"二",
"二回",
"本轮",
]);
const built = buildDictateContext({
systemPrompt: "规则",
products: [],
dialogue,
phase: "select",
historyTurns: 2,
});
const body = built.messages[1]?.content ?? "";
expect(body).not.toContain("【用户】\n零");
expect(body).toContain("一");
expect(body).toContain("本轮");
});
it("injects the spec, stamps the read credential, and writes on the single fill", async () => {
const products: Array<{ tag: string; content: string }> = []; const products: Array<{ tag: string; content: string }> = [];
const tags = new Map<string, string>(); const tags = new Map<string, string>();
const result = await runDictateTurn({ const result = await runDictateTurn({
@@ -189,7 +222,6 @@ describe("dictate planned round", () => {
inserts: [{ tag: "设计.氛围", module_id: "mood" }], inserts: [{ tag: "设计.氛围", module_id: "mood" }],
deletes: [], deletes: [],
}, },
{ products: [], reply: "还没写" },
{ {
products: [{ tag: "设计.氛围", content: "细雨" }], products: [{ tag: "设计.氛围", content: "细雨" }],
reply: "已写成细雨", reply: "已写成细雨",
@@ -223,6 +255,7 @@ describe("dictate planned round", () => {
expect(result.reply).toContain("已写成细雨"); expect(result.reply).toContain("已写成细雨");
const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG)); const state = parseDictateModuleReadState(tags.get(DICTATE_MODULE_READ_STATE_TAG));
expect(state.modules.mood).toBe("v-test"); expect(state.modules.mood).toBe("v-test");
expect(tags.get(DICTATE_REMEDIATE_TAG) ?? "").toBe("");
}); });
it("stops after the select step when the plan is a clarification", async () => { it("stops after the select step when the plan is a clarification", async () => {
@@ -501,7 +534,7 @@ describe("dictate planned round", () => {
expect(seen[0]).not.toContain("declare_variable"); expect(seen[0]).not.toContain("declare_variable");
expect(seen[1]).toContain("写成细雨"); expect(seen[1]).toContain("写成细雨");
expect(seen[1]).toContain("写氛围"); expect(seen[1]).toContain("写氛围");
expect(seen[1]).toContain("第一轮选定的节点:mood"); expect(seen[1]).toContain("本轮选定的节点:mood");
expect(seen[1]).toContain("还可能缺哪些节点"); expect(seen[1]).toContain("还可能缺哪些节点");
expect(result.wroteTags).toEqual(["设计.氛围"]); expect(result.wroteTags).toEqual(["设计.氛围"]);
expect(result.reply).toContain("可能还缺主角设定"); expect(result.reply).toContain("可能还缺主角设定");
@@ -617,16 +650,18 @@ describe("dictate planned round", () => {
}, },
}); });
expect(seen).toHaveLength(2);
expect(seen[1]).toContain("self_score 维度(这次写入必填,name 必须一致):短句"); expect(seen[1]).toContain("self_score 维度(这次写入必填,name 必须一致):短句");
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true);
expect(seen[2]).toContain("还有未通过");
expect(products).toHaveLength(0); expect(products).toHaveLength(0);
expect(result.reply).toContain("失败待补(下轮继续)");
expect(result.reply).toContain("self_score 必填"); expect(result.reply).toContain("self_score 必填");
expect(result.reply).toContain("短句"); expect(result.reply).toContain("短句");
expect(result.reply).not.toContain("未给出"); expect(result.reply).not.toContain("未给出");
const remediate = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG));
expect(remediate.items.map((item) => item.tag)).toEqual(["设计.氛围"]);
}); });
it("keeps the node table and refuses to rewrite a tag that already passed", async () => { it("defers failed tags to the next turn instead of same-turn fill-retry", async () => {
const narration: DictateModuleReadPayload = { const narration: DictateModuleReadPayload = {
...modulePayload, ...modulePayload,
module_id: "narration", module_id: "narration",
@@ -643,24 +678,11 @@ describe("dictate planned round", () => {
[ [
{ id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) }, { id: "a", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "细雨" }) },
{ id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.旁白", content: "旁白" }) }, { id: "b", name: "insert", arguments: JSON.stringify({ position: "设计.旁白", content: "旁白" }) },
{ id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "都写了" }) }, { id: "r1", name: "reply_user", arguments: JSON.stringify({ text: "氛围先落下" }) },
], ],
[
{ id: "a2", name: "insert", arguments: JSON.stringify({ position: "设计.氛围", content: "改掉细雨" }) },
{
id: "b2",
name: "insert",
arguments: JSON.stringify({
position: "设计.旁白",
content: "旁白",
self_score: { dims: [{ name: "一句", score: 4 }] },
}),
},
],
[{ id: "r2", name: "reply_user", arguments: JSON.stringify({ text: "旁白补上了" }) }],
]; ];
let index = 0; let index = 0;
await runDictateTurn({ const result = await runDictateTurn({
llm: { llm: {
async complete() { async complete() {
throw new Error("对话落盘走工具,不走纯文本完成"); throw new Error("对话落盘走工具,不走纯文本完成");
@@ -700,9 +722,94 @@ describe("dictate planned round", () => {
}, },
}); });
expect(seen[2]?.startsWith(seen[1] ?? "")).toBe(true); expect(seen).toHaveLength(2);
expect(seen[2]).toContain("写旁白"); expect(writes).toEqual(["设计.氛围:细雨"]);
expect(writes).toEqual(["设计.氛围:细雨", "设计.旁白:旁白"]); expect(result.reply).toContain("失败待补(下轮继续)");
expect(result.reply).toContain("设计.旁白");
const remediate = parseDictateRemediate(tags.get(DICTATE_REMEDIATE_TAG));
expect(remediate.items).toEqual([
expect.objectContaining({ tag: "设计.旁白", kind: "insert" }),
]);
});
it("merges prior失败待补 into the next fill even if select omits them", async () => {
const tags = new Map<string, string>([
[
DICTATE_REMEDIATE_TAG,
JSON.stringify({
schema: "dictate-remediate.v1",
items: [{ tag: "设计.氛围", moduleId: "mood", error: "self_score 必填", kind: "insert" }],
}),
],
]);
const products: Array<{ tag: string; content: string }> = [];
const seen: string[] = [];
const steps = [
{ nodes: [{ module_id: "narration" }] },
{
products: [
{ tag: "设计.氛围", content: "细雨" },
{ tag: "设计.旁白", content: "旁白" },
],
reply: "都补上了",
},
];
let index = 0;
const result = await runDictateTurn({
llm: {
async complete() {
throw new Error("对话落盘走工具,不走纯文本完成");
},
async completeWithTools(messages) {
seen.push(messages.map((message) => message.content ?? "").join("\n"));
const body = steps[index++] ?? {};
return {
content: null,
toolCalls: toToolCalls(body, index === 1),
usage: { promptTokens: 10, completionTokens: 4, totalTokens: 14 },
};
},
},
dialogue: [{ role: "user", text: "继续" }],
moduleCatalog: {
modules: [
...catalog.modules,
{ id: "narration", name: "旁白", artifact: "设计.旁白", declaration: "旁白" },
],
} as ModuleCatalog,
moduleReadIndex: new Map([
["mood", modulePayload],
[
"narration",
{
...modulePayload,
module_id: "narration",
artifact: "设计.旁白",
task: "写旁白",
},
],
]),
handlers: {
listProducts: () => products,
writeProduct: (tag, content) => {
products.push({ tag, content });
},
deleteProduct: () => false,
readTag: (tag) => tags.get(tag),
writeTag: (tag, content) => {
tags.set(tag, content);
},
deleteTag: (tag) => tags.delete(tag),
clearDialogue: () => undefined,
},
});
expect(seen[0]).toContain("失败待补");
expect(seen[1]).toContain("设计.氛围");
expect(seen[1]).toContain("写氛围");
expect(result.wroteTags).toEqual(expect.arrayContaining(["设计.氛围", "设计.旁白"]));
expect(tags.has(DICTATE_REMEDIATE_TAG)).toBe(false);
expect(result.reply).toContain("都补上了");
}); });
it("puts reply_user guidance early in the fill prompt and does not mention a fallback reply step", async () => { it("puts reply_user guidance early in the fill prompt and does not mention a fallback reply step", async () => {
+3 -1
View File
@@ -50,8 +50,10 @@ describe("listen-serial state", () => {
expect(state.outline).toHaveLength(3); expect(state.outline).toHaveLength(3);
expect(state.outline[2]?.payoff).toBe(true); expect(state.outline[2]?.payoff).toBe(true);
expect(unreadChars(state)).toBe(100); expect(unreadChars(state)).toBe(100);
const view = toListenView(state); const view = toListenView(state, true);
expect(view.unreadChars).toBe(100); expect(view.unreadChars).toBe(100);
expect(view.playActive).toBe(true);
expect(toListenView(state).playActive).toBe(false);
}); });
it("applyRedirectCut drops unconsumed sections and keeps consumed", () => { it("applyRedirectCut drops unconsumed sections and keeps consumed", () => {
+106 -27
View File
@@ -134,7 +134,9 @@ export function canOfferSaveProduct(view) {
export function canOfferEnterPlay(view) { export function canOfferEnterPlay(view) {
if (!view || isPlayView(view)) return false; if (!view || isPlayView(view)) return false;
if (view.creationMode === "listen_serial") return false; if (view.creationMode === "listen_serial") {
return Boolean(view.playReady);
}
return canOfferSaveProduct(view) || Boolean(view.hasProduct); return canOfferSaveProduct(view) || Boolean(view.hasProduct);
} }
@@ -1618,10 +1620,23 @@ function syncCoordRailChrome() {
const title = document.getElementById("coord-rail-title"); const title = document.getElementById("coord-rail-title");
const products = coordRailMode === "products"; const products = coordRailMode === "products";
const listen = coordRailMode === "listen"; const listen = coordRailMode === "listen";
const listenPlay = listen && rail?.dataset.listenPlay === "1";
document.body.classList.toggle("coord-rail-collapsed", coordRailCollapsed); document.body.classList.toggle("coord-rail-collapsed", coordRailCollapsed);
document.body.dataset.coordRailMode = coordRailMode; document.body.dataset.coordRailMode = coordRailMode;
const shortLabel = listen ? "正文" : products ? "产物" : "对话"; const shortLabel = listen
const longLabel = listen ? "大纲与正文" : products ? "已持久化" : "此前对话"; ? listenPlay
? "正文"
: "本局"
: products
? "产物"
: "对话";
const longLabel = listen
? listenPlay
? "大纲与正文"
: "本局设定"
: products
? "已持久化"
: "此前对话";
if (toggleLabel) toggleLabel.textContent = shortLabel; if (toggleLabel) toggleLabel.textContent = shortLabel;
if (title) title.textContent = longLabel; if (title) title.textContent = longLabel;
if (toggle) { if (toggle) {
@@ -5736,10 +5751,11 @@ function getRollbackMessageId(messages, messageId) {
/** @returns {"speak"|"answer"|"review"|"busy"|"dictate"|"listen"|null} */ /** @returns {"speak"|"answer"|"review"|"busy"|"dictate"|"listen"|null} */
function resolveDesignSurface(view, loading) { function resolveDesignSurface(view, loading) {
// 听书:创作/播放都用同一壳;播放层右侧才写大纲与正文
if (view.creationMode === "listen_serial") return "listen";
if (isPlayView(view)) return null; if (isPlayView(view)) return null;
if (view.phase === "done") return null; if (view.phase === "done") return null;
// 听书 / 转述:主面始终是对话流;右侧为大纲正文或产物 // 转述:主面始终是对话流;右侧为产物
if (view.creationMode === "listen_serial") return "listen";
if (view.creationMode === "dictate") return "dictate"; if (view.creationMode === "dictate") return "dictate";
const busy = Boolean(loading || (view.phase === "running" && !view.waitingReason)); const busy = Boolean(loading || (view.phase === "running" && !view.waitingReason));
if (busy) return "busy"; if (busy) return "busy";
@@ -5973,20 +5989,24 @@ function renderSpeakWorkspace(view) {
title = displayCreationModeLabel("listen_serial"); title = displayCreationModeLabel("listen_serial");
const ls = view.listenSerial; const ls = view.listenSerial;
const phase = ls?.phase; const phase = ls?.phase;
if (phase === "awaiting_confirm") { if (ls?.playActive) {
hint = "回复「确认」开始生成大纲与正文;或继续改需求。"; if (phase === "soft_stop") {
} else if (phase === "soft_stop") { hint = "本段已兑现。输入「下一波」或「收工」,或一句意见改向。";
hint = "本段已兑现。输入「下一波」或「收工」,或一句意见改向。"; } else {
} else if (phase === "running") { hint =
hint = view.hints?.[0] ||
view.hints?.[0] || wr.message ||
wr.message || "说「继续」补货;不满意直接一句意见改后面。";
"说「继续」补货;不满意直接一句意见改后面。"; }
} else if (phase === "awaiting_confirm") {
hint = "回复「确认」锁定本局;确认后点「播放」写大纲与正文。";
} else if (phase === "ready") {
hint = "本局已确认。点「播放」生成大纲与正文;也可继续改爽点。";
} else { } else {
hint = hint =
view.hints?.[0] || view.hints?.[0] ||
wr.message || wr.message ||
"用几句话描述题材与爽点;右侧是大纲与正文。"; "用几句话描述题材与爽点;确认后进「播放」写大纲与正文。";
} }
} else if (view.creationMode === "dictate") { } else if (view.creationMode === "dictate") {
title = displayCreationModeLabel("dictate"); title = displayCreationModeLabel("dictate");
@@ -6029,7 +6049,13 @@ function renderSpeakWorkspace(view) {
} }
<button type="button" class="btn${ <button type="button" class="btn${
canOfferSaveProduct(view) ? "" : " btn-primary" canOfferSaveProduct(view) ? "" : " btn-primary"
}" data-enter-play>${view.hasProduct ? "用产物开玩" : "开玩"}</button> }" data-enter-play>${
view.creationMode === "listen_serial"
? "播放"
: view.hasProduct
? "用产物开玩"
: "开玩"
}</button>
</div>` </div>`
: ""; : "";
return `<section class="workspace-intent"> return `<section class="workspace-intent">
@@ -6174,7 +6200,7 @@ function fillCoordRailProducts(view) {
function renderListenSerialRail(view) { function renderListenSerialRail(view) {
const ls = view.listenSerial; const ls = view.listenSerial;
if (!ls) { if (!ls) {
return `<p class="coord-empty">确认开书后,这里显示大纲与正文缓冲。</p>`; return `<p class="coord-empty">开书后,这里显示本局爽点与世界。</p>`;
} }
const brief = [ const brief = [
ls.thrill ? `<p class="listen-brief"><strong>爽点</strong> ${esc(ls.thrill)}</p>` : "", ls.thrill ? `<p class="listen-brief"><strong>爽点</strong> ${esc(ls.thrill)}</p>` : "",
@@ -6183,6 +6209,28 @@ function renderListenSerialRail(view) {
? `<p class="listen-brief"><strong>结构</strong> ${esc(ls.structure)}</p>` ? `<p class="listen-brief"><strong>结构</strong> ${esc(ls.structure)}</p>`
: "", : "",
].join(""); ].join("");
// 创作层:只展示 brief,大纲/正文/TTS 在播放层
if (!ls.playActive) {
const ready = Boolean(view.playReady);
const phaseHint =
ls.phase === "awaiting_confirm"
? "回复「确认」锁定本局。"
: ls.phase === "ready"
? "本局已确认,可进播放写大纲与正文。"
: "先定爽点与世界,确认后再播放。";
return `<div class="listen-rail listen-rail--design">
<p class="listen-meta">创作 · 阶段 ${esc(ls.phase)}</p>
${brief || `<p class="coord-empty">尚无本局设定</p>`}
<p class="listen-rail-hint">${esc(phaseHint)}</p>
<div class="listen-rail-actions">
<button type="button" class="btn-sm btn-primary" data-enter-play ${
ready ? "" : "disabled"
} title="${ready ? "进入播放,生成大纲与正文" : "确认本局后可播放"}">播放</button>
</div>
</div>`;
}
const outline = (ls.outline ?? []) const outline = (ls.outline ?? [])
.map((o) => { .map((o) => {
const mark = o.status === "done" ? "✓" : o.status === "cut" ? "×" : "·"; const mark = o.status === "done" ? "✓" : o.status === "cut" ? "×" : "·";
@@ -6206,12 +6254,13 @@ function renderListenSerialRail(view) {
const consumed = Number(ls.consumedChars) || 0; const consumed = Number(ls.consumedChars) || 0;
const total = (ls.sections ?? []).reduce((n, s) => n + String(s.text || "").length, 0); const total = (ls.sections ?? []).reduce((n, s) => n + String(s.text || "").length, 0);
const hasSections = (ls.sections ?? []).length > 0; const hasSections = (ls.sections ?? []).length > 0;
return `<div class="listen-rail"> return `<div class="listen-rail listen-rail--play">
<p class="listen-meta">未读约 ${unread} 字 / 缓冲目标 ${target} 字 · 阶段 ${esc(ls.phase)} · Edge TTS</p> <p class="listen-meta">未读约 ${unread} 字 / 缓冲目标 ${target} 字 · 阶段 ${esc(ls.phase)} · Edge TTS</p>
${brief} ${brief}
${outline ? `<ul class="listen-outline">${outline}</ul>` : ""} ${outline ? `<ul class="listen-outline">${outline}</ul>` : `<p class="coord-empty">正在准备大纲…</p>`}
<div class="listen-sections">${sections || `<p class="coord-empty">尚无正文</p>`}</div> <div class="listen-sections">${sections || `<p class="coord-empty">尚无正文</p>`}</div>
<div class="listen-rail-actions"> <div class="listen-rail-actions">
<button type="button" class="btn-sm" data-listen-leave-design="1" title="回到创作改爽点">回创作</button>
<button type="button" class="btn-sm" data-listen-tts-chain="1" ${hasSections ? "" : "disabled"} title="从第一节连续播放">连续听</button> <button type="button" class="btn-sm" data-listen-tts-chain="1" ${hasSections ? "" : "disabled"} title="从第一节连续播放">连续听</button>
<button type="button" class="btn-sm" data-listen-tts-stop="1" title="停止播放">停</button> <button type="button" class="btn-sm" data-listen-tts-stop="1" title="停止播放">停</button>
<button type="button" class="btn-sm" data-listen-consume="all" ${total <= consumed ? "disabled" : ""}>标记全部已读</button> <button type="button" class="btn-sm" data-listen-consume="all" ${total <= consumed ? "disabled" : ""}>标记全部已读</button>
@@ -6229,12 +6278,24 @@ function fillCoordRailListen(view, handlers) {
coordRailMode = "listen"; coordRailMode = "listen";
coordRailCollapsed = false; coordRailCollapsed = false;
} }
const playActive = Boolean(view.listenSerial?.playActive);
coordRail.dataset.listenPlay = playActive ? "1" : "0";
coordRail.hidden = false; coordRail.hidden = false;
if (drawerBody) { if (drawerBody) {
drawerBody.innerHTML = renderListenSerialRail(view); drawerBody.innerHTML = renderListenSerialRail(view);
if (!drawerBody.dataset.listenConsumeWired) { if (!drawerBody.dataset.listenConsumeWired) {
drawerBody.dataset.listenConsumeWired = "1"; drawerBody.dataset.listenConsumeWired = "1";
drawerBody.addEventListener("click", (e) => { drawerBody.addEventListener("click", (e) => {
const enterBtn = e.target.closest("[data-enter-play]");
if (enterBtn && drawerBody.contains(enterBtn) && !enterBtn.disabled) {
handlers?.onEnterPlay?.();
return;
}
const leaveBtn = e.target.closest("[data-listen-leave-design]");
if (leaveBtn && drawerBody.contains(leaveBtn)) {
handlers?.onLeaveListenPlay?.();
return;
}
const stopBtn = e.target.closest("[data-listen-tts-stop]"); const stopBtn = e.target.closest("[data-listen-tts-stop]");
if (stopBtn && drawerBody.contains(stopBtn)) { if (stopBtn && drawerBody.contains(stopBtn)) {
handlers?.onListenStop?.(); handlers?.onListenStop?.();
@@ -6270,8 +6331,14 @@ function fillCoordRailListen(view, handlers) {
} }
const n = view.listenSerial?.sections?.length ?? 0; const n = view.listenSerial?.sections?.length ?? 0;
if (drawerCount) { if (drawerCount) {
drawerCount.textContent = n ? String(n) : ""; if (playActive) {
drawerCount.title = n ? `${n} 节正文` : "尚无正文"; drawerCount.textContent = n ? String(n) : "";
drawerCount.title = n ? `${n} 节正文` : "尚无正文";
} else {
const hasBrief = Boolean(view.listenSerial?.thrill || view.listenSerial?.world);
drawerCount.textContent = hasBrief ? "·" : "";
drawerCount.title = hasBrief ? "本局设定" : "尚无本局设定";
}
} }
syncCoordRailChrome(); syncCoordRailChrome();
} }
@@ -6282,17 +6349,19 @@ function fillCoordRail(visible, view, handlers) {
const drawerCount = document.getElementById("coord-drawer-count"); const drawerCount = document.getElementById("coord-drawer-count");
if (!coordRail) return; if (!coordRail) return;
if (view.creationMode === "listen_serial" && !isPlayView(view)) { if (view.creationMode === "listen_serial") {
fillCoordRailListen(view, handlers); fillCoordRailListen(view, handlers);
return; return;
} }
// 转述产物栏只在创作层;游玩层与配方开玩一样走回合历史 // 转述产物栏只在创作层;游玩层与配方开玩一样走回合历史
if (view.creationMode === "dictate" && !isPlayView(view)) { if (view.creationMode === "dictate" && !isPlayView(view)) {
delete coordRail.dataset.listenPlay;
fillCoordRailProducts(view); fillCoordRailProducts(view);
return; return;
} }
delete coordRail.dataset.listenPlay;
if (coordRailMode !== "history") { if (coordRailMode !== "history") {
coordRailMode = "history"; coordRailMode = "history";
coordRailCollapsed = true; coordRailCollapsed = true;
@@ -6553,9 +6622,13 @@ export function renderMessageFeed(view, loading, handlers = {}) {
surface === "dictate" && view.selectedRecipe?.name surface === "dictate" && view.selectedRecipe?.name
? esc(view.selectedRecipe.name) ? esc(view.selectedRecipe.name)
: ""; : "";
const kicker = displayCreationModeLabel( const listenPlay = surface === "listen" && Boolean(view.listenSerial?.playActive);
surface === "listen" ? "listen_serial" : "dictate", const kicker =
); surface === "listen"
? listenPlay
? "听书播放"
: displayCreationModeLabel("listen_serial")
: displayCreationModeLabel("dictate");
const head = recipeName const head = recipeName
? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${kicker}</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>` ? `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${kicker}</span><span class="dictate-stage-recipe">配方 · ${recipeName}</span></header>`
: `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${kicker}</span></header>`; : `<header class="dictate-stage-bar"><span class="workspace-surface-kicker">${kicker}</span></header>`;
@@ -6566,7 +6639,9 @@ export function renderMessageFeed(view, loading, handlers = {}) {
appendMessagesToFeed(dictateFeed, visible, view, handlers, activeQuestions, { appendMessagesToFeed(dictateFeed, visible, view, handlers, activeQuestions, {
emptyText: emptyText:
surface === "listen" surface === "listen"
? "用几句话描述题材与爽点。确认后我会写大纲和正文;右侧可看缓冲与大纲。" ? listenPlay
? "右侧是大纲与正文。说「继续」补货,或一句意见改向。"
: "用几句话描述题材与爽点。确认本局后点「播放」写大纲与正文。"
: "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。", : "直接说你想要的体验与设定。我会整理进右侧产物,并在回复里跟你确认、建议补充。",
loading, loading,
}); });
@@ -6578,7 +6653,11 @@ export function renderMessageFeed(view, loading, handlers = {}) {
const label = const label =
live?.label ?? live?.label ??
view.focus?.action ?? view.focus?.action ??
(surface === "listen" ? "连载中" : "整理中"); (surface === "listen"
? listenPlay
? "写稿中"
: "定局中"
: "整理中");
pending.innerHTML = ` pending.innerHTML = `
<div class="msg-bubble"> <div class="msg-bubble">
<header class="msg-head"><span class="msg-tag">${esc(label)}</span><span class="msg-live-indicator">流式输出中</span>${renderRetryRunButton()}</header> <header class="msg-head"><span class="msg-tag">${esc(label)}</span><span class="msg-live-indicator">流式输出中</span>${renderRetryRunButton()}</header>
+175 -42
View File
@@ -10,7 +10,12 @@ import {
} from "./agent-ui.js"; } from "./agent-ui.js";
import { downloadMarkdown, sessionToMarkdown } from "./export.js"; import { downloadMarkdown, sessionToMarkdown } from "./export.js";
import { renderIntakePanel } from "./intake-ui.js"; import { renderIntakePanel } from "./intake-ui.js";
import { displaySkillPackLabel, isFlowPlanReview, reviewComposerCopy } from "./display-labels.js"; import {
displayCreationModeLabel,
displaySkillPackLabel,
isFlowPlanReview,
reviewComposerCopy,
} from "./display-labels.js";
import { import {
clearQuestionCardState, clearQuestionCardState,
collectQuestionAnswers, collectQuestionAnswers,
@@ -236,6 +241,33 @@ function resolveUserTask(view, loading) {
const hasQuestions = Boolean(getActiveQuestions(view)?.questions?.length); const hasQuestions = Boolean(getActiveQuestions(view)?.questions?.length);
const moduleOpening = isModuleOpeningWaiting(view); const moduleOpening = isModuleOpeningWaiting(view);
if (view.creationMode === "listen_serial") {
const ls = view.listenSerial;
if (ls?.playActive) {
return {
id: "speak",
label: "播放",
title: ls.phase === "soft_stop" ? "本段已兑现" : "听书播放",
hint:
ls.phase === "soft_stop"
? "「下一波」或「收工」,或一句意见改向。"
: "说「继续」补货;不满意直接一句意见。",
};
}
const phase = ls?.phase;
return {
id: "speak",
label: "听书",
title: displayCreationModeLabel("listen_serial"),
hint:
phase === "awaiting_confirm"
? "回复「确认」锁定本局;确认后点「播放」。"
: phase === "ready"
? "点「播放」写大纲与正文;也可继续改爽点。"
: "用几句话描述题材与爽点。",
};
}
if (isPlayView(view)) { if (isPlayView(view)) {
return { return {
id: "speak", id: "speak",
@@ -492,6 +524,31 @@ function resolveComposer(view, loading) {
}; };
} }
if (view.creationMode === "listen_serial") {
const send = view.actions?.find((a) => a.type === "send_message");
const ls = view.listenSerial;
let placeholder = "描述你想写什么…";
if (ls?.playActive) {
placeholder =
ls.phase === "soft_stop"
? "下一波 / 收工 / 一句改向…"
: "继续 / 一句意见改向…";
} else if (ls?.phase === "awaiting_confirm") {
placeholder = "确认 / 或继续改需求…";
} else if (ls?.phase === "ready") {
placeholder = "继续改爽点,或点右侧「播放」…";
}
return {
mode: "input",
task,
taskTitle: task.title || displayCreationModeLabel("listen_serial"),
taskHint: task.hint,
placeholder: send?.placeholder ?? placeholder,
hint: null,
submitLabel: "发送",
};
}
if (isPlayView(view)) { if (isPlayView(view)) {
const send = view.actions?.find((a) => a.type === "send_message"); const send = view.actions?.find((a) => a.type === "send_message");
return { return {
@@ -1990,11 +2047,22 @@ function showEnterPicker(bookId, anchorEl) {
el.classList.remove("is-picking"); el.classList.remove("is-picking");
}); });
anchorEl.classList.add("is-picking"); anchorEl.classList.add("is-picking");
const book = books.find((b) => b.id === bookId);
const listen = bookCreationMode(book) === "listen_serial";
const designBtn = pop.querySelector("[data-enter=design]");
if (designBtn) designBtn.textContent = "创作";
const playBtn = pop.querySelector("[data-enter=play]"); const playBtn = pop.querySelector("[data-enter=play]");
const ready = bookPlayReady(bookId); const ready = bookPlayReady(bookId);
if (playBtn) { if (playBtn) {
playBtn.disabled = !ready; playBtn.textContent = listen ? "播放" : "游玩";
playBtn.title = ready ? "进入游玩" : "验收后可游玩"; playBtn.disabled = listen ? false : !ready;
playBtn.title = listen
? ready
? "进入播放,写大纲与正文"
: "确认本局后可播放(未确认会提示)"
: ready
? "进入游玩"
: "验收后可游玩";
} }
pop.hidden = false; pop.hidden = false;
const rect = anchorEl.getBoundingClientRect(); const rect = anchorEl.getBoundingClientRect();
@@ -2149,6 +2217,9 @@ function getNavBook() {
function catalogBookMeta(book) { function catalogBookMeta(book) {
if (bookCreationMode(book) === "listen_serial") { if (bookCreationMode(book) === "listen_serial") {
if (lastView?.bookId === book.id && lastView.playReady) {
return lastView.playLayerActive ? "播放中" : "可播放";
}
return "听书"; return "听书";
} }
const saves = playSavesByBook.get(book.id); const saves = playSavesByBook.get(book.id);
@@ -2582,7 +2653,6 @@ function renderBookList() {
for (const book of modeBooks) { for (const book of modeBooks) {
const selected = selectedBookIds.has(book.id); const selected = selectedBookIds.has(book.id);
const listen = bookCreationMode(book) === "listen_serial";
list.appendChild( list.appendChild(
renderExplorerRow({ renderExplorerRow({
name: book.title, name: book.title,
@@ -2597,10 +2667,6 @@ function renderBookList() {
return; return;
} }
e.stopPropagation(); e.stopPropagation();
if (listen) {
void openBook(book.id);
return;
}
if (enterPickerBookId === book.id) { if (enterPickerBookId === book.id) {
hideEnterPicker(); hideEnterPicker();
return; return;
@@ -2884,9 +2950,15 @@ async function openBookDesign(bookId) {
async function openCurrentPlay(bookId) { async function openCurrentPlay(bookId) {
hideEnterPicker(); hideEnterPicker();
const instanceId = playingInstanceIdForBook({ id: bookId }); const book = books.find((b) => b.id === bookId);
if (instanceId) ensureSpecExpanded(bookId, instanceId); const listen = bookCreationMode(book) === "listen_serial";
else navigateToBook(bookId); if (!listen) {
const instanceId = playingInstanceIdForBook({ id: bookId });
if (instanceId) ensureSpecExpanded(bookId, instanceId);
else navigateToBook(bookId);
} else {
navigateToBook(bookId);
}
const alreadyHere = const alreadyHere =
activeBookId === bookId && activeBookId === bookId &&
isPlayNow() && isPlayNow() &&
@@ -2900,6 +2972,11 @@ async function openCurrentPlay(bookId) {
if (activeBookId !== bookId || !sessionId) { if (activeBookId !== bookId || !sessionId) {
await openBook(bookId); await openBook(bookId);
} }
if (listen) {
activePlaySaveId = null;
await enterListenPlay();
return;
}
if (activePlaySaveId && lastView?.playLayerActive) { if (activePlaySaveId && lastView?.playLayerActive) {
try { try {
const data = await api( const data = await api(
@@ -2923,10 +3000,15 @@ function renderHeader(view, loading) {
document.title = `${title} · Writing Agent`; document.title = `${title} · Writing Agent`;
$("status-dot").className = `status-dot ${st.cls}`; $("status-dot").className = `status-dot ${st.cls}`;
$("status-text").textContent = st.text; $("status-text").textContent = st.text;
const listenMode = view.creationMode === "listen_serial";
const showSavePlay = Boolean( const showSavePlay = Boolean(
view.bookId && view.playReady && view.lifecycleStage === "play" && view.playLayerActive, !listenMode &&
view.bookId &&
view.playReady &&
view.lifecycleStage === "play" &&
view.playLayerActive,
); );
const showSaveInstance = Boolean(view.bookId && view.playReady); const showSaveInstance = Boolean(!listenMode && view.bookId && view.playReady);
const btnPlay = $("btn-save-play"); const btnPlay = $("btn-save-play");
if (btnPlay) btnPlay.hidden = !showSavePlay; if (btnPlay) btnPlay.hidden = !showSavePlay;
const btnInst = $("btn-save-instance"); const btnInst = $("btn-save-instance");
@@ -3030,6 +3112,23 @@ function isStaleUiRequest(seq) {
return seq !== inflightSeq; return seq !== inflightSeq;
} }
/** 真值 / 程序投影指纹:旁观改表后即使消息条数不变也要刷正文壳 */
function sessionTruthSig(view) {
const rows = view?.playAux?.table?.rows;
if (Array.isArray(rows) && rows.length) {
return rows.map((r) => `${r.key}:${r.rev ?? 0}`).join(",");
}
const projected = view?.presentationProjection;
if (projected && typeof projected === "object") {
try {
return `p:${JSON.stringify(projected).length}:${Object.keys(projected).sort().join(",")}`;
} catch {
return "p:?";
}
}
return "";
}
function sessionStructuralSig(view) { function sessionStructuralSig(view) {
const messages = view?.messages ?? []; const messages = view?.messages ?? [];
const last = messages[messages.length - 1]; const last = messages[messages.length - 1];
@@ -3042,6 +3141,8 @@ function sessionStructuralSig(view) {
view?.phase ?? "", view?.phase ?? "",
view?.waitingReason?.kind ?? "", view?.waitingReason?.kind ?? "",
view?.runActive ? "1" : "0", view?.runActive ? "1" : "0",
// 副 LLM 填表落后于主世界出稿;表 rev / 投影变了必须重渲染 present
sessionTruthSig(view),
].join("|"); ].join("|");
} }
@@ -3173,13 +3274,23 @@ function startLivePoll() {
return; return;
} }
// 结构未变时仍并入真值投影,避免 lastView 卡在旁观改表前的旧壳数据
const truthChanged = sessionTruthSig(view) !== sessionTruthSig(lastView);
lastView = { lastView = {
...lastView, ...lastView,
liveStream: view.liveStream, liveStream: view.liveStream,
agentThinking: view.agentThinking, agentThinking: view.agentThinking,
runActive: view.runActive, runActive: view.runActive,
dictateProducts: view.dictateProducts ?? lastView.dictateProducts, dictateProducts: view.dictateProducts ?? lastView.dictateProducts,
playAux: view.playAux ?? lastView.playAux,
presentationProjection:
view.presentationProjection ?? lastView.presentationProjection,
}; };
if (truthChanged) {
livePollStructuralSig = sessionStructuralSig(view);
renderSession(view, true);
return;
}
updateLiveStreamPanel(view); updateLiveStreamPanel(view);
} catch { } catch {
livePollFailCount += 1; livePollFailCount += 1;
@@ -3247,6 +3358,7 @@ function renderSession(view, loading = false) {
onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }), onSpawnCreationStep: (moduleName) => runAction("spawn_step", { moduleName }),
onLeaveCreationStep: () => runAction("leave_step"), onLeaveCreationStep: () => runAction("leave_step"),
onEnterPlay: () => enterPlayNow(), onEnterPlay: () => enterPlayNow(),
onLeaveListenPlay: () => void setLifecycle("design"),
onSaveProduct: () => void saveCurrentInstance(), onSaveProduct: () => void saveCurrentInstance(),
onPatchTable: (tag, cells) => void patchPlayTable(tag, cells), onPatchTable: (tag, cells) => void patchPlayTable(tag, cells),
onSelectOpening: (index, usePersona) => void selectPlayOpening(index, usePersona), onSelectOpening: (index, usePersona) => void selectPlayOpening(index, usePersona),
@@ -3546,6 +3658,10 @@ async function runAction(action, extra = {}) {
} }
function enterPlayNow() { function enterPlayNow() {
if (lastView?.creationMode === "listen_serial") {
void enterListenPlay();
return;
}
if (activeBookId) { if (activeBookId) {
void openCurrentPlay(activeBookId); void openCurrentPlay(activeBookId);
return; return;
@@ -3555,6 +3671,25 @@ function enterPlayNow() {
})(); })();
} }
async function enterListenPlay() {
if (!sessionId) return;
const seq = beginUiRequest();
try {
renderSession(lastView, true);
const view = await api(
`/api/sessions/${encodeURIComponent(sessionId)}/listen/play`,
{ method: "POST", body: "{}" },
);
if (isStaleUiRequest(seq)) return;
if (activeBookId) navigateToBook(activeBookId);
renderSession(view, false);
} catch (err) {
if (isStaleUiRequest(seq)) return;
alert(err.message || "无法进入播放");
if (lastView) renderSession({ ...lastView, hints: [err.message] }, false);
}
}
async function patchPlayTable(tag, cells) { async function patchPlayTable(tag, cells) {
if (!sessionId) return; if (!sessionId) return;
try { try {
@@ -3598,6 +3733,13 @@ async function selectPlayOpening(index, usePersona) {
async function setLifecycle(stage, opts) { async function setLifecycle(stage, opts) {
if (!sessionId) return; if (!sessionId) return;
if (lastView?.creationMode === "listen_serial") {
if (stage === "play") {
await enterListenPlay();
return;
}
// design:离开播放层
}
if (stage === lastView?.lifecycleStage) { if (stage === lastView?.lifecycleStage) {
if (stage === "design") return; if (stage === "design") return;
if ( if (
@@ -3608,7 +3750,13 @@ async function setLifecycle(stage, opts) {
return; return;
} }
} }
if (stage === "play" && !lastView?.playReady && !canOfferEnterPlay(lastView) && !lastView?.hasProduct) { if (
stage === "play" &&
lastView?.creationMode !== "listen_serial" &&
!lastView?.playReady &&
!canOfferEnterPlay(lastView) &&
!lastView?.hasProduct
) {
alert("请先保存产物,或完成收口后再开玩"); alert("请先保存产物,或完成收口后再开玩");
return; return;
} }
@@ -3629,7 +3777,9 @@ async function setLifecycle(stage, opts) {
navigateToBook(activeBookId); navigateToBook(activeBookId);
} }
renderSession(view, false); renderSession(view, false);
if (activeBookId) void fetchPlaySavesForBook(activeBookId, true); if (activeBookId && lastView?.creationMode !== "listen_serial") {
void fetchPlaySavesForBook(activeBookId, true);
}
} catch (err) { } catch (err) {
alert(err.message || "无法切换阶段"); alert(err.message || "无法切换阶段");
if (lastView) renderSession(lastView, false); if (lastView) renderSession(lastView, false);
@@ -3657,26 +3807,16 @@ function resolveNewBookCreationMode(opts = {}) {
function openNewBookDialog(opts = {}) { function openNewBookDialog(opts = {}) {
$("input-book-title").value = ""; $("input-book-title").value = "";
const locked = resolveNewBookCreationMode(opts); const locked = resolveNewBookCreationMode(opts);
const modeField = $("field-creation-mode"); if (!locked) {
const modeSel = $("input-creation-mode"); alert("请先进入「对话落盘」或「听书连载」文件夹,再新建作品。");
return;
}
const lockedInput = $("input-creation-mode-locked"); const lockedInput = $("input-creation-mode-locked");
const desc = $("dialog-new-book-desc"); const desc = $("dialog-new-book-desc");
if (lockedInput) lockedInput.value = locked;
if (locked) { if (desc) {
if (modeField) modeField.hidden = true; desc.textContent = `将创建到「${creationModeFolderLabel(locked)}」,填写名称即可。`;
if (lockedInput) lockedInput.value = locked;
if (modeSel) modeSel.value = locked;
if (desc) {
desc.textContent = `将创建到「${creationModeFolderLabel(locked)}」,填写名称即可。`;
}
} else {
// 仍在目录根:须先选方式,或临时露出下拉
if (modeField) modeField.hidden = false;
if (lockedInput) lockedInput.value = "";
if (modeSel) modeSel.value = "dictate";
if (desc) desc.textContent = "先选创作方式,再填写名称。";
} }
$("dialog-new-book").showModal(); $("dialog-new-book").showModal();
$("input-book-title").focus(); $("input-book-title").focus();
} }
@@ -3684,9 +3824,9 @@ function openNewBookDialog(opts = {}) {
async function createBook() { async function createBook() {
const title = $("input-book-title").value.trim() || "未命名作品"; const title = $("input-book-title").value.trim() || "未命名作品";
const locked = ($("input-creation-mode-locked")?.value || "").trim(); const locked = ($("input-creation-mode-locked")?.value || "").trim();
const fromNav = resolveNewBookCreationMode();
const creationMode = const creationMode =
locked === "listen_serial" || locked === "listen_serial" || fromNav === "listen_serial"
$("input-creation-mode")?.value === "listen_serial"
? "listen_serial" ? "listen_serial"
: "dictate"; : "dictate";
$("btn-create-book").disabled = true; $("btn-create-book").disabled = true;
@@ -4277,14 +4417,7 @@ $("lifecycle-toggle")?.addEventListener("click", (e) => {
if (stage === "play") enterPlayNow(); if (stage === "play") enterPlayNow();
else if (stage === "design") void setLifecycle("design"); else if (stage === "design") void setLifecycle("design");
}); });
$("btn-new-book")?.addEventListener("click", () => { $("btn-new-book")?.addEventListener("click", () => openNewBookDialog());
const locked = resolveNewBookCreationMode();
if (!locked && sidebarNav.level === "root") {
alert("请先进入「对话落盘」或「听书连载」文件夹,再新建作品。");
return;
}
openNewBookDialog(locked ? { presetMode: locked } : {});
});
$("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close()); $("btn-cancel-new")?.addEventListener("click", () => $("dialog-new-book").close());
$("book-enter-pop")?.addEventListener("click", (e) => { $("book-enter-pop")?.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
-8
View File
@@ -38,7 +38,6 @@
<nav class="main-app-nav" aria-label="应用"> <nav class="main-app-nav" aria-label="应用">
<div id="persona-picker-host" class="persona-picker-host" hidden></div> <div id="persona-picker-host" class="persona-picker-host" hidden></div>
<a href="/modules.html">技能</a> <a href="/modules.html">技能</a>
<a href="/shells.html">呈现壳</a>
<a href="/libraries.html">库</a> <a href="/libraries.html">库</a>
<a href="/stats.html">Token</a> <a href="/stats.html">Token</a>
<a href="/settings.html">设置</a> <a href="/settings.html">设置</a>
@@ -201,13 +200,6 @@
名称 名称
<input id="input-book-title" type="text" placeholder="未命名作品" maxlength="80" autofocus /> <input id="input-book-title" type="text" placeholder="未命名作品" maxlength="80" autofocus />
</label> </label>
<label class="field" id="field-creation-mode" hidden>
创作方式
<select id="input-creation-mode">
<option value="dictate" selected>对话落盘(可玩规格)</option>
<option value="listen_serial">听书连载(连续消费)</option>
</select>
</label>
<input type="hidden" id="input-creation-mode-locked" value="" /> <input type="hidden" id="input-creation-mode-locked" value="" />
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" class="btn" value="cancel" id="btn-cancel-new">取消</button> <button type="button" class="btn" value="cancel" id="btn-cancel-new">取消</button>
-1
View File
@@ -20,7 +20,6 @@
<nav class="st-nav" aria-label="主导航"> <nav class="st-nav" aria-label="主导航">
<a href="/">对话</a> <a href="/">对话</a>
<a href="/modules.html">技能</a> <a href="/modules.html">技能</a>
<a href="/shells.html">呈现壳</a>
<a href="/libraries.html" aria-current="page">库</a> <a href="/libraries.html" aria-current="page">库</a>
<a href="/stats.html">Token</a> <a href="/stats.html">Token</a>
<a href="/settings.html">设置</a> <a href="/settings.html">设置</a>
-1
View File
@@ -29,7 +29,6 @@
<nav class="st-nav" aria-label="主导航"> <nav class="st-nav" aria-label="主导航">
<a href="/">对话</a> <a href="/">对话</a>
<a href="/modules.html" aria-current="page">技能</a> <a href="/modules.html" aria-current="page">技能</a>
<a href="/shells.html">呈现壳</a>
<a href="/libraries.html">库</a> <a href="/libraries.html">库</a>
<a href="/stats.html">Token</a> <a href="/stats.html">Token</a>
<a href="/settings.html">设置</a> <a href="/settings.html">设置</a>
-1
View File
@@ -19,7 +19,6 @@
<nav class="st-nav" aria-label="主导航"> <nav class="st-nav" aria-label="主导航">
<a href="/">对话</a> <a href="/">对话</a>
<a href="/modules.html">技能</a> <a href="/modules.html">技能</a>
<a href="/shells.html">呈现壳</a>
<a href="/libraries.html">库</a> <a href="/libraries.html">库</a>
<a href="/stats.html">Token</a> <a href="/stats.html">Token</a>
<a href="/settings.html" aria-current="page">设置</a> <a href="/settings.html" aria-current="page">设置</a>
-320
View File
@@ -1,320 +0,0 @@
/* 呈现壳预览页 — 主工作面 = 壳画布;侧栏只负责换壳 */
html,
body.shells-page {
height: 100%;
overflow: hidden;
background: var(--st-bg, #f4f4f5);
}
.shells-page .st-shell.shells-shell {
max-width: none;
width: 100%;
align-items: stretch;
grid-template-columns: 220px minmax(0, 1fr);
}
.shells-page .st-rail {
width: auto;
position: static;
max-height: none;
overflow: auto;
min-height: 0;
align-self: stretch;
gap: 2px;
}
.st-rail-hint {
margin: 0 8px 10px;
font-size: 11px;
line-height: 1.45;
color: var(--st-faint, #a1a1aa);
}
.shells-list {
display: flex;
flex-direction: column;
gap: 2px;
flex: 0 0 auto;
}
.shells-list .st-rail-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 8px 10px;
line-height: 1.25;
}
.shells-list .shell-item-name {
font-size: 13px;
font-weight: 600;
color: inherit;
}
.shells-list .shell-item-task {
font-size: 11px;
font-weight: 400;
color: var(--st-faint, #a1a1aa);
}
.shells-list .st-rail-item.active .shell-item-task {
color: color-mix(in srgb, var(--st-accent, #2563eb) 65%, #71717a);
}
.shells-main {
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.shells-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 20px;
padding: 14px 24px 12px;
border-bottom: 1px solid var(--st-border, #e4e4e7);
background: var(--st-surface, #fff);
position: static;
flex-shrink: 0;
z-index: 3;
}
.shells-toolbar-block {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.shells-toolbar-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--st-faint, #a1a1aa);
}
.shells-toolbar-meta {
margin-left: auto;
align-items: flex-end;
text-align: right;
max-width: 280px;
}
.shells-toolbar-meta .shells-meta-title {
margin: 0;
font-size: 14px;
font-weight: 650;
color: var(--st-text, #18181b);
}
.shells-toolbar-meta .shells-meta-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--st-muted, #71717a);
line-height: 1.4;
}
.shells-tones {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.shells-tone-chip {
font: inherit;
font-size: 12px;
padding: 5px 10px;
border-radius: 6px;
border: 1px solid var(--st-border, #e4e4e7);
background: var(--st-surface, #fff);
cursor: pointer;
color: var(--st-muted, #71717a);
}
.shells-tone-chip:hover {
background: color-mix(in srgb, var(--st-surface, #fff) 62%, var(--st-bg, #f8f8f8));
color: var(--st-text, #18181b);
}
.shells-tone-chip:focus-visible {
outline: 2px solid var(--st-accent, #2563eb);
outline-offset: 2px;
}
.shells-tone-chip.active {
border-color: transparent;
background: var(--st-accent-soft, #dbeafe);
color: var(--st-accent, #2563eb);
font-weight: 600;
}
/* 主工作面:尽量大的阅读画布 */
.shells-preview-wrap {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
padding: 20px 24px 28px;
background: color-mix(in srgb, var(--st-bg) 82%, var(--st-border));
}
.shells-preview {
flex: 1 1 auto;
min-height: min-content;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid var(--st-border, #e4e4e7);
background: color-mix(in srgb, var(--st-surface) 62%, var(--st-bg));
overflow: visible;
}
.shells-preview .present-shell,
.shells-preview[data-shell="turn_panel"] .present-shell,
.shells-preview[data-shell="split_board"] .present-shell,
.shells-preview[data-shell="chapter_reader"] .present-shell {
max-width: none;
width: 100%;
margin: 0;
box-shadow: none;
}
.shells-preview .present-shell-badge {
display: none;
}
.shells-details {
flex-shrink: 0;
margin: 0 24px 16px;
max-height: min(36%, 280px);
overflow: auto;
border: 1px solid var(--st-border, #e4e4e7);
border-radius: 8px;
background: var(--st-surface, #fff);
}
.shells-details > summary {
cursor: pointer;
padding: 12px 14px;
font-size: 13px;
font-weight: 600;
color: var(--st-muted, #71717a);
list-style: none;
}
.shells-details > summary::-webkit-details-marker {
display: none;
}
.shells-details > summary::before {
content: "▸ ";
color: var(--st-faint, #a1a1aa);
}
.shells-details[open] > summary::before {
content: "▾ ";
}
.shells-details[open] > summary {
border-bottom: 1px solid var(--st-border, #e4e4e7);
color: var(--st-text, #18181b);
}
.shells-details-body {
padding: 14px 16px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.shells-tweak-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.shells-tweak-grid .field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.shells-tweak-grid input[type="text"] {
font: inherit;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--st-border, #e4e4e7);
max-width: 320px;
}
.shells-help-list {
margin: 0;
padding-left: 1.15rem;
line-height: 1.55;
font-size: 13px;
color: var(--st-muted, #71717a);
}
@media (max-width: 800px) {
.shells-page .st-shell.shells-shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(7.5rem, 34%) minmax(0, 1fr);
}
.shells-page .st-rail {
position: static;
max-height: none;
width: auto;
overflow: auto;
align-items: flex-start;
align-content: flex-start;
border-right: none;
border-bottom: 1px solid var(--st-border, #e4e4e7);
}
.shells-list {
flex-direction: row;
flex-wrap: wrap;
}
.shells-list .st-rail-item {
width: auto;
max-width: 46%;
}
.st-rail-hint,
.st-rail-note {
display: none;
}
.shells-toolbar {
position: static;
}
.shells-toolbar-meta {
margin-left: 0;
text-align: left;
align-items: flex-start;
max-width: none;
width: 100%;
}
.shells-preview-wrap {
padding: 12px;
}
.shells-preview .present-shell {
max-width: 100%;
}
.shells-details {
margin: 0 12px 20px;
}
}
-74
View File
@@ -1,74 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>呈现壳预览 · Writing Agent</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap"
rel="stylesheet"
/>
<script src="/theme-init.js"></script>
<link rel="stylesheet" href="/settings.css" />
<link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="/shells.css" />
</head>
<body class="settings-page shells-page">
<header class="st-top">
<a class="st-brand" href="/">Writing Agent</a>
<nav class="st-nav" aria-label="主导航">
<a href="/">对话</a>
<a href="/modules.html">技能</a>
<a href="/shells.html" aria-current="page">呈现壳</a>
<a href="/libraries.html">库</a>
<a href="/stats.html">Token</a>
<a href="/settings.html">设置</a>
</nav>
</header>
<div class="st-shell shells-shell">
<aside class="st-rail" aria-label="壳列表">
<p class="st-rail-label">选壳</p>
<p class="st-rail-hint">按「要同时看见什么」选,不是按好看选。</p>
<nav class="shells-list" id="shell-list" role="tablist"></nav>
<p class="st-rail-note">
主任务永远是读正文(常 1000~2000 字)。侧栏只换壳;长度/语气在预览顶栏。
</p>
</aside>
<main class="st-main shells-main">
<div class="shells-toolbar" role="toolbar" aria-label="预览条件">
<div class="shells-toolbar-block">
<span class="shells-toolbar-label" id="length-label">正文长度</span>
<div class="shells-tones" id="length-list" role="group" aria-labelledby="length-label"></div>
</div>
<div class="shells-toolbar-block">
<span class="shells-toolbar-label" id="tone-label">语气</span>
<div class="shells-tones" id="tone-list" role="group" aria-labelledby="tone-label"></div>
</div>
<div class="shells-toolbar-block shells-toolbar-meta" id="shell-meta"></div>
</div>
<section class="shells-preview-wrap" aria-label="壳预览主工作面">
<div id="shell-preview" class="shells-preview"></div>
</section>
<details class="shells-details">
<summary>微调轴与说明</summary>
<div class="shells-details-body">
<div class="shells-tweak-grid" id="tweak-controls"></div>
<ul class="shells-help-list">
<li>主工作面 = 上面的壳。长文在预览框内滚动;顶栏和侧栏钉住。</li>
<li>壳差在「必须同时看见什么」:只正文 / 正文+状态 / 正文+线索 / 正文+选项…</li>
<li>创作只改字段绑定与显示名,不发明新布局。文档:<code>docs/play-presentation-shells.md</code></li>
</ul>
</div>
</details>
</main>
</div>
<script type="module" src="/shells.js"></script>
</body>
</html>
-348
View File
@@ -1,348 +0,0 @@
/**
* 呈现壳预览:主工作面 = 壳画布。
* 按「要同时看见什么」选壳;正文按 1000~2000 字撑高滚动。
*/
import {
PRESENT_SHELL_IDS,
renderPresentShellHtml,
} from "./present-shells.js";
/** task = 同时可见关系(选壳依据);name = 短名 */
const SHELL_INFO = {
prose: {
name: "纯散文",
task: "只要正文",
blurb: "无 HUD。读完即止。",
},
chat_monitor: {
name: "对话+监控",
task: "正文 + 扫状态",
blurb: "顶栏芯片矮;主聊/场面往下长。",
},
spotlight: {
name: "场面主视",
task: "大正文 + 弱状态",
blurb: "无聊天气泡框;场面即画布。",
},
turn_panel: {
name: "回合面板",
task: "场面 + 物 + 行动",
blurb: "对照交互物;行动在下。",
},
split_board: {
name: "双栏看板",
task: "正文 ‖ 线索",
blurb: "左右对照;侧栏钉住。",
},
choice_dock: {
name: "选择坞",
task: "局面 + 选项",
blurb: "读完再选;坞贴视口底。",
},
chapter_reader: {
name: "章节阅读",
task: "章正文 + 进度",
blurb: "阅读柱为主;进度窄栏钉住。",
},
};
const TONES = [
{ id: "default", label: "默认" },
{ id: "messenger", label: "讯息" },
{ id: "book", label: "书页" },
{ id: "terminal", label: "终端" },
];
const LENGTHS = [
{ id: "short", label: "短" },
{ id: "mid", label: "中 ·800" },
{ id: "long", label: "长 ·2000" },
];
const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。
路口的灯跳成绿色。你们一起迈步——这一次,谁也没有先松开。对面便利店的玻璃上映着两个人并肩的轮廓,忽明忽暗,像谁随手画的草稿。
「伞……」她终于开口,声音被雨声压得很低,「明天不用还。」
你点头,又觉得这回答太轻,补了一句:「那我请你喝热的?」她侧过脸,雨丝粘在睫毛上,笑意只闪了一下,像怕被看穿。
电梯门开的时候,楼道里干燥得过分。她把袋子换到另一只手,钥匙在指间转了一圈,忽然停住:「其实我今天本来想绕路的。」
「为什么?」
「因为预报说会停。结果没有。」她把钥匙推进锁孔,「所以——只好借你的伞了。」
门开了一条缝。暖光漏出来,照在你们脚边的水渍上。她没有立刻进去,只是抬眼看你,像在等一句还没说出口的话。你把伞收拢,水仍顺着伞尖滴到地毯边缘。谁也没有提「进来坐坐」,但空气里已经有了那句话的形状。`;
const BODY_LONG = `${BODY_MID}
天桥下的风从另一头灌过来,把塑料袋吹得啪啪响。你想起上一次走过这里,还是夏天,蝉声吵得人没法好好说话;如今只剩雨,以及偶尔驶过的车灯,把积水切成一条条亮痕。
便利店里的收音机断断续续播着交通路况。店员打着哈欠换班,玻璃门开合时带进一阵湿冷。你站在门口抖伞,余光看见货架尽头有人停了很久——拿起一瓶水,又放下,再拿起,像在跟自己商量一件比购物更大的事。
她的短信迟了三分钟才到:「到家了吗?」你盯着屏幕上的光标,打了又删:刚进小区 / 伞还在我这 / 明天……最后只留下一句:「刚到。伞明天给你。」她回了个省略号,又补:「不用那么快。」
你把手机塞回口袋,忽然觉得「不用那么快」四个字比天气预报更准。有些东西一旦说破就会散,只好让它停在半湿半干之间。
楼道灯感应得慢,你摸黑上了两级台阶才亮。隔壁门缝里漏出电视剧的笑声,被雨声削成模糊的边。你靠着墙站了一会儿,听自己的呼吸从急变匀,才继续往上走。
到家后你把伞撑在阳台,水沿着伞骨汇成细流,滴进盆里,一声一声,像在数今晚还剩多少没说清的话。窗玻璃上凝着雾,你用指尖随便划了一道,很快又糊住——外面的城市灯火依旧,只是被雨帘隔成另一层世界。
你泡了杯热水,坐到桌前,本想写点什么,笔尖却停在空白处。纸页吸走一点水汽,皱起细纹。你写下一句又涂掉:关于伞,关于绿灯,关于她说「绕路」时那种几乎听不见的停顿。最后只留下日期,和一行没写完的「明天」。
夜里雨势小了些。你关灯前又看了一眼阳台——伞还在,水还在滴,只是间隔更长了,像什么人放慢了脚步,却没有真的离开。
(——以下为加长段,模拟单轮 2000+ 字场面——)
次日清晨,天边只剩薄云。你带着伞出门,金属骨架在布套里轻轻碰撞。电梯里遇见邻居遛狗,它的爪子在地垫上留下两串湿印,很快被后来的人踩散。你想:痕迹就是这样,被人看见时已不是原样。
到了约定的路口,她比你早到半分钟,手里多了一杯热饮,杯套上印着歪歪扭扭的笑脸。「还给你。」你把伞递过去。她却摇头:「今天晴。」伞停在两人之间,像一句还没决定归属的句子。
你们沿着河堤走。水面上漂着昨夜的落叶,偶有涟漪被风抹平。她讲起最近在忙的展览,讲到一半又停住,说其实不太想聊工作。于是话题滑向更碎的东西:哪家面馆换了老板、哪条巷子的灯坏了三天、哪一次下雨你们刚好都没带伞。
「所以才需要借。」她说。
「借完还要还。」你说。
「有的不用还。」她看着河对岸的塔吊,「有的还了,反而像没发生过。」
塔吊缓缓旋转,吊臂在灰蓝天空里划出钝角。你们在桥上站了一会儿。风把伞套吹得鼓起,又瘪下去。她把热饮塞进你手里:「暖的。你昨晚肯定没睡好。」你没有否认。杯壁的温度从掌心爬上手腕,像把雨夜重新烘了一遍,只留下能说出口的那一层。
分开前,她抬手点了点伞:「这个,先放你那儿。等下一场雨。」
你点头。伞重新沉进包里,重量不大,却让背包的一侧微微倾斜——刚好提醒你:故事还会继续,而界面必须装得下继续。`;
const BODY_SHORT = {
prose:
"雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。\n\n路口的灯跳成绿色。你们一起迈步——这一次,谁也没有先松开。",
chat_monitor:
"【短信】\n她:到家了吗?\n你:刚进小区。伞借你的,明天还给你?\n她:……不用那么快。",
spotlight:
"水从铁栏杆上砸下来,把对面那人的轮廓冲得忽明忽暗。\n\n他抬起头——认出是你的瞬间,肩膀松了一下,又立刻绷紧。",
turn_panel:
"货架后传来塑料袋摩擦声。你屏住呼吸,左手按住刀柄——也许只是风。\n\n收银台抽屉半开,里面有未拆封的电池。",
split_board:
"抽屉夹层里有一张折痕发白的收据。日期是失踪前夜,商户栏写着「北港五金」。\n\n窗台积灰被抹过一道——有人近来开过窗。",
choice_dock:
"她把钥匙放在桌上,没有推过来,也没有收回。\n\n「你要是现在走,我就当今晚什么都没发生。」",
chapter_reader:
"  天亮得比往日晚。林晚站在窗边,把画册合上,指尖在封面上停了一息。\n\n  「今天,」她说,「我们换条路回家。」",
};
const SAMPLE_META = {
prose: {
blocks: { footer: "本段为示例备注(可关)" },
meta: { suggested_actions: [] },
},
chat_monitor: {
blocks: {
monitor: { 好感: "暖络", 今日话题: "下雨天", 体力: "还好" },
footer: "",
},
meta: { suggested_actions: ["回一句关心", "约周末还伞"] },
},
spotlight: {
blocks: {
monitor: { 地点: "天桥下", 天气: "暴雨", 张力: "紧" },
footer: "",
},
meta: { suggested_actions: ["先开口", "递伞过去"] },
},
turn_panel: {
blocks: {
monitor: { 体力: "6/10", 物资: "压缩饼干×2", 威胁: "低" },
header: "第 12 回合 · 废弃便利店",
footer: "检定:潜行(通过)",
aside: { 可交互: "电池 / 后门 / 死角", 噪音: "低" },
},
meta: { suggested_actions: ["摸电池", "从后门离开", "大声喝问"] },
},
split_board: {
blocks: {
header: "案发现场 · 二楼书房",
aside: {
线索: "北港收据",
证物: "窗台指痕",
待查: "五金店监控",
},
footer: "",
},
meta: { suggested_actions: ["去北港", "采指纹", "先问管家"] },
},
choice_dock: {
blocks: {
monitor: { 信任: "中", 时间: "子夜前" },
footer: "",
},
meta: {
suggested_actions: ["拿走钥匙留下", "把钥匙推回去", "什么也不说,坐着"],
},
},
chapter_reader: {
blocks: {
header: "卷三 · 第 7 章 雨停之前",
aside: "进度:卷三 7/18\n下一钩:母亲来电",
footer: "",
},
meta: { suggested_actions: [] },
},
};
function pickBody(shell, length) {
if (length === "short") return BODY_SHORT[shell] || BODY_SHORT.prose;
if (length === "mid") return BODY_MID;
return BODY_LONG;
}
function countChars(s) {
return Array.from(String(s ?? "").replace(/\s/g, "")).length;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
const state = {
shell: "spotlight",
tone: "default",
length: "mid",
showActions: true,
blockLabels: {},
};
function buildTweaks() {
return {
tone_chrome: state.tone,
show_suggested_actions: state.showActions,
block_labels: { ...state.blockLabels },
empty_states: {
aside: "(侧栏空)",
footer: "(文末空)",
},
};
}
function buildPacket() {
const base = SAMPLE_META[state.shell] || SAMPLE_META.prose;
const body = pickBody(state.shell, state.length);
return {
schema: "present.v1",
shell: state.shell,
blocks: { ...base.blocks, body },
meta: base.meta,
_chars: countChars(body),
};
}
function render() {
const info = SHELL_INFO[state.shell] || {
name: state.shell,
task: "",
blurb: "",
};
const packet = buildPacket();
document.getElementById("shell-meta").innerHTML = `
<p class="shells-meta-title">${esc(info.name)}</p>
<p class="shells-meta-sub">${esc(info.task)} · 试读 ${packet._chars} 字<br>${esc(info.blurb)}</p>
`;
const preview = document.getElementById("shell-preview");
preview.dataset.shell = state.shell;
preview.innerHTML = renderPresentShellHtml(packet, esc, {
tweaks: buildTweaks(),
applyUserChrome: false,
});
document.querySelectorAll("[data-shell]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-shell") === state.shell);
btn.setAttribute("aria-selected", btn.getAttribute("data-shell") === state.shell ? "true" : "false");
});
document.querySelectorAll("[data-tone]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-tone") === state.tone);
});
document.querySelectorAll("[data-length]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-length") === state.length);
});
const showActionsEl = document.getElementById("tweak-show-actions");
if (showActionsEl) showActionsEl.checked = state.showActions;
}
function mount() {
const list = document.getElementById("shell-list");
list.innerHTML = PRESENT_SHELL_IDS.map((id) => {
const info = SHELL_INFO[id];
return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab" aria-selected="false">
<span class="shell-item-name">${esc(info?.name || id)}</span>
<span class="shell-item-task">${esc(info?.task || "")}</span>
</button>`;
}).join("");
document.getElementById("tone-list").innerHTML = TONES.map(
(t) =>
`<button type="button" class="shells-tone-chip" data-tone="${esc(t.id)}">${esc(t.label)}</button>`,
).join("");
document.getElementById("length-list").innerHTML = LENGTHS.map(
(t) =>
`<button type="button" class="shells-tone-chip" data-length="${esc(t.id)}">${esc(t.label)}</button>`,
).join("");
document.getElementById("tweak-controls").innerHTML = `
<label class="field">
<input type="checkbox" id="tweak-show-actions" />
显示建议行动(show_suggested_actions)
</label>
<label class="field">
正文显示名(block_labels.body)
<input type="text" id="tweak-label-body" placeholder="短信记录 / 场面 / 本章" />
</label>
<label class="field">
监控显示名(block_labels.monitor)
<input type="text" id="tweak-label-monitor" placeholder="状态 / 今日" />
</label>
`;
list.addEventListener("click", (e) => {
const btn = e.target.closest("[data-shell]");
if (!btn) return;
state.shell = btn.getAttribute("data-shell");
render();
});
document.getElementById("tone-list").addEventListener("click", (e) => {
const btn = e.target.closest("[data-tone]");
if (!btn) return;
state.tone = btn.getAttribute("data-tone");
render();
});
document.getElementById("length-list").addEventListener("click", (e) => {
const btn = e.target.closest("[data-length]");
if (!btn) return;
state.length = btn.getAttribute("data-length");
render();
});
const syncLabels = () => {
state.showActions = document.getElementById("tweak-show-actions").checked;
const body = document.getElementById("tweak-label-body").value.trim();
const monitor = document.getElementById("tweak-label-monitor").value.trim();
state.blockLabels = {};
if (body) state.blockLabels.body = body;
if (monitor) state.blockLabels.monitor = monitor;
render();
};
document.getElementById("tweak-controls").addEventListener("change", syncLabels);
document.getElementById("tweak-controls").addEventListener("input", syncLabels);
render();
}
mount();
-1
View File
@@ -20,7 +20,6 @@
<nav class="st-nav" aria-label="主导航"> <nav class="st-nav" aria-label="主导航">
<a href="/">对话</a> <a href="/">对话</a>
<a href="/modules.html">技能</a> <a href="/modules.html">技能</a>
<a href="/shells.html">呈现壳</a>
<a href="/libraries.html">库</a> <a href="/libraries.html">库</a>
<a href="/stats.html" aria-current="page">Token</a> <a href="/stats.html" aria-current="page">Token</a>
<a href="/settings.html">设置</a> <a href="/settings.html">设置</a>
+6
View File
@@ -1457,6 +1457,12 @@ body.design-workspace[data-design-surface="dictate"] .panel-feed,
body.design-workspace[data-design-surface="listen"] .panel-feed { body.design-workspace[data-design-surface="listen"] .panel-feed {
overflow: hidden; overflow: hidden;
} }
.listen-rail-hint {
margin: 0.35rem 0 0;
font-size: 0.82rem;
opacity: 0.72;
line-height: 1.4;
}
.listen-rail { .listen-rail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;