diff --git a/src/dictate/context.ts b/src/dictate/context.ts new file mode 100644 index 0000000..af05ec4 --- /dev/null +++ b/src/dictate/context.ts @@ -0,0 +1,64 @@ +import type { ChatMessage } from "../llm/client.js"; +import { + effectiveDictateOrder, + sortDictateProducts, + type DictateChatTurn, + type DictateProduct, +} from "./types.js"; + +/** + * 拼给模型的上下文:系统提示 + 全部产物(按相对序)+ 全量对话。 + * 清空对话后,只剩产物块,产物即浓缩上下文。 + */ +export function buildDictateMessages(params: { + systemPrompt: string; + products: DictateProduct[]; + dialogue: DictateChatTurn[]; +}): ChatMessage[] { + const parts: string[] = []; + const products = sortDictateProducts(params.products); + + if (products.length > 0) { + parts.push("## 当前产物(已写入黑板,按相对序;对话清空后仍保留)"); + for (const p of products) { + const ord = effectiveDictateOrder(p); + parts.push(`### ${p.tag} 〔order=${ord}〕\n${p.content}`); + } + } else { + parts.push("## 当前产物\n(尚无)"); + } + + parts.push("## 对话(全量)"); + if (params.dialogue.length === 0) { + parts.push("(尚无对话;等用户开口)"); + } else { + for (const turn of params.dialogue) { + const who = turn.role === "user" ? "用户" : "助手"; + parts.push(`【${who}】\n${turn.text}`); + } + } + + return [ + { role: "system", content: params.systemPrompt }, + { role: "user", content: parts.join("\n\n") }, + ]; +} + +/** 从会话消息里抽出转述式对话(用户输入 + 助手回复) */ +export function extractDictateDialogue( + messages: Array<{ role?: string; kind?: string; text?: string; body?: string }>, +): DictateChatTurn[] { + const out: DictateChatTurn[] = []; + for (const m of messages) { + const text = (m.body ?? m.text ?? "").trim(); + if (!text) continue; + if (m.role === "user" || m.kind === "user_input") { + out.push({ role: "user", text }); + continue; + } + if (m.kind === "dictate_reply") { + out.push({ role: "assistant", text }); + } + } + return out; +} diff --git a/src/dictate/index.ts b/src/dictate/index.ts new file mode 100644 index 0000000..398403f --- /dev/null +++ b/src/dictate/index.ts @@ -0,0 +1,21 @@ +export { buildDictateSystemPrompt, DICTATE_SYSTEM_PROMPT } from "./prompt.js"; +export { + CREATION_INTAKE_MODE_TAG, + DICTATE_MODE_VALUE, + DICTATE_ORDER_META_KEY, + isDictateModeValue, + isAllowedProductTag, + defaultDictateOrder, + effectiveDictateOrder, + compareDictateProducts, + sortDictateProducts, + parseDictateOrder, + type DictateProduct, + type DictateChatTurn, +} from "./types.js"; +export { + buildDictateMessages, + extractDictateDialogue, +} from "./context.js"; +export { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; +export { runDictateTurn, type DictateTurnResult } from "./turn.js"; diff --git a/src/dictate/prompt.ts b/src/dictate/prompt.ts new file mode 100644 index 0000000..10d2cf7 --- /dev/null +++ b/src/dictate/prompt.ts @@ -0,0 +1,77 @@ +/** + * 转述整理式 · 创作进料专用系统提示 + * + * 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。 + * 仅用于创作层;开玩后由游玩管线接棒。 + * + * 产物只许用 insert toolcall;聊天只做确认与建议。 + */ + +export function buildDictateSystemPrompt(opts?: { + recipeName?: string; + recipeBrief?: string; +}): string { + const recipeBlock = + opts?.recipeName || opts?.recipeBrief + ? ` +## 本局配方 +名称:${opts.recipeName ?? "(未命名)"} +${opts.recipeBrief ? `要点:${opts.recipeBrief}` : ""} +按上述适用与原则整理产物;材料只够做本配方时,就只做本配方范围内的事。 +` + : ""; + + return `你是转述整理助手:在**创作进料**阶段,把用户原话整理成黑板产物。作用对象是本局创作上下文(对话可清空,产物保留)。 + +**本提示只用于创作。** 开玩后的叙事由程序另调游玩 worker;不要假装在跑游玩回合。 + +## 落盘(仅允许 toolcall) +产物**只能**用工具 **insert** 写入,禁止把设定/格式/开场全文只写在聊天里冒充已落盘。 +- \`position\`:插入位置(tag),如 \`用户.需求\`、\`设计.正文组成\`、\`设计.开场白\` +- \`content\`:该位置正文 +- \`order\`(可选):相对顺序,数字可负、可为 0、可正;**越小越靠前** + +**order 怎么取:** +1. 变化越频繁 → 数字越大(越靠后)。例:开场白常改 > 正文组成 > 需求/美学。 +2. 改动频率差不多 → 越重要数字越小(越靠前)。例:用户.需求 通常最前。 +3. 同轮可多次 insert;程序按 order 升序拼进上下文。省略 order 时沿用该 position 原序,或按 tag 默认。 + +**自然语言回复**只写:短确认、是否这个、建议补充 A/B/C、提问。不要复述 insert 全文。 + +**分清两件事:** +- **正文组成(格式)**:insert → \`设计.正文组成\`(壳与分区,非故事)。 +- **开场白(内容)**:insert → \`设计.开场白\`(按格式写的首屏);落档/开玩同步 \`输出.开场白\`。 +${recipeBlock} +## 按用户本轮意图择一为主 +### A · 钉设定 +1. insert 设定类 position(保留原意原词);order 偏小(靠前)。 +2. 回复:确认体验(2~4 句 + 问是否这个)+ 建议补充(1~2 缺口,各 A/B/C)。 + +### B · 钉正文格式 +1. 必须 insert \`设计.正文组成\`(见规格);order 居中(约 0)。 +2. 回复短说明已钉壳与主块。 + +### C · 生成开场白 +1. 先有 \`设计.正文组成\`(否则先 B;缺设定先 A)。 +2. insert \`设计.开场白\`:Markdown 主阅读;岔路用 \`## 岔路\`;勿写 【body】/【footer】。order 偏大(靠后)。 +3. 回复只短说已写入;全文在 content。 + +## 常用 position 与默认序感 +- 用户.需求、设计.美学纲领等设定:靠前(负) +- 设计.正文组成:中间(0 附近) +- 设计.开场白:靠后(正) + +## 设计.正文组成 · 规格(格式) +JSON 即可。核心:\`正文.呈现壳.shell_id\`、\`正文.可见块[]\`(区域 body/footer 等)。 +文本局默认 shell_id=\`prose\`;至少一块 body。要选项再声明 footer。 + +## 设计.开场白 · 规格(内容) +按正文组成职责写满主阅读;Markdown;确认与建议不进 content。 + +## 其它 +对话明显过长时 clear_dialogue(产物保留);否则不要清。 +`; +} + +/** @deprecated 用 buildDictateSystemPrompt() */ +export const DICTATE_SYSTEM_PROMPT = buildDictateSystemPrompt(); diff --git a/src/dictate/tools.ts b/src/dictate/tools.ts new file mode 100644 index 0000000..bdea656 --- /dev/null +++ b/src/dictate/tools.ts @@ -0,0 +1,57 @@ +import type { ToolDefinition } from "../llm/client.js"; + +/** + * 转述创作期工具:只允许用 toolcall 写产物。 + * insert(position, content, order?) —— 打标记落盘并带相对序。 + */ +export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [ + { + type: "function", + function: { + name: "insert", + description: + "唯一写产物入口:把正文插入黑板。确认/建议/思考只写在普通回复,禁止放进 content。用 order 标相对先后以便拼装。", + parameters: { + type: "object", + properties: { + position: { + type: "string", + description: + "插入位置(产物 tag)。须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白", + }, + content: { + type: "string", + description: + "落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=可读开场(Markdown,用 ## 分段,勿写【body】、勿夹确认/建议)", + }, + order: { + type: "number", + description: + "相对顺序(可负、0、正)。越小越靠前。原则:越常改越大(靠后);改动频率差不多时,越重要越小(靠前)。省略则沿用原序或按 tag 默认。", + }, + }, + required: ["position", "content"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "clear_dialogue", + description: + "对话过长时清空对话记录。产物保留。清空后继续用自然语言回复用户。", + parameters: { + type: "object", + properties: { + reason: { + type: "string", + description: "为何清空(一句话)", + }, + }, + required: ["reason"], + additionalProperties: false, + }, + }, + }, +]; diff --git a/src/dictate/turn.ts b/src/dictate/turn.ts new file mode 100644 index 0000000..1f4814d --- /dev/null +++ b/src/dictate/turn.ts @@ -0,0 +1,163 @@ +import type { LlmProvider } from "../llm/client.js"; +import { + createLocalLlmDriver, + type AgentDriver, +} from "../runtime/driver.js"; +import { buildDictateMessages } from "./context.js"; +import { buildDictateSystemPrompt } from "./prompt.js"; +import { DICTATE_TOOL_DEFINITIONS } from "./tools.js"; +import { + isAllowedProductTag, + parseDictateOrder, + type DictateChatTurn, + type DictateProduct, +} from "./types.js"; + +export type DictateTurnHandlers = { + listProducts: () => DictateProduct[]; + /** order 省略时由实现沿用原序或默认 */ + writeProduct: (tag: string, content: string, order?: number) => void; + clearDialogue: () => void; + onToolCall?: (name: string, detail: string) => void; + onThinkingDelta?: (delta: string) => void; + onThinkingDone?: (text: string) => void; +}; + +export type DictateTurnResult = { + reply: string; + iterations: number; + wroteTags: string[]; + clearedDialogue: boolean; +}; + +/** + * 跑一轮转述整理:上下文 = 预设提示 + 按相对序的产物 + 全量对话。 + * 产物只许 insert(position, content, order?);最终文本即对用户回复。 + */ +export async function runDictateTurn(params: { + llm: LlmProvider; + dialogue: DictateChatTurn[]; + handlers: DictateTurnHandlers; + systemPrompt?: string; + recipeName?: string; + recipeBrief?: string; + driver?: AgentDriver; +}): Promise { + const driver = params.driver ?? createLocalLlmDriver(params.llm); + const wroteTags: string[] = []; + let clearedDialogue = false; + let dialogue = [...params.dialogue]; + + const systemPrompt = + params.systemPrompt ?? + buildDictateSystemPrompt({ + recipeName: params.recipeName, + recipeBrief: params.recipeBrief, + }); + const assembled = buildDictateMessages({ + systemPrompt, + products: params.handlers.listProducts(), + dialogue, + }); + // driver 会再前置 system;此处只传 user 拼装块,避免双重 system + const userBlock = assembled.filter((m) => m.role !== "system"); + + const run = await driver.run({ + system: systemPrompt, + messages: userBlock, + tools: DICTATE_TOOL_DEFINITIONS, + caller: "dictate_agent", + label: "转述整理", + onThinkingDelta: params.handlers.onThinkingDelta, + onThinkingDone: params.handlers.onThinkingDone, + handleStep: (calls) => { + const results = calls.map((call) => { + const args = safeParseArgs(call.arguments); + if (call.name === "insert" || call.name === "write_product") { + const tag = String( + args.position ?? args.tag ?? "", + ).trim(); + const content = String(args.content ?? ""); + if (!isAllowedProductTag(tag)) { + const err = `拒绝写入:position 必须以「用户.」或「设计.」开头(收到:${tag || "空"})`; + params.handlers.onToolCall?.(call.name, err); + return { callId: call.id, content: JSON.stringify({ error: err }) }; + } + if (!content.trim()) { + const err = "content 不能为空"; + params.handlers.onToolCall?.(call.name, err); + return { callId: call.id, content: JSON.stringify({ error: err }) }; + } + const order = parseDictateOrder(args.order); + params.handlers.writeProduct(tag, content, order); + wroteTags.push(tag); + const detail = + order === undefined ? tag : `${tag} order=${order}`; + params.handlers.onToolCall?.(call.name, detail); + return { + callId: call.id, + content: JSON.stringify({ + ok: true, + position: tag, + ...(order !== undefined ? { order } : {}), + }), + }; + } + if (call.name === "clear_dialogue") { + const reason = String(args.reason ?? "").trim() || "上下文过长"; + params.handlers.clearDialogue(); + clearedDialogue = true; + dialogue = []; + params.handlers.onToolCall?.(call.name, reason); + return { + callId: call.id, + content: JSON.stringify({ + ok: true, + cleared: true, + note: "对话已清空;产物仍在。后续上下文以产物为准。", + }), + }; + } + const err = `未知工具:${call.name}`; + params.handlers.onToolCall?.(call.name, err); + return { callId: call.id, content: JSON.stringify({ error: err }) }; + }); + return { kind: "continue" as const, results }; + }, + }); + + // driver 的 messages 在首轮已固定;clear 后若还要续写依赖模型已知工具结果。 + // 若模型只调工具不说话,给一个兜底回复。 + let reply = ""; + if (run.stop.kind === "text") { + reply = run.stop.content.trim(); + } + if (!reply) { + if (wroteTags.length) { + reply = `已写入:${[...new Set(wroteTags)].join("、")}。还有要补充的吗?`; + } else if (clearedDialogue) { + reply = "对话已清空,产物都还在。可以继续说。"; + } else { + reply = "我在听,请继续说你想要的体验或设定。"; + } + } + + return { + reply, + iterations: run.iterations, + wroteTags: [...new Set(wroteTags)], + clearedDialogue, + }; +} + +function safeParseArgs(raw: string): Record { + try { + const parsed = JSON.parse(raw || "{}"); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + /* ignore */ + } + return {}; +} diff --git a/src/dictate/types.ts b/src/dictate/types.ts new file mode 100644 index 0000000..07f2606 --- /dev/null +++ b/src/dictate/types.ts @@ -0,0 +1,75 @@ +/** 转述整理式 · 类型与常量 */ + +export const CREATION_INTAKE_MODE_TAG = "创作.进料模式"; +export const DICTATE_MODE_VALUE = "dictate"; + +/** 产物相对序写在黑板 item.metadata 里 */ +export const DICTATE_ORDER_META_KEY = "dictateOrder"; + +export type DictateProduct = { + tag: string; + content: string; + /** 相对顺序:负数靠前,正数靠后;缺省时按 tag 启发式 */ + order?: number; +}; + +export type DictateChatTurn = { + role: "user" | "assistant"; + text: string; +}; + +export function isDictateModeValue(raw: string | undefined | null): boolean { + return (raw ?? "").trim() === DICTATE_MODE_VALUE; +} + +/** 允许写入的产物 tag */ +export function isAllowedProductTag(tag: string): boolean { + const t = tag.trim(); + if (!t) return false; + if (t.includes("\n") || t.includes("\0")) return false; + return t.startsWith("用户.") || t.startsWith("设计."); +} + +/** + * 未显式给 order 时的默认相对序。 + * 越稳定/重要越靠前(更负);越常改越靠后(更正)。 + */ +export function defaultDictateOrder(tag: string): number { + const t = tag.trim(); + if (t === "用户.需求") return -40; + if (/美学|纲领|禁忌|示例/.test(t)) return -30; + if (/文风|叙事指南|篇幅|结构/.test(t)) return -20; + if (/正文组成|回复格式/.test(t)) return 0; + if (/开场白/.test(t)) return 20; + return 0; +} + +export function effectiveDictateOrder(p: Pick): number { + return typeof p.order === "number" && Number.isFinite(p.order) + ? p.order + : defaultDictateOrder(p.tag); +} + +/** 按相对序升序:负 → 0 → 正;同序再按 tag */ +export function compareDictateProducts( + a: Pick, + b: Pick, +): number { + const d = effectiveDictateOrder(a) - effectiveDictateOrder(b); + if (d !== 0) return d; + return a.tag.localeCompare(b.tag, "zh"); +} + +export function sortDictateProducts>( + products: T[], +): T[] { + return [...products].sort(compareDictateProducts); +} + +/** 解析 insert 的 order;非法则 undefined(表示沿用/默认) */ +export function parseDictateOrder(raw: unknown): number | undefined { + if (raw === undefined || raw === null || raw === "") return undefined; + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n)) return undefined; + return n; +} diff --git a/tests/dictate.test.ts b/tests/dictate.test.ts new file mode 100644 index 0000000..1f1ebb1 --- /dev/null +++ b/tests/dictate.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + buildDictateMessages, + buildDictateSystemPrompt, + defaultDictateOrder, + extractDictateDialogue, + isAllowedProductTag, + isDictateModeValue, + sortDictateProducts, +} from "../src/dictate/index.js"; + +describe("dictate context", () => { + it("assembles full dialogue + products with almost no filtering", () => { + const messages = buildDictateMessages({ + systemPrompt: "SYS", + products: [{ tag: "用户.需求", content: "雨夜压抑" }], + dialogue: [ + { role: "user", text: "我想要压抑雨夜" }, + { role: "assistant", text: "已写入用户.需求" }, + ], + }); + expect(messages[0]).toEqual({ role: "system", content: "SYS" }); + expect(messages[1]?.role).toBe("user"); + const body = messages[1]?.content ?? ""; + expect(body).toContain("用户.需求"); + expect(body).toContain("雨夜压抑"); + expect(body).toContain("我想要压抑雨夜"); + expect(body).toContain("已写入用户.需求"); + }); + + it("after clear (empty dialogue) still carries products", () => { + const messages = buildDictateMessages({ + systemPrompt: "SYS", + products: [{ tag: "设计.美学纲领", content: "冷色短句" }], + dialogue: [], + }); + const body = messages[1]?.content ?? ""; + expect(body).toContain("冷色短句"); + expect(body).toContain("尚无对话"); + }); + + it("extracts only user + dictate_reply turns", () => { + const turns = extractDictateDialogue([ + { kind: "orchestrator_prompt", text: "欢迎" }, + { kind: "user_input", text: "你好" }, + { kind: "agent_tool", text: "insert" }, + { kind: "dictate_reply", text: "已记下" }, + ]); + expect(turns).toEqual([ + { role: "user", text: "你好" }, + { role: "assistant", text: "已记下" }, + ]); + }); + + it("validates product tags and mode value", () => { + expect(isAllowedProductTag("用户.需求")).toBe(true); + expect(isAllowedProductTag("设计.美学纲领")).toBe(true); + expect(isAllowedProductTag("设计.正文组成")).toBe(true); + expect(isAllowedProductTag("设计.开场白")).toBe(true); + expect(isAllowedProductTag("变量.当前")).toBe(false); + expect(isDictateModeValue("dictate")).toBe(true); + expect(isDictateModeValue("recipe")).toBe(false); + }); + + it("system prompt requires insert toolcall and relative order", () => { + const prompt = buildDictateSystemPrompt({ + recipeName: "正文组成", + recipeBrief: "钉呈现壳与可见块", + }); + expect(prompt).toContain("设计.正文组成"); + expect(prompt).toContain("设计.开场白"); + expect(prompt).toContain("输出.开场白"); + expect(prompt).toContain("insert"); + expect(prompt).toMatch(/order/); + expect(prompt).toContain("仅允许 toolcall"); + expect(prompt).toContain("变化越频繁"); + }); + + it("sorts products by relative order: neg then 0 then pos", () => { + const sorted = sortDictateProducts([ + { tag: "设计.开场白", content: "开", order: 20 }, + { tag: "用户.需求", content: "需", order: -40 }, + { tag: "设计.正文组成", content: "格", order: 0 }, + ]); + expect(sorted.map((p) => p.tag)).toEqual([ + "用户.需求", + "设计.正文组成", + "设计.开场白", + ]); + }); + + it("default order puts stable/important before volatile", () => { + expect(defaultDictateOrder("用户.需求")).toBeLessThan( + defaultDictateOrder("设计.正文组成"), + ); + expect(defaultDictateOrder("设计.正文组成")).toBeLessThan( + defaultDictateOrder("设计.开场白"), + ); + }); + + it("context lists products in order", () => { + const body = + buildDictateMessages({ + systemPrompt: "SYS", + products: [ + { tag: "设计.开场白", content: "尾", order: 10 }, + { tag: "用户.需求", content: "头", order: -10 }, + ], + dialogue: [], + })[1]?.content ?? ""; + expect(body.indexOf("用户.需求")).toBeLessThan(body.indexOf("设计.开场白")); + expect(body).toContain("order=-10"); + expect(body).toContain("order=10"); + }); +});