接通主角绑定、开场封印与游玩辅助,让世界模拟器从创作连续落到本局。
直聘插入按模块回馈,并记录请求骨架便于排查。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -102,6 +102,20 @@ export class Blackboard {
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 删除某 tag 的全部条目;返回是否曾存在 */
|
||||
deleteByTag(tag: string): boolean {
|
||||
const t = tag.trim();
|
||||
if (!t) return false;
|
||||
let removed = false;
|
||||
for (const [id, item] of this.items) {
|
||||
if (item.tag === t) {
|
||||
this.items.delete(id);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
seed(items: BlackboardItem[]): void {
|
||||
for (const item of items) {
|
||||
this.items.set(item.id, item);
|
||||
|
||||
@@ -6,10 +6,13 @@ export type TableCellSource = `user` | `system` | `worker:${string}`;
|
||||
export type TableCell = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
/** 该格版本号:手改须带读到的 rev,对不上则拒绝,避免盖过后端更新 */
|
||||
rev: number;
|
||||
updatedAt: string;
|
||||
source: TableCellSource | string;
|
||||
visibility?: "visible" | "hidden";
|
||||
/** 未声明时:可见格可改,隐藏格只读 */
|
||||
editable?: boolean;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
@@ -47,6 +50,12 @@ export function parseTableDoc(raw: string | undefined | null): TableDoc | null {
|
||||
row.visibility === "hidden" || row.visibility === "visible"
|
||||
? row.visibility
|
||||
: "visible",
|
||||
editable:
|
||||
row.editable === true
|
||||
? true
|
||||
: row.editable === false
|
||||
? false
|
||||
: undefined,
|
||||
note: typeof row.note === "string" ? row.note : undefined,
|
||||
});
|
||||
}
|
||||
@@ -109,6 +118,7 @@ export function mergeTableCells(params: {
|
||||
updatedAt: now,
|
||||
source: params.actor,
|
||||
visibility: patch.visibility ?? existing?.visibility ?? "visible",
|
||||
editable: patch.editable ?? existing?.editable,
|
||||
note: patch.note ?? existing?.note,
|
||||
});
|
||||
applied.push(key);
|
||||
@@ -121,11 +131,108 @@ export function mergeTableCells(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function isTableCellEditable(row: TableCell): boolean {
|
||||
if (row.editable === false) return false;
|
||||
if (row.editable === true) return true;
|
||||
return (row.visibility ?? "visible") === "visible";
|
||||
}
|
||||
|
||||
export type TableCellPatch = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
expectedRev: number;
|
||||
};
|
||||
|
||||
export type UserTableSkip = {
|
||||
key: string;
|
||||
reason: string;
|
||||
have?: number;
|
||||
expected?: number;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
/** 用户按格手改:必须带读到的 rev;不可改或版本冲突则跳过该格。 */
|
||||
export function patchUserTableCells(
|
||||
current: TableDoc | null,
|
||||
patches: readonly TableCellPatch[],
|
||||
): {
|
||||
doc: TableDoc;
|
||||
applied: string[];
|
||||
skipped: UserTableSkip[];
|
||||
} {
|
||||
const empty: TableDoc = { rows: [] };
|
||||
const base = current ?? empty;
|
||||
const skipped: UserTableSkip[] = [];
|
||||
const allowed: TableCellPatch[] = [];
|
||||
for (const patch of patches) {
|
||||
const key = patch.key.trim();
|
||||
if (!key) continue;
|
||||
const existing = base.rows.find((r) => r.key === key);
|
||||
if (!existing) {
|
||||
skipped.push({ key, reason: "missing" });
|
||||
continue;
|
||||
}
|
||||
if (!isTableCellEditable(existing)) {
|
||||
skipped.push({
|
||||
key,
|
||||
reason: "not-editable",
|
||||
have: existing.rev,
|
||||
value: existing.value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!Number.isFinite(patch.expectedRev)) {
|
||||
skipped.push({ key, reason: "rev-required", have: existing.rev });
|
||||
continue;
|
||||
}
|
||||
allowed.push({ ...patch, key });
|
||||
}
|
||||
if (!allowed.length) {
|
||||
return { doc: base, applied: [], skipped };
|
||||
}
|
||||
const expectedRev = Object.fromEntries(
|
||||
allowed.map((p) => [p.key, p.expectedRev]),
|
||||
);
|
||||
const merged = mergeTableCells({
|
||||
current: base,
|
||||
patch: {
|
||||
rows: allowed.map((p) => ({
|
||||
key: p.key,
|
||||
value: p.value,
|
||||
rev: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "user",
|
||||
})),
|
||||
},
|
||||
actor: "user",
|
||||
expectedRev,
|
||||
});
|
||||
const byKey = new Map(base.rows.map((r) => [r.key, r]));
|
||||
const skippedOut: UserTableSkip[] = [
|
||||
...skipped,
|
||||
...merged.skipped.map((s) => {
|
||||
const haveRow = byKey.get(s.key);
|
||||
const expected = expectedRev[s.key];
|
||||
return {
|
||||
key: s.key,
|
||||
reason: s.reason,
|
||||
have: haveRow?.rev,
|
||||
expected,
|
||||
value: haveRow?.value,
|
||||
};
|
||||
}),
|
||||
];
|
||||
return { doc: merged.doc, applied: merged.applied, skipped: skippedOut };
|
||||
}
|
||||
|
||||
/** 从零创建:全部 source=actor,rev=1 */
|
||||
export function createTableFromValues(
|
||||
values: Record<string, unknown>,
|
||||
actor: TableCellSource | string,
|
||||
meta?: Record<string, { visibility?: "visible" | "hidden"; note?: string }>,
|
||||
meta?: Record<
|
||||
string,
|
||||
{ visibility?: "visible" | "hidden"; note?: string; editable?: boolean }
|
||||
>,
|
||||
): TableDoc {
|
||||
const now = new Date().toISOString();
|
||||
const rows: TableCell[] = Object.entries(values).map(([key, value]) => ({
|
||||
@@ -135,6 +242,9 @@ export function createTableFromValues(
|
||||
updatedAt: now,
|
||||
source: actor,
|
||||
visibility: meta?.[key]?.visibility ?? "visible",
|
||||
...(meta?.[key]?.editable !== undefined
|
||||
? { editable: meta[key]?.editable }
|
||||
: {}),
|
||||
note: meta?.[key]?.note,
|
||||
}));
|
||||
return { rows: rows.sort((a, b) => a.key.localeCompare(b.key)) };
|
||||
|
||||
@@ -19,6 +19,17 @@ export {
|
||||
} from "./context.js";
|
||||
export { DICTATE_TOOL_DEFINITIONS } from "./tools.js";
|
||||
export { runDictateTurn, type DictateTurnResult } from "./turn.js";
|
||||
export {
|
||||
DICTATE_INSERT_TAG_ALIASES,
|
||||
DICTATE_MULTI_MODULE_REPLY_HINT,
|
||||
buildDictateInsertFeedback,
|
||||
buildDictateInsertFeedbackIndex,
|
||||
formatDictateUserFacingBrief,
|
||||
lookupDictateInsertFeedback,
|
||||
resolveModuleForInsertTag,
|
||||
extractSelfScoreDimensionNames,
|
||||
type DictateInsertFeedback,
|
||||
} from "./insert-feedback.js";
|
||||
export {
|
||||
applyDictatePlayBind,
|
||||
bindDictateProductsToPlaySpec,
|
||||
@@ -26,6 +37,18 @@ export {
|
||||
collectDictateBindProducts,
|
||||
type DictatePlayBindResult,
|
||||
} from "./play-bind.js";
|
||||
export {
|
||||
REPEATABLE_TAG_SEP,
|
||||
buildRepeatableProductTag,
|
||||
collectFamilyContents,
|
||||
dictateProductFamily,
|
||||
dictateProductSlot,
|
||||
extractRepeatableSlotFromContent,
|
||||
isDictateProductFamily,
|
||||
mergeGenerationRulesArtifacts,
|
||||
resolveRepeatableInsertTag,
|
||||
sanitizeRepeatableSlot,
|
||||
} from "./repeatable-tags.js";
|
||||
export {
|
||||
VARIABLE_CATALOG_TAG,
|
||||
parseVariableCatalog,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* insert(tag) → 反查能力 → 独立「回复模块」规范,塞进 tool result。
|
||||
* 一轮可多次 insert:每个模块各自一份;末尾回复须分段全部附带。
|
||||
* 不照搬 schema / mount;只抽 probe、交互原则、自评关注点。
|
||||
*/
|
||||
|
||||
import {
|
||||
getModuleSection,
|
||||
loadModulePrompt,
|
||||
parseModulePromptSections,
|
||||
type ModuleCatalog,
|
||||
type ModuleCatalogEntry,
|
||||
} from "../skills/creation-flow.js";
|
||||
|
||||
/** 对话落盘常用简化 tag → 能力 id(catalog.artifact 之外) */
|
||||
export const DICTATE_INSERT_TAG_ALIASES: Readonly<Record<string, string>> = {
|
||||
"设计.开场白": "opening-setup",
|
||||
"设计.美学纲领": "aesthetics-interaction",
|
||||
"设计.文风": "narrative-guide",
|
||||
"设计.监控栏": "status-bar",
|
||||
};
|
||||
|
||||
/** 多模块同轮:写进每条 insert tool result 的 reply_hint */
|
||||
export const DICTATE_MULTI_MODULE_REPLY_HINT =
|
||||
"本轮带回了多个 reply_module。末尾可见回复:每个模块各写一块 markdown,标题用 ## 模块 · {名称}(与该 reply_module 一致);块间空一行;每块只写该模块的确认 / 薄弱点 / 追问。全部模块写完后,可另起一段写 1 条「建议下一刀」(条件须已接近成立)。缺某一块则补上后再结束本轮。";
|
||||
|
||||
const MAX_PROBE_CHARS = 1200;
|
||||
const MAX_PRINCIPLES_CHARS = 900;
|
||||
const MAX_BRIEF_CHARS = 2800;
|
||||
|
||||
export type DictateInsertFeedback = {
|
||||
moduleId: string;
|
||||
moduleName: string;
|
||||
tag: string;
|
||||
/** 给模型的独立模块 markdown */
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function resolveModuleForInsertTag(
|
||||
tag: string,
|
||||
catalog: ModuleCatalog | null | undefined,
|
||||
): ModuleCatalogEntry | null {
|
||||
const t = tag.trim();
|
||||
if (!t || !catalog?.modules?.length) return null;
|
||||
|
||||
const byArtifact = catalog.modules.find((m) => m.artifact?.trim() === t);
|
||||
if (byArtifact) return byArtifact;
|
||||
|
||||
// 可增殖拆分:设计.生成规则#slug → 仍反查 设计.生成规则
|
||||
const hash = t.indexOf("#");
|
||||
if (hash > 0) {
|
||||
const family = t.slice(0, hash).trim();
|
||||
const byFamily = catalog.modules.find((m) => m.artifact?.trim() === family);
|
||||
if (byFamily) return byFamily;
|
||||
}
|
||||
|
||||
const aliasId = DICTATE_INSERT_TAG_ALIASES[t];
|
||||
if (aliasId) {
|
||||
return catalog.modules.find((m) => m.id === aliasId) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从 output 块里抽自评维度名(用户向;不交 JSON) */
|
||||
export function extractSelfScoreDimensionNames(outputFence: string): string[] {
|
||||
const names: string[] = [];
|
||||
const re = /"名"\s*:\s*"([^"]+)"/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(outputFence)) !== null) {
|
||||
const name = m[1]!.trim();
|
||||
if (name && !names.includes(name)) names.push(name);
|
||||
}
|
||||
return names.slice(0, 8);
|
||||
}
|
||||
|
||||
function clip(text: string, max: number): string {
|
||||
const t = text.trim();
|
||||
if (t.length <= max) return t;
|
||||
return `${t.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从能力 prompt.md 抽「回答时附带本模块」的 markdown(无匹配块则空串)。
|
||||
*/
|
||||
export function formatDictateUserFacingBrief(params: {
|
||||
tag: string;
|
||||
module: ModuleCatalogEntry;
|
||||
promptMd: string;
|
||||
}): string {
|
||||
const sections = parseModulePromptSections(params.promptMd);
|
||||
const probe = getModuleSection(sections, "probe");
|
||||
const principles = getModuleSection(sections, "principles");
|
||||
const output = getModuleSection(sections, "output");
|
||||
const dims = output ? extractSelfScoreDimensionNames(output) : [];
|
||||
|
||||
if (!probe && !principles && dims.length === 0) return "";
|
||||
|
||||
const includes: string[] = [];
|
||||
if (probe) includes.push("探测与追问口径");
|
||||
if (principles) includes.push("交互原则");
|
||||
if (dims.length) includes.push(`自评关注(${dims.join("、")})`);
|
||||
|
||||
const parts: string[] = [
|
||||
`# 模块 · ${params.module.name}`,
|
||||
"",
|
||||
`回答时请附带本模块(落盘 tag:\`${params.tag}\`)。`,
|
||||
"",
|
||||
"本模块内容包括:",
|
||||
...includes.map((x) => `- ${x}`),
|
||||
"",
|
||||
"## 写入用户回复时做什么",
|
||||
`1. 用二级标题 \`## 模块 · ${params.module.name}\` 开一段(多模块时本段只写本模块)。`,
|
||||
"2. 确认:2~4 句复述本模块已落要点,让用户能判断对不对。",
|
||||
"3. 薄弱点:对照下方「自评关注」口头点 1 处不够硬的地方(不必交打分表或 JSON)。",
|
||||
"4. 追问:1~2 点;每点给建议选项或短场景,用户可直接采用或改写。材料已够则追问可空,改写「建议下一刀」。",
|
||||
"5. 不粘贴产物全文;不复述 tool JSON。",
|
||||
];
|
||||
|
||||
if (probe) {
|
||||
parts.push("", "## 探测与追问(本模块口径)", clip(probe, MAX_PROBE_CHARS));
|
||||
}
|
||||
if (principles) {
|
||||
parts.push("", "## 交互原则(摘)", clip(principles, MAX_PRINCIPLES_CHARS));
|
||||
}
|
||||
if (dims.length) {
|
||||
parts.push("", "## 自评关注", dims.map((d) => `- ${d}`).join("\n"));
|
||||
}
|
||||
|
||||
return clip(parts.join("\n"), MAX_BRIEF_CHARS);
|
||||
}
|
||||
|
||||
export function buildDictateInsertFeedback(params: {
|
||||
tag: string;
|
||||
catalog: ModuleCatalog | null | undefined;
|
||||
promptMd: string | null | undefined;
|
||||
}): DictateInsertFeedback | null {
|
||||
const module = resolveModuleForInsertTag(params.tag, params.catalog);
|
||||
if (!module || !params.promptMd?.trim()) return null;
|
||||
const text = formatDictateUserFacingBrief({
|
||||
tag: params.tag.trim(),
|
||||
module,
|
||||
promptMd: params.promptMd,
|
||||
});
|
||||
if (!text.trim()) return null;
|
||||
return {
|
||||
moduleId: module.id,
|
||||
moduleName: module.name,
|
||||
tag: params.tag.trim(),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载 catalog 全部能力的 insert 反馈索引(tag → 模块 markdown)。
|
||||
* 含 artifact 与别名 key。
|
||||
*/
|
||||
export async function buildDictateInsertFeedbackIndex(params: {
|
||||
catalog: ModuleCatalog;
|
||||
skillPackRoot: string;
|
||||
skillsRoot?: string;
|
||||
}): Promise<Map<string, string>> {
|
||||
const index = new Map<string, string>();
|
||||
const { catalog, skillPackRoot } = params;
|
||||
|
||||
await Promise.all(
|
||||
catalog.modules.map(async (module) => {
|
||||
const promptMd = await loadModulePrompt(
|
||||
skillPackRoot,
|
||||
module.id,
|
||||
params.skillsRoot,
|
||||
);
|
||||
if (!promptMd) return;
|
||||
const artifact = module.artifact?.trim();
|
||||
if (artifact) {
|
||||
const fb = buildDictateInsertFeedback({
|
||||
tag: artifact,
|
||||
catalog,
|
||||
promptMd,
|
||||
});
|
||||
if (fb) index.set(artifact, fb.text);
|
||||
}
|
||||
for (const [aliasTag, moduleId] of Object.entries(
|
||||
DICTATE_INSERT_TAG_ALIASES,
|
||||
)) {
|
||||
if (moduleId !== module.id) continue;
|
||||
const fb = buildDictateInsertFeedback({
|
||||
tag: aliasTag,
|
||||
catalog,
|
||||
promptMd,
|
||||
});
|
||||
if (fb) index.set(aliasTag, fb.text);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
export function lookupDictateInsertFeedback(
|
||||
index: Map<string, string> | null | undefined,
|
||||
tag: string,
|
||||
): string | undefined {
|
||||
const t = tag.trim();
|
||||
if (!t || !index?.size) return undefined;
|
||||
const direct = index.get(t);
|
||||
if (direct) return direct;
|
||||
const hash = t.indexOf("#");
|
||||
if (hash > 0) return index.get(t.slice(0, hash).trim());
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Boss 直聘式 · 落档/开玩时自动把产物 tag 挂到游玩 worker。
|
||||
* 对话落盘 · 落档/开玩时自动把产物 tag 挂到游玩 worker。
|
||||
* 只做「固定上下文绑定」;变量清单、可见性、分档映射仍靠对话产出,此处不发明。
|
||||
*/
|
||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||
@@ -59,7 +59,7 @@ const SKIP_BIND_TAGS = new Set([
|
||||
|
||||
/** 偏文风/呈现:有叙事转述时挂转述;否则仍挂主世界层 */
|
||||
const STYLE_TAG_RE =
|
||||
/叙事指南|文风|美学|纲领|禁忌|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/;
|
||||
/叙事指南|故事推进|文风|美学|纲领|禁忌|示例|模仿|正文组成|回复格式|监控栏|篇幅|结构/;
|
||||
|
||||
/** 这些配方开玩时刚需叙事转述(主世界出内容、转述出文风) */
|
||||
const RECIPES_REQUIRE_NARRATOR = new Set(["文本生成器"]);
|
||||
@@ -85,7 +85,7 @@ function shouldSkipBindTag(tag: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 从黑板收集 Boss 直聘产物(有正文的 用户.* / 设计.*) */
|
||||
/** 从黑板收集对话落盘产物(有正文的 用户.* / 设计.*) */
|
||||
export function collectDictateBindProducts(board: Blackboard): DictateProduct[] {
|
||||
const products: DictateProduct[] = [];
|
||||
for (const e of board.listTagIndex()) {
|
||||
@@ -162,7 +162,7 @@ function buildGmInserts(params: {
|
||||
o++,
|
||||
p.tag,
|
||||
"full",
|
||||
`Boss直聘固定产物〔order=${effectiveDictateOrder(p)}〕`,
|
||||
`对话落盘固定产物〔order=${effectiveDictateOrder(p)}〕`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -278,7 +278,7 @@ export function buildDictateContextOrder(params: {
|
||||
|
||||
return renumberContextOrder({
|
||||
schema: CONTEXT_ORDER_SCHEMA,
|
||||
brief: "Boss直聘落档自动挂载:固定产物 → 对话.历史 → 已有真值/投影 → 本轮输入",
|
||||
brief: "对话落盘落档自动挂载:固定产物 → 对话.历史 → 已有真值/投影 → 本轮输入",
|
||||
play_slots: params.playSlots,
|
||||
slots,
|
||||
});
|
||||
@@ -325,7 +325,7 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
|
||||
: JSON.stringify(
|
||||
{
|
||||
play_slots: playSlots,
|
||||
brief: "Boss直聘落档自动生成",
|
||||
brief: "对话落盘落档自动生成",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -339,14 +339,14 @@ export function bindDictateProductsToPlaySpec(board: Blackboard): DictatePlayBin
|
||||
}
|
||||
row = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
row = { play_slots: playSlots, brief: "Boss直聘落档自动生成" };
|
||||
row = { play_slots: playSlots, brief: "对话落盘落档自动生成" };
|
||||
}
|
||||
row.play_slots = playSlots;
|
||||
if (playSlots.narrator) {
|
||||
row.brief =
|
||||
typeof row.brief === "string" && String(row.brief).includes("转述")
|
||||
? row.brief
|
||||
: "Boss直聘:主世界出内容,叙事转述出文风";
|
||||
: "对话落盘:主世界出内容,叙事转述出文风";
|
||||
}
|
||||
const withSlots = JSON.stringify(row, null, 2);
|
||||
const workerSetJson = mergeContextOrderIntoWorkerSetJson(withSlots, contextOrder);
|
||||
|
||||
+55
-20
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Boss 直聘式 · 创作进料专用系统提示
|
||||
* 对话落盘 · 创作进料专用系统提示
|
||||
*
|
||||
* 拼装:本提示(system)+ 当前产物(按相对序)+ 全量对话。
|
||||
* 仅用于创作层;开玩后由游玩管线接棒。
|
||||
@@ -28,57 +28,92 @@ ${opts.moduleGuide.trim()}
|
||||
`
|
||||
: "";
|
||||
|
||||
return `你是 Boss 直聘式创作助手:把用户原话整理成可落盘规格。作用对象是本局创作上下文(对话可清空,产物保留)。
|
||||
return `你是对话落盘创作助手:把用户原话整理成可落盘规格,并用可见回复让用户确认、补洞、选下一刀。作用对象是本局创作上下文(对话可清空,产物保留)。
|
||||
|
||||
**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;不要假装在跑游玩回合。
|
||||
**本提示只用于创作。** 开玩后叙事由游玩 worker 执行;创作期只落规格与引导,不写游玩回合正文。
|
||||
|
||||
## 落盘节奏(先读)
|
||||
1. 对照【能力 · 何时落盘】与已有产物:条件成立才落对应能力;未成立 → 本轮不 insert / 不 declare 该能力产物。
|
||||
2. 每轮通常只推进一刀(一个能力或一组紧密相关的真值);用户一次说很多也不要批量抢跑下游。
|
||||
3. 配方顺序是骨架;具体能不能落,以各能力「何时用 / 何时不用」为准。
|
||||
1. 对照【能力 · 何时落盘】与已有产物:条件成立才落对应能力;未成立 → 本轮不 insert / 不 declare 该能力产物,改为短确认或问挡住的 1 点。
|
||||
2. **同轮可多次 insert / declare_***(一步并行或多个 tool 轮次都行):用户本轮材料已够、且多个能力的「何时用」都成立时,把这些都落掉;每个成功的 insert 各回一份 \`reply_module\`,可见回复按模块分段附带。
|
||||
3. **不抢跑未齐条件的下游**(这才是「别一口气铺满」的意思):条件未成立的能力本轮不落,写进「建议下一刀」等用户补料或上游齐了再落。配方顺序是骨架;能不能落以各能力「何时用 / 何时不用」为准。
|
||||
|
||||
## 落盘(仅允许 toolcall)
|
||||
禁止把设定/变量/映射全文只写在聊天里冒充已落盘。
|
||||
设定 / 变量 / 映射必须经 tool 写入;聊天只做确认与引导,不当落盘载体。
|
||||
|
||||
### insert — 不变型固定产物
|
||||
### insert — 写入/覆盖固定产物
|
||||
- position:\`用户.*\` / \`设计.*\`(优先用【能力 · 何时落盘】里的落盘 tag)
|
||||
- content:正文;order 可选(越小越靠前;越常改越大)
|
||||
- 勿用 insert 写变量目录或映射(用下面两个工具)
|
||||
- 同 position 再 insert = 整份覆盖改写;变量目录与映射改用 declare_variable / declare_map
|
||||
- **〔可反复〕须拆分**:生成规则 / 具体实例等每条增殖用 \`基名#唯一id\`(如 \`设计.生成规则#ex-girlfriend-card\`、\`设计.具体实例#lei-ying\`)。id 用 rule_id / batch_id / 姓名 slug。禁止反复 insert 基名覆盖旧条;改某一条才 insert 同一完整 tag。
|
||||
|
||||
### delete — 删除固定产物
|
||||
- position:已写入的 \`用户.*\` / \`设计.*\` tag,整份移除
|
||||
- 改内容用 insert,不要 delete 再重写除非用户要丢掉整块
|
||||
- 删单个变量字段 → undeclare_variable;删单条映射 → remove_map
|
||||
|
||||
### declare_variable — 跨轮真值
|
||||
- key / type / initial / user_visible(用户是否可见)
|
||||
- 只立必须跨轮记住的状态;派生文案不要立成可写变量
|
||||
- 时机对照「变量设计与更新规则」的何时用;美学未锚定前不要抢 declare
|
||||
- 时机对照「变量设计与更新规则」的何时用;美学未锚定前不 declare
|
||||
- 移除字段 → undeclare_variable
|
||||
|
||||
### declare_map — 真值→投影上下文
|
||||
- id / field(真值名)/ target_tag(\`上下文.*\` 或 \`大纲.*\`)/ bands[]
|
||||
- bands:\`{min,max,content}\` 区间(min 含 max 不含),或 \`{value,content}\`,或 \`{when:"default",content}\`
|
||||
- 槽位固定、内容随真值换档;旧档离开上下文
|
||||
- 移除一条 → remove_map(同 id 再 declare_map = 覆盖改写)
|
||||
|
||||
**自然语言回复**只写:短确认、是否这个、建议补充 A/B/C(须是条件已接近成立的下一刀)、提问。不要复述 tool 全文。
|
||||
## 对用户可见回复
|
||||
tool 跑完后写给用户看的 markdown。作用:确认本轮落了什么、点出薄弱处、用可选追问引导补洞或下一刀。
|
||||
|
||||
**分清:**
|
||||
执行顺序:
|
||||
1. 先完成本轮需要的 insert / declare_*。
|
||||
2. 收集各次 insert 成功返回的 \`reply_module\`(若有;每个模块一份独立 markdown)。
|
||||
3. 按下列规格写自然语言(粘贴产物全文或 tool JSON 都算失败):
|
||||
|
||||
**有 1 个 reply_module**
|
||||
- 用二级标题 \`## 模块 · {名称}\`(名称与该 reply_module 标题一致)
|
||||
- 段内按该模块的「写入用户回复时做什么」写满:确认要点 → 口头薄弱点 → 追问 1~2 点(每点含建议选项或短场景)
|
||||
|
||||
**有 N 个 reply_module(N≥2)**
|
||||
- 同轮多次 insert,或一步并行多个,都会各回一份 \`reply_module\`
|
||||
- 每个模块各写一块,标题互不相同的 \`## 模块 · {名称}\`;块与块之间空一行
|
||||
- 每一块只服务该模块;全部模块写完后,可另起一段写「建议下一刀」(仅 1 条、且条件已接近成立)
|
||||
|
||||
**没有 reply_module**
|
||||
- 用 2~4 句确认本轮写入的 tag 要点,并建议条件已近的下一刀;若落盘被挡住,只问挡住的 1 点
|
||||
|
||||
## 分清落盘目标
|
||||
- 正文组成(格式)→ insert \`设计.正文组成\`(须「回复呈现」等上游条件已满足,见能力表)
|
||||
- 开场白(内容)→ insert \`设计.开场白\`;落档/开玩同步 \`输出.开场白\`(收口;上游未齐不硬塞)
|
||||
- 多篇无关联短文(生产在游玩)→ 创作期钉 \`设计.模仿范例\` / \`设计.模仿要点\` / \`设计.叙事指南与故事推进\`(大纲扩写)
|
||||
- 好感分档性格等 → declare_variable + declare_map,不要 insert 一整张表进设定散文
|
||||
- 开场白(内容)→ insert \`设计.开场白\`;落档/开玩同步 \`输出.开场白\`(收口;上游未齐则先补上游,不硬塞开场)
|
||||
- 多篇无关联短文(生产在游玩)→ 创作期钉 \`设计.模仿范例\` / \`设计.模仿要点\` / \`设计.故事推进\`(大纲扩写);文风细则另 insert \`设计.叙事指南\`
|
||||
- 好感分档性格等 → declare_variable + declare_map;分档文案走映射,不 insert 整表散文
|
||||
- 代入名片(名字/身份/金手指)→ insert \`设计.主角设定\`(对照「主角设定」何时用;名字写成具体称呼,不用 @玩家 / 用户 / 主角)
|
||||
${recipeBlock}${moduleBlock}
|
||||
## 按用户本轮意图择一为主(仍须过「何时用」)
|
||||
落盘后的可见回复一律按「对用户可见回复」写;下列各节只约定本轮落什么。
|
||||
|
||||
### A · 钉体验 / 设定
|
||||
优先美学纲领与体验核心(条件总是先做);其它设定类 insert 仅当对应能力何时用成立。回复:确认本刀 + 建议条件已近的下一刀。
|
||||
优先美学纲领与体验核心(条件总是先做);其它设定类 insert 仅当对应能力何时用成立。
|
||||
|
||||
### B · 钉正文格式
|
||||
「回复呈现」等上游已齐时才 insert \`设计.正文组成\`。
|
||||
|
||||
### C · 生成开场白
|
||||
收口:先有正文组成且体验已锚定;insert \`设计.开场白\`(Markdown;岔路用 \`## 岔路\`)。
|
||||
**content 规格:** 正文必须含字面 \`@玩家\`(至少一处)作用户角色名位;禁止写死姓名;禁止全文只有「你」而零个 \`@玩家\`。缺则改写后再 insert。进游玩时程序替换 \`@玩家\`。
|
||||
收口:先有正文组成且体验已锚定;insert \`设计.开场白\`(岔路用 \`## 岔路\`)。
|
||||
**形状:** meta + 正文。有代入名片时,开头 YAML \`用户角色.名字\` / \`用户角色.简介\`,再 \`---\`,下面才是开场正文。不同开场可有不同用户角色。
|
||||
**正文格式:** 已有 \`设计.正文组成\` 时,正文必须是 present.v1 JSON(\`shell\` 与正文组成一致,主读写 \`blocks.body\`),不要只交裸 Markdown;尚未钉正文组成时才用 Markdown。
|
||||
**content 规格:** 正文至少一处字面 \`@玩家\` 作用户角色名位;姓名位用 \`@玩家\`,不写死具体姓名;全文不能只有「你」而零个 \`@玩家\`。缺则改写后再 insert。进游玩时程序替换 \`@玩家\`。
|
||||
进玩时若有用户角色,程序提示是否切换到这个预设。
|
||||
|
||||
### D · 钉变量与映射
|
||||
仅当「变量设计」何时用成立(体验须跨轮记住状态等):declare_variable → 有分档换文再 declare_map。勿因配方名叫数据化就抢跑 D。
|
||||
仅当「变量设计」何时用成立(体验须跨轮记住状态等):declare_variable → 有分档换文再 declare_map。配方名带「数据化」也不自动开跑 D。
|
||||
|
||||
### E · 文本生成器(创作收料 → 开玩挂载)
|
||||
必须用 insert 写齐游玩上下文:\`设计.模仿范例\`、\`设计.模仿要点\`、\`设计.叙事指南与故事推进\`(用户输入用法:大纲扩写;禁止扮演停笔)。缺任一就先补产物,不要说可以开玩。禁止在创作期写完整短文。开玩后程序只挂载这些产物:主世界出内容,转述出文风。
|
||||
必须用 insert 写齐游玩上下文:\`设计.模仿范例\`、\`设计.模仿要点\`、\`设计.故事推进\`(用户输入用法:大纲扩写;扮演停笔关掉)。文风细则对照「叙事指南」何时用再 insert \`设计.叙事指南\`。缺范例/要点/故事推进任一就先补产物,再说可以开玩。创作期不写完整短文。开玩后程序只挂载这些产物:主世界出内容,转述出文风。
|
||||
|
||||
### F · 快穿短局
|
||||
仅当本局配方是快穿短局,或用户只要一核短局。先 insert 美学纲领(这一核、怎么代入、何时等用户);代入有名字/简介则写进开场白的用户角色 meta(不单独 insert 主角设定)。核是变造法则再 insert 实现机制;有门控才 declare 至多三个真值。点名文风 → insert 叙事指南;点名推进/停笔 → insert 故事推进(条件独立)。核可玩即 insert 开场白:用户未点名分区/壳则跳过正文组成(覆盖 C),开玩吃默认散文。舞台骨架/生成规则/拓扑即使「何时用」成立也不 insert,把地点与规则折进美学核或开场白。用户改口要铺可经营世界 → 本局仍只收这一核,并告知改用「数据化跑团体验」或「回合推演」。
|
||||
|
||||
## 常用序感
|
||||
- 需求/美学等不变设定:靠前(负)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 对话落盘 · 可增殖产物 tag 拆分
|
||||
*
|
||||
* catalog 标 repeatable 的能力(生成规则 / 具体实例)共用同一 artifact 基名。
|
||||
* 对话里若反复 insert 基 tag,后写会整份覆盖先写。
|
||||
* 约定:基名#槽位(如 设计.具体实例#lei-ying),每条增殖各占一 tag。
|
||||
*/
|
||||
|
||||
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
||||
|
||||
export const REPEATABLE_TAG_SEP = "#";
|
||||
|
||||
/** 去掉 #槽位,得到能力 artifact 基名 */
|
||||
export function dictateProductFamily(tag: string): string {
|
||||
const t = tag.trim();
|
||||
if (!t) return "";
|
||||
const i = t.indexOf(REPEATABLE_TAG_SEP);
|
||||
return i >= 0 ? t.slice(0, i).trim() : t;
|
||||
}
|
||||
|
||||
/** 显式槽位;无 # 则空 */
|
||||
export function dictateProductSlot(tag: string): string {
|
||||
const t = tag.trim();
|
||||
const i = t.indexOf(REPEATABLE_TAG_SEP);
|
||||
if (i < 0) return "";
|
||||
return sanitizeRepeatableSlot(t.slice(i + 1));
|
||||
}
|
||||
|
||||
export function isDictateProductFamily(tag: string, family: string): boolean {
|
||||
return dictateProductFamily(tag) === family.trim();
|
||||
}
|
||||
|
||||
/** 槽位:英文 kebab / 短中文均可;去掉危险字符 */
|
||||
export function sanitizeRepeatableSlot(raw: string): string {
|
||||
return String(raw ?? "")
|
||||
.trim()
|
||||
.replace(/[\n\r\0#/\\]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
export function buildRepeatableProductTag(
|
||||
family: string,
|
||||
slot: string,
|
||||
): string {
|
||||
const base = family.trim();
|
||||
const s = sanitizeRepeatableSlot(slot);
|
||||
return s ? `${base}${REPEATABLE_TAG_SEP}${s}` : base;
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v !== "string") return undefined;
|
||||
const t = v.trim();
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
function bodyOfFragment(content: string): Record<string, unknown> | null {
|
||||
const parsed = tryParseJsonDoc(content);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const body = (parsed as Record<string, unknown>).正文;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从正文抽出建议槽位:生成规则 → rule_id;具体实例 → batch_id / 首条姓名。
|
||||
*/
|
||||
export function extractRepeatableSlotFromContent(
|
||||
family: string,
|
||||
content: string,
|
||||
): string | undefined {
|
||||
const body = bodyOfFragment(content);
|
||||
if (!body) return undefined;
|
||||
const base = family.trim();
|
||||
|
||||
if (base === "设计.生成规则" || /生成规则$/.test(base)) {
|
||||
const top =
|
||||
asString(body.rule_id) ?? asString(body.id) ?? asString(body.规则id);
|
||||
if (top) return sanitizeRepeatableSlot(top) || undefined;
|
||||
const rules = body.rules;
|
||||
if (Array.isArray(rules)) {
|
||||
for (const row of rules) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
const r = row as Record<string, unknown>;
|
||||
const id =
|
||||
asString(r.rule_id) ?? asString(r.id) ?? asString(r.规则id);
|
||||
if (id) return sanitizeRepeatableSlot(id) || undefined;
|
||||
}
|
||||
}
|
||||
const target = asString(body.target) ?? asString(body.对象) ?? asString(body.对象族);
|
||||
if (target) return sanitizeRepeatableSlot(target) || undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (base === "设计.具体实例" || /具体实例$/.test(base)) {
|
||||
const batch =
|
||||
asString(body.batch_id) ??
|
||||
asString(body.批次id) ??
|
||||
asString(body.id);
|
||||
if (batch) return sanitizeRepeatableSlot(batch) || undefined;
|
||||
const records = body.records;
|
||||
if (Array.isArray(records)) {
|
||||
for (const row of records) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
const r = row as Record<string, unknown>;
|
||||
const name =
|
||||
asString(r.姓名) ??
|
||||
asString(r.name) ??
|
||||
asString(r.名称) ??
|
||||
asString(r.id);
|
||||
if (name) return sanitizeRepeatableSlot(name) || undefined;
|
||||
}
|
||||
}
|
||||
const ruleId = asString(body.rule_id);
|
||||
if (ruleId) {
|
||||
return sanitizeRepeatableSlot(`${ruleId}-batch`) || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type ResolveRepeatableInsertResult =
|
||||
| { tag: string; remapped?: boolean; note?: string }
|
||||
| { error: string };
|
||||
|
||||
/**
|
||||
* 可增殖 insert:有槽位则落到 基名#槽位;无槽位且基名已有产物 → 拒绝覆盖。
|
||||
*/
|
||||
export function resolveRepeatableInsertTag(params: {
|
||||
tag: string;
|
||||
content: string;
|
||||
existingTags: readonly string[];
|
||||
repeatableFamilies: ReadonlySet<string>;
|
||||
}): ResolveRepeatableInsertResult {
|
||||
const raw = params.tag.trim();
|
||||
if (!raw) return { error: "position 为空" };
|
||||
|
||||
const family = dictateProductFamily(raw);
|
||||
if (!params.repeatableFamilies.has(family)) {
|
||||
return { tag: raw };
|
||||
}
|
||||
|
||||
const explicit = dictateProductSlot(raw);
|
||||
const extracted = extractRepeatableSlotFromContent(family, params.content);
|
||||
const slot = explicit || extracted || "";
|
||||
|
||||
const siblings = params.existingTags
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t && dictateProductFamily(t) === family);
|
||||
|
||||
if (!slot) {
|
||||
if (siblings.length > 0 && raw === family) {
|
||||
return {
|
||||
error: `「${family}」是可增殖产物,追加请用「${family}#唯一id」(生成规则用 rule_id,具体实例用 batch_id/姓名),不要覆盖基 tag。改某一条则 insert 同一完整 tag。`,
|
||||
};
|
||||
}
|
||||
return { tag: family };
|
||||
}
|
||||
|
||||
const resolved = buildRepeatableProductTag(family, slot);
|
||||
if (raw === family || raw !== resolved) {
|
||||
return {
|
||||
tag: resolved,
|
||||
remapped: raw !== resolved,
|
||||
note: raw !== resolved ? `可增殖已拆到 ${resolved}` : undefined,
|
||||
};
|
||||
}
|
||||
return { tag: resolved };
|
||||
}
|
||||
|
||||
/** 收集同族全部正文(供旁观抽样等仍按「一份生成规则」读的逻辑) */
|
||||
export function collectFamilyContents(
|
||||
entries: ReadonlyArray<{ tag: string; content: string }>,
|
||||
family: string,
|
||||
): string[] {
|
||||
const base = family.trim();
|
||||
return entries
|
||||
.filter((e) => isDictateProductFamily(e.tag, base) && e.content.trim())
|
||||
.map((e) => e.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把多份生成规则 context-fragment 合成一份(合并 rules[]),便于 findRuleBody。
|
||||
*/
|
||||
export function mergeGenerationRulesArtifacts(
|
||||
rawList: readonly string[],
|
||||
): string | undefined {
|
||||
const rules: unknown[] = [];
|
||||
let shell: Record<string, unknown> | null = null;
|
||||
for (const raw of rawList) {
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
continue;
|
||||
}
|
||||
const doc = parsed as Record<string, unknown>;
|
||||
if (!shell) shell = { ...doc };
|
||||
const body = doc.正文;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) continue;
|
||||
const rows = (body as Record<string, unknown>).rules;
|
||||
if (!Array.isArray(rows)) continue;
|
||||
for (const row of rows) rules.push(row);
|
||||
}
|
||||
if (!shell || rules.length === 0) {
|
||||
// 无法 JSON 合并时:按序拼接,抽样侧仍可能从单份命中
|
||||
const nonempty = rawList.map((s) => s.trim()).filter(Boolean);
|
||||
return nonempty.length ? nonempty.join("\n\n---\n\n") : undefined;
|
||||
}
|
||||
const body =
|
||||
shell.正文 && typeof shell.正文 === "object" && !Array.isArray(shell.正文)
|
||||
? { ...(shell.正文 as Record<string, unknown>), rules }
|
||||
: { rules };
|
||||
return JSON.stringify({ ...shell, 正文: body }, null, 2);
|
||||
}
|
||||
+66
-4
@@ -1,7 +1,7 @@
|
||||
import type { ToolDefinition } from "../llm/client.js";
|
||||
|
||||
/**
|
||||
* Boss 直聘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
|
||||
* 对话落盘创作期工具:产物 / 变量 / 映射均须 toolcall;聊天只做确认与建议。
|
||||
*/
|
||||
export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
{
|
||||
@@ -9,19 +9,19 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
function: {
|
||||
name: "insert",
|
||||
description:
|
||||
"写入不变型固定产物(设定/格式/开场等)。确认与建议只写在普通回复。用 order 标相对先后。",
|
||||
"写入或覆盖固定产物。同 position 再调用即整份改写。可增殖(生成规则/具体实例)须用 基名#唯一id 拆分,禁止反复写基名覆盖。成功时可能返回 reply_module:末尾可见回复须附带该模块;同轮多个则每个模块各写一块 ## 模块 · 名称。确认与追问写在普通回复,不写进本工具。order 标相对先后。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
position: {
|
||||
type: "string",
|
||||
description:
|
||||
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白。勿用本工具写变量目录/映射。",
|
||||
"产物 tag,须以「用户.」或「设计.」开头。例:用户.需求、设计.正文组成、设计.开场白;可增殖例:设计.生成规则#rule-id、设计.具体实例#batch-id。勿用本工具写变量目录/映射。",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description:
|
||||
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=可读开场 Markdown,必须含字面 @玩家(用户角色名位,禁止写死姓名)",
|
||||
"落入该位置的正文。设计.正文组成=格式 JSON;设计.开场白=meta + 正文(有正文组成时正文为 present.v1 JSON;否则 Markdown),必须含字面 @玩家(用户角色名位,禁止写死姓名)",
|
||||
},
|
||||
order: {
|
||||
type: "number",
|
||||
@@ -112,6 +112,68 @@ export const DICTATE_TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete",
|
||||
description:
|
||||
"删除一个已落盘的固定产物 tag(整份移除)。改内容请用 insert 覆盖;删变量字段用 undeclare_variable;删映射条目用 remove_map。勿用本工具写空 content 假装删除。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
position: {
|
||||
type: "string",
|
||||
description:
|
||||
"要删的产物 tag,须以「用户.」或「设计.」开头。例:设计.开场白。删整份变量目录/映射也可(设计.变量目录 / 设计.变量映射)。",
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "为何删除(一句话,可选)",
|
||||
},
|
||||
},
|
||||
required: ["position"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "undeclare_variable",
|
||||
description:
|
||||
"从设计.变量目录移除一个真值字段,并同步从变量.当前去掉该格。改初值/可见性请用 declare_variable。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: {
|
||||
type: "string",
|
||||
description: "要移除的字段名",
|
||||
},
|
||||
},
|
||||
required: ["key"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "remove_map",
|
||||
description:
|
||||
"从设计.变量映射按 id 移除一条映射;若无其它映射共用其投影 tag,则一并删除该投影 tag。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "映射 id(与 declare_map 时一致)",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
|
||||
+185
-5
@@ -12,11 +12,14 @@ import {
|
||||
type DictateChatTurn,
|
||||
type DictateProduct,
|
||||
} from "./types.js";
|
||||
import { DICTATE_MULTI_MODULE_REPLY_HINT } from "./insert-feedback.js";
|
||||
import { resolveRepeatableInsertTag } from "./repeatable-tags.js";
|
||||
import {
|
||||
VARIABLE_CATALOG_TAG,
|
||||
parseVariableCatalog,
|
||||
serializeVariableCatalog,
|
||||
upsertVariableField,
|
||||
removeVariableField,
|
||||
type UpsertVariableInput,
|
||||
} from "../skills/variable-catalog.js";
|
||||
import {
|
||||
@@ -24,31 +27,46 @@ import {
|
||||
parseValueMapDoc,
|
||||
serializeValueMapDoc,
|
||||
upsertValueMapEntry,
|
||||
removeValueMapEntry,
|
||||
type UpsertMapInput,
|
||||
} from "../skills/value-map.js";
|
||||
import {
|
||||
parseTableDoc,
|
||||
stringifyTableDoc,
|
||||
} from "../blackboard/table-cells.js";
|
||||
|
||||
export type DictateTurnHandlers = {
|
||||
listProducts: () => DictateProduct[];
|
||||
/** order 省略时由实现沿用原序或默认 */
|
||||
writeProduct: (tag: string, content: string, order?: number) => void;
|
||||
/** 删除固定产物 tag;返回是否曾存在 */
|
||||
deleteProduct: (tag: string) => boolean;
|
||||
/** 读/写变量目录与映射(程序校验后的 JSON) */
|
||||
readTag: (tag: string) => string | undefined;
|
||||
writeTag: (tag: string, content: string) => void;
|
||||
/** 删除任意黑板 tag(投影 / 变量.当前 等) */
|
||||
deleteTag: (tag: string) => boolean;
|
||||
clearDialogue: () => void;
|
||||
onToolCall?: (name: string, detail: string) => void;
|
||||
onThinkingDelta?: (delta: string) => void;
|
||||
onThinkingDone?: (text: string) => void;
|
||||
/**
|
||||
* insert 成功后:按 tag 反查能力,返回独立「回复模块」markdown。
|
||||
* 程序写入 tool result 的 reply_module;同轮多个须在末尾回复中全部附带。
|
||||
*/
|
||||
lookupInsertFeedback?: (tag: string) => string | undefined;
|
||||
};
|
||||
|
||||
export type DictateTurnResult = {
|
||||
reply: string;
|
||||
iterations: number;
|
||||
wroteTags: string[];
|
||||
deletedTags: string[];
|
||||
clearedDialogue: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 跑一轮 Boss 直聘整理:任务 messages 由本模块拼;preset 夹心由 LLM 包装层统一装配。
|
||||
* 跑一轮对话落盘整理:任务 messages 由本模块拼;preset 夹心由 LLM 包装层统一装配。
|
||||
*/
|
||||
export async function runDictateTurn(params: {
|
||||
llm: LlmProvider;
|
||||
@@ -59,12 +77,18 @@ export async function runDictateTurn(params: {
|
||||
recipeBrief?: string;
|
||||
/** modules catalog 的何时落盘块(formatModuleCatalogForDictate) */
|
||||
moduleGuide?: string;
|
||||
/** catalog 中 repeatable 能力的 artifact 基名(设计.生成规则 等) */
|
||||
repeatableFamilies?: ReadonlySet<string> | readonly string[];
|
||||
driver?: AgentDriver;
|
||||
}): Promise<DictateTurnResult> {
|
||||
const driver = params.driver ?? createLocalLlmDriver(params.llm);
|
||||
const wroteTags: string[] = [];
|
||||
const deletedTags: string[] = [];
|
||||
let clearedDialogue = false;
|
||||
let dialogue = [...params.dialogue];
|
||||
const repeatableFamilies = new Set(
|
||||
[...(params.repeatableFamilies ?? [])].map((t) => String(t).trim()).filter(Boolean),
|
||||
);
|
||||
|
||||
const systemPrompt =
|
||||
params.systemPrompt ??
|
||||
@@ -85,14 +109,15 @@ export async function runDictateTurn(params: {
|
||||
messages: userBlock,
|
||||
tools: DICTATE_TOOL_DEFINITIONS,
|
||||
caller: "dictate_agent",
|
||||
label: "Boss直聘",
|
||||
label: "对话落盘",
|
||||
onThinkingDelta: params.handlers.onThinkingDelta,
|
||||
onThinkingDone: params.handlers.onThinkingDone,
|
||||
handleStep: (calls) => {
|
||||
const moduleNamesThisStep: string[] = [];
|
||||
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();
|
||||
let tag = String(args.position ?? args.tag ?? "").trim();
|
||||
const content = String(args.content ?? "");
|
||||
if (tag === VARIABLE_CATALOG_TAG || tag === VALUE_MAP_TAG) {
|
||||
const err = `请用 declare_variable / declare_map,不要 insert ${tag}`;
|
||||
@@ -109,18 +134,47 @@ export async function runDictateTurn(params: {
|
||||
params.handlers.onToolCall?.(call.name, err);
|
||||
return { callId: call.id, content: JSON.stringify({ error: err }) };
|
||||
}
|
||||
if (repeatableFamilies.size) {
|
||||
const resolved = resolveRepeatableInsertTag({
|
||||
tag,
|
||||
content,
|
||||
existingTags: params.handlers.listProducts().map((p) => p.tag),
|
||||
repeatableFamilies,
|
||||
});
|
||||
if ("error" in resolved) {
|
||||
params.handlers.onToolCall?.(call.name, resolved.error);
|
||||
return {
|
||||
callId: call.id,
|
||||
content: JSON.stringify({ error: resolved.error }),
|
||||
};
|
||||
}
|
||||
tag = resolved.tag;
|
||||
}
|
||||
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);
|
||||
const replyModule =
|
||||
params.handlers.lookupInsertFeedback?.(tag)?.trim() || undefined;
|
||||
if (replyModule) {
|
||||
const title =
|
||||
replyModule.match(/^#\s*模块\s*·\s*(.+)$/m)?.[1]?.trim() || tag;
|
||||
moduleNamesThisStep.push(title);
|
||||
}
|
||||
return {
|
||||
callId: call.id,
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
position: tag,
|
||||
...(order !== undefined ? { order } : {}),
|
||||
...(replyModule
|
||||
? {
|
||||
reply_module: replyModule,
|
||||
reply_hint: DICTATE_MULTI_MODULE_REPLY_HINT,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -185,6 +239,108 @@ export async function runDictateTurn(params: {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (call.name === "delete") {
|
||||
const tag = String(args.position ?? args.tag ?? "").trim();
|
||||
const reason = String(args.reason ?? "").trim();
|
||||
if (!isAllowedProductTag(tag)) {
|
||||
const err = `拒绝删除:position 必须以「用户.」或「设计.」开头(收到:${tag || "空"})`;
|
||||
params.handlers.onToolCall?.(call.name, err);
|
||||
return { callId: call.id, content: JSON.stringify({ error: err }) };
|
||||
}
|
||||
const existed = params.handlers.deleteProduct(tag);
|
||||
if (!existed) {
|
||||
const err = `无产物 ${tag}`;
|
||||
params.handlers.onToolCall?.(call.name, err);
|
||||
return { callId: call.id, content: JSON.stringify({ error: err }) };
|
||||
}
|
||||
deletedTags.push(tag);
|
||||
const detail = reason ? `${tag}(${reason})` : tag;
|
||||
params.handlers.onToolCall?.(call.name, detail);
|
||||
return {
|
||||
callId: call.id,
|
||||
content: JSON.stringify({ ok: true, deleted: tag }),
|
||||
};
|
||||
}
|
||||
if (call.name === "undeclare_variable") {
|
||||
const key = String(args.key ?? "").trim();
|
||||
const current = parseVariableCatalog(
|
||||
params.handlers.readTag(VARIABLE_CATALOG_TAG),
|
||||
);
|
||||
const { doc, error } = removeVariableField(current, key);
|
||||
if (error) {
|
||||
params.handlers.onToolCall?.(call.name, error);
|
||||
return { callId: call.id, content: JSON.stringify({ error }) };
|
||||
}
|
||||
if (doc.fields.length === 0) {
|
||||
params.handlers.deleteTag(VARIABLE_CATALOG_TAG);
|
||||
} else {
|
||||
params.handlers.writeTag(
|
||||
VARIABLE_CATALOG_TAG,
|
||||
serializeVariableCatalog(doc),
|
||||
);
|
||||
}
|
||||
// 同步从 变量.当前 / 运行.初始变量 去掉该格
|
||||
for (const tableTag of ["变量.当前", "运行.初始变量"] as const) {
|
||||
const raw = params.handlers.readTag(tableTag);
|
||||
const table = parseTableDoc(raw);
|
||||
if (!table?.rows.some((r) => r.key === key)) continue;
|
||||
const next = {
|
||||
rows: table.rows.filter((r) => r.key !== key),
|
||||
};
|
||||
if (next.rows.length === 0) {
|
||||
params.handlers.deleteTag(tableTag);
|
||||
} else {
|
||||
params.handlers.writeTag(tableTag, stringifyTableDoc(next));
|
||||
}
|
||||
}
|
||||
deletedTags.push(`${VARIABLE_CATALOG_TAG}#${key}`);
|
||||
params.handlers.onToolCall?.(call.name, key);
|
||||
return {
|
||||
callId: call.id,
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
key,
|
||||
fields: doc.fields.length,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (call.name === "remove_map") {
|
||||
const id = String(args.id ?? "").trim();
|
||||
const current = parseValueMapDoc(
|
||||
params.handlers.readTag(VALUE_MAP_TAG),
|
||||
);
|
||||
const { doc, error, removed } = removeValueMapEntry(current, id);
|
||||
if (error || !removed) {
|
||||
const err = error ?? `映射无 id ${id}`;
|
||||
params.handlers.onToolCall?.(call.name, err);
|
||||
return { callId: call.id, content: JSON.stringify({ error: err }) };
|
||||
}
|
||||
if (doc.maps.length === 0) {
|
||||
params.handlers.deleteTag(VALUE_MAP_TAG);
|
||||
} else {
|
||||
params.handlers.writeTag(VALUE_MAP_TAG, serializeValueMapDoc(doc));
|
||||
}
|
||||
const stillUsed = doc.maps.some(
|
||||
(m) => m.target_tag === removed.target_tag,
|
||||
);
|
||||
if (!stillUsed) {
|
||||
params.handlers.deleteTag(removed.target_tag);
|
||||
}
|
||||
deletedTags.push(`${VALUE_MAP_TAG}#${id}`);
|
||||
params.handlers.onToolCall?.(
|
||||
call.name,
|
||||
`${id} → ${removed.target_tag}`,
|
||||
);
|
||||
return {
|
||||
callId: call.id,
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
id,
|
||||
cleared_target: stillUsed ? null : removed.target_tag,
|
||||
maps: doc.maps.length,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (call.name === "clear_dialogue") {
|
||||
const reason = String(args.reason ?? "").trim() || "上下文过长";
|
||||
params.handlers.clearDialogue();
|
||||
@@ -204,6 +360,22 @@ export async function runDictateTurn(params: {
|
||||
params.handlers.onToolCall?.(call.name, err);
|
||||
return { callId: call.id, content: JSON.stringify({ error: err }) };
|
||||
});
|
||||
|
||||
if (moduleNamesThisStep.length > 1) {
|
||||
const roster = moduleNamesThisStep.join("、");
|
||||
for (const r of results) {
|
||||
try {
|
||||
const parsed = JSON.parse(r.content) as Record<string, unknown>;
|
||||
if (typeof parsed.reply_module === "string") {
|
||||
parsed.reply_hint = `${DICTATE_MULTI_MODULE_REPLY_HINT} 本步已带回模块:${roster}。`;
|
||||
r.content = JSON.stringify(parsed);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "continue" as const, results };
|
||||
},
|
||||
});
|
||||
@@ -213,8 +385,15 @@ export async function runDictateTurn(params: {
|
||||
reply = run.stop.content.trim();
|
||||
}
|
||||
if (!reply) {
|
||||
if (wroteTags.length) {
|
||||
reply = `已写入:${[...new Set(wroteTags)].join("、")}。还有要补充的吗?`;
|
||||
if (wroteTags.length || deletedTags.length) {
|
||||
const parts: string[] = [];
|
||||
if (wroteTags.length) {
|
||||
parts.push(`已写入:${[...new Set(wroteTags)].join("、")}`);
|
||||
}
|
||||
if (deletedTags.length) {
|
||||
parts.push(`已删除:${[...new Set(deletedTags)].join("、")}`);
|
||||
}
|
||||
reply = `${parts.join(";")}。还有要补充的吗?`;
|
||||
} else if (clearedDialogue) {
|
||||
reply = "对话已清空,产物都还在。可以继续说。";
|
||||
} else {
|
||||
@@ -226,6 +405,7 @@ export async function runDictateTurn(params: {
|
||||
reply,
|
||||
iterations: run.iterations,
|
||||
wroteTags: [...new Set(wroteTags)],
|
||||
deletedTags: [...new Set(deletedTags)],
|
||||
clearedDialogue,
|
||||
};
|
||||
}
|
||||
|
||||
+13
-8
@@ -33,17 +33,22 @@ export function isAllowedProductTag(tag: string): boolean {
|
||||
/**
|
||||
* 未显式给 order 时的默认相对序。
|
||||
* 越稳定/重要越靠前(更负);越常改越靠后(更正)。
|
||||
* 可增殖拆分 tag(基名#槽)按基名归类。
|
||||
*/
|
||||
export function defaultDictateOrder(tag: string): number {
|
||||
const t = tag.trim();
|
||||
if (t === "用户.需求") return -40;
|
||||
if (t === "设计.变量目录" || t === "设计.变量映射") return -25;
|
||||
if (t === "设计.模仿范例" || t === "设计.模仿要点") return -28;
|
||||
if (/美学|纲领|禁忌|示例|模仿/.test(t)) return -30;
|
||||
if (/文风|叙事指南|篇幅|结构/.test(t)) return -20;
|
||||
if (/短文集|短文\./.test(t)) return 10;
|
||||
if (/正文组成|回复格式/.test(t)) return 0;
|
||||
if (/开场白/.test(t)) return 20;
|
||||
const family = t.includes("#") ? t.slice(0, t.indexOf("#")).trim() : t;
|
||||
if (family === "用户.需求" || t === "用户.需求") return -40;
|
||||
if (family === "设计.变量目录" || family === "设计.变量映射") return -25;
|
||||
if (family === "设计.模仿范例" || family === "设计.模仿要点") return -28;
|
||||
if (/美学|纲领|禁忌|示例|模仿/.test(family)) return -30;
|
||||
if (/主角设定/.test(family)) return -26;
|
||||
if (/文风|叙事指南|故事推进|篇幅|结构/.test(family)) return -20;
|
||||
if (/生成规则/.test(family)) return -6;
|
||||
if (/具体实例/.test(family)) return -4;
|
||||
if (/短文集|短文\./.test(family)) return 10;
|
||||
if (/正文组成|回复格式/.test(family)) return 0;
|
||||
if (/开场白/.test(family)) return 20;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ export type CompleteOptions = {
|
||||
generation?: GenerationParameters;
|
||||
/** 统计用途,如 main_agent / worker:write-rules */
|
||||
caller?: string;
|
||||
/** 日志短名,如「对话落盘 第2轮」;缺则用 caller */
|
||||
label?: string;
|
||||
/** 取消进行中的 LLM 请求(停止并重试) */
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 模型请求日志:发送一行、完成/中断/失败一行。
|
||||
* 只写任务骨架(轮次、待消化什么、可调工具、上下文体积),不写正文。
|
||||
*/
|
||||
import type {
|
||||
ChatMessage,
|
||||
CompleteResult,
|
||||
CompleteWithToolsResult,
|
||||
ToolDefinition,
|
||||
} from "./client.js";
|
||||
import { isAbortError } from "./run-abort.js";
|
||||
import { debugLog, labelCaller, labelLlmMode, labelTool } from "../log.js";
|
||||
|
||||
export type LlmRequestLogInput = {
|
||||
kind: string;
|
||||
caller?: string;
|
||||
label?: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
};
|
||||
|
||||
function requestTitle(caller: string | undefined, label: string | undefined): string {
|
||||
const task = label?.trim();
|
||||
const who = labelCaller(caller);
|
||||
if (task && (task === who || task.startsWith(`${who} `) || task.startsWith(`${who}·`))) {
|
||||
return task;
|
||||
}
|
||||
if (task) return `${who} · ${task}`;
|
||||
return who;
|
||||
}
|
||||
|
||||
function countRoles(messages: ChatMessage[]): string {
|
||||
const n = { system: 0, user: 0, assistant: 0, tool: 0 };
|
||||
for (const m of messages) {
|
||||
if (m.role === "system") n.system += 1;
|
||||
else if (m.role === "user") n.user += 1;
|
||||
else if (m.role === "assistant") n.assistant += 1;
|
||||
else if (m.role === "tool") n.tool += 1;
|
||||
}
|
||||
const bits = [
|
||||
n.system ? `系统${n.system}` : "",
|
||||
n.user ? `用户${n.user}` : "",
|
||||
n.assistant ? `助手${n.assistant}` : "",
|
||||
n.tool ? `工具${n.tool}` : "",
|
||||
].filter(Boolean);
|
||||
return bits.length ? `条=${bits.join("+")}` : "条=0";
|
||||
}
|
||||
|
||||
function messageChars(m: ChatMessage): number {
|
||||
let n = typeof m.content === "string" ? m.content.length : 0;
|
||||
if (m.role === "assistant" && m.tool_calls) {
|
||||
for (const tc of m.tool_calls) {
|
||||
n += (tc.function?.name?.length ?? 0) + (tc.function?.arguments?.length ?? 0);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function formatContextSize(messages: ChatMessage[]): string {
|
||||
const n = messages.reduce((sum, m) => sum + messageChars(m), 0);
|
||||
if (n >= 10_000) {
|
||||
const wan = n / 10_000;
|
||||
const text = wan >= 10 ? String(Math.round(wan)) : wan.toFixed(1).replace(/\.0$/, "");
|
||||
return `上下文≈${text}万字`;
|
||||
}
|
||||
return `上下文=${n}字`;
|
||||
}
|
||||
|
||||
function nearestAssistantToolNames(messages: ChatMessage[]): string[] {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m.role === "assistant" && m.tool_calls?.length) {
|
||||
return m.tool_calls.map((tc) => tc.function.name);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 这次请求在等什么:用户话、工具结果、还是空上下文。不含正文。 */
|
||||
export function describeRequestAwaiting(messages: ChatMessage[]): string {
|
||||
if (!messages.length) return "空上下文";
|
||||
const last = messages[messages.length - 1];
|
||||
if (last.role === "user") return "待答用户";
|
||||
if (last.role === "tool") {
|
||||
const names = nearestAssistantToolNames(messages);
|
||||
return names.length
|
||||
? `待消化 ${names.map((n) => labelTool(n)).join("、")}`
|
||||
: "待消化工具";
|
||||
}
|
||||
if (last.role === "assistant") {
|
||||
const names =
|
||||
last.tool_calls?.map((tc) => tc.function.name) ?? [];
|
||||
return names.length
|
||||
? `续轮 ${names.map((n) => labelTool(n)).join("、")}`
|
||||
: "待续助手";
|
||||
}
|
||||
if (last.role === "system") return "仅系统";
|
||||
return last.role;
|
||||
}
|
||||
|
||||
function formatAvailableTools(tools: ToolDefinition[] | undefined): string {
|
||||
if (!tools?.length) return "";
|
||||
const names = tools.map((t) => labelTool(t.function.name));
|
||||
if (names.length <= 6) return `可调 ${names.join("、")}`;
|
||||
return `可调 ${names.slice(0, 5).join("、")} 等${names.length}个`;
|
||||
}
|
||||
|
||||
export function formatLlmSendLine(input: LlmRequestLogInput): string {
|
||||
const title = requestTitle(input.caller, input.label);
|
||||
const mode = labelLlmMode(input.kind);
|
||||
const bits = [
|
||||
`发送 ${title} · ${mode}`,
|
||||
describeRequestAwaiting(input.messages),
|
||||
countRoles(input.messages),
|
||||
formatAvailableTools(input.tools),
|
||||
formatContextSize(input.messages),
|
||||
];
|
||||
return bits.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function formatUsage(usage: CompleteResult["usage"]): string {
|
||||
if (!usage) return "";
|
||||
const cache =
|
||||
usage.cachedTokens != null && usage.cachedTokens > 0
|
||||
? ` 缓存=${usage.cachedTokens}`
|
||||
: "";
|
||||
return `用量=${usage.promptTokens}+${usage.completionTokens}${cache}`;
|
||||
}
|
||||
|
||||
export function formatLlmFinishLine(
|
||||
input: LlmRequestLogInput,
|
||||
elapsedMs: number,
|
||||
result: CompleteResult | CompleteWithToolsResult,
|
||||
): string {
|
||||
const title = requestTitle(input.caller, input.label);
|
||||
const mode = labelLlmMode(input.kind);
|
||||
const toolCalls =
|
||||
"toolCalls" in result
|
||||
? result.toolCalls.map((t) => labelTool(t.name)).join("、") || "无"
|
||||
: undefined;
|
||||
const bits = [
|
||||
`完成 ${title} · ${mode}`,
|
||||
`${elapsedMs}毫秒`,
|
||||
result.model ? `模型=${result.model}` : "",
|
||||
`思维链=${result.reasoning?.trim().length ?? 0}字`,
|
||||
`正文=${result.content?.length ?? 0}字`,
|
||||
toolCalls != null ? `调用=${toolCalls}` : "",
|
||||
formatUsage(result.usage),
|
||||
];
|
||||
return bits.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function formatLlmAbortLine(
|
||||
input: Pick<LlmRequestLogInput, "kind" | "caller" | "label">,
|
||||
elapsedMs: number,
|
||||
): string {
|
||||
const title = requestTitle(input.caller, input.label);
|
||||
const mode = labelLlmMode(input.kind);
|
||||
return `中断 ${title} · ${mode} ${elapsedMs}毫秒`;
|
||||
}
|
||||
|
||||
export function formatLlmFailLine(
|
||||
input: Pick<LlmRequestLogInput, "kind" | "caller" | "label">,
|
||||
elapsedMs: number,
|
||||
err: unknown,
|
||||
): string {
|
||||
const title = requestTitle(input.caller, input.label);
|
||||
const mode = labelLlmMode(input.kind);
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return `失败 ${title} · ${mode} ${elapsedMs}毫秒 ${detail.slice(0, 160)}`;
|
||||
}
|
||||
|
||||
export async function loggedLlmRequest<
|
||||
T extends CompleteResult | CompleteWithToolsResult,
|
||||
>(input: LlmRequestLogInput, run: () => Promise<T>): Promise<T> {
|
||||
debugLog("llm", formatLlmSendLine(input));
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const result = await run();
|
||||
debugLog("llm", formatLlmFinishLine(input, Date.now() - t0, result));
|
||||
return result;
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - t0;
|
||||
if (isAbortError(err)) {
|
||||
debugLog("llm", formatLlmAbortLine(input, elapsed));
|
||||
} else {
|
||||
debugLog("llm", formatLlmFailLine(input, elapsed, err));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
+31
-65
@@ -16,7 +16,7 @@ import {
|
||||
buildContextTrace,
|
||||
type LlmContextTrace,
|
||||
} from "../types/context-trace.js";
|
||||
import { debugLog, labelCaller, labelLlmMode, labelTool } from "../log.js";
|
||||
import { loggedLlmRequest } from "./request-log.js";
|
||||
|
||||
export type LlmTrackingContext = {
|
||||
sessionId?: string;
|
||||
@@ -73,54 +73,6 @@ function recordUsage(
|
||||
ctx.pendingUsage = toMessageTokenUsage(record);
|
||||
}
|
||||
|
||||
function summarizeComplete(result: CompleteResult): string {
|
||||
const bits = [
|
||||
result.model ? `模型=${result.model}` : "",
|
||||
`思维链=${result.reasoning?.trim().length ?? 0}字`,
|
||||
`正文=${result.content?.length ?? 0}字`,
|
||||
];
|
||||
if (result.usage) {
|
||||
bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`);
|
||||
}
|
||||
return bits.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function summarizeTools(result: CompleteWithToolsResult): string {
|
||||
const tools =
|
||||
result.toolCalls.map((t) => labelTool(t.name)).join("、") || "无";
|
||||
const bits = [
|
||||
result.model ? `模型=${result.model}` : "",
|
||||
`思维链=${result.reasoning?.trim().length ?? 0}字`,
|
||||
`正文=${result.content?.length ?? 0}字`,
|
||||
`工具=${tools}`,
|
||||
];
|
||||
if (result.usage) {
|
||||
bits.push(`用量=${result.usage.promptTokens}+${result.usage.completionTokens}`);
|
||||
}
|
||||
return bits.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
async function loggedLlm<T>(
|
||||
kind: string,
|
||||
caller: string | undefined,
|
||||
run: () => Promise<T>,
|
||||
summarize: (result: T) => string,
|
||||
): Promise<T> {
|
||||
const who = labelCaller(caller);
|
||||
const mode = labelLlmMode(kind);
|
||||
const t0 = Date.now();
|
||||
debugLog("llm", `开始 ${who} · ${mode}`);
|
||||
try {
|
||||
const result = await run();
|
||||
debugLog("llm", `结束 ${who} · ${mode} ${Date.now() - t0}毫秒 ${summarize(result)}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
debugLog("llm", `失败 ${who} · ${mode} ${Date.now() - t0}毫秒 ${detail.slice(0, 160)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export class TokenTrackingProvider implements LlmProvider {
|
||||
constructor(
|
||||
private readonly inner: LlmProvider,
|
||||
@@ -131,9 +83,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
messages: Parameters<LlmProvider["complete"]>[0],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
return loggedLlm(
|
||||
"complete",
|
||||
options?.caller,
|
||||
return loggedLlmRequest(
|
||||
{
|
||||
kind: "complete",
|
||||
caller: options?.caller,
|
||||
label: options?.label,
|
||||
messages,
|
||||
},
|
||||
async () => {
|
||||
const result = await this.inner.complete(messages, options);
|
||||
const ctx = this.getContext();
|
||||
@@ -150,7 +106,6 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
}
|
||||
return result;
|
||||
},
|
||||
summarizeComplete,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,9 +121,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
if (result.content) callbacks.onContentDelta?.(result.content);
|
||||
return result;
|
||||
}
|
||||
return loggedLlm(
|
||||
"stream",
|
||||
options?.caller,
|
||||
return loggedLlmRequest(
|
||||
{
|
||||
kind: "stream",
|
||||
caller: options?.caller,
|
||||
label: options?.label,
|
||||
messages,
|
||||
},
|
||||
async () => {
|
||||
let reasoningBuf = "";
|
||||
const result = await inner.completeStream!(messages, options, {
|
||||
@@ -194,7 +153,6 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
}
|
||||
return result;
|
||||
},
|
||||
summarizeComplete,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,9 +160,14 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
messages: Parameters<LlmProvider["completeWithTools"]>[0],
|
||||
options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
return loggedLlm(
|
||||
"tools",
|
||||
options.caller,
|
||||
return loggedLlmRequest(
|
||||
{
|
||||
kind: "tools",
|
||||
caller: options.caller,
|
||||
label: options.label,
|
||||
messages,
|
||||
tools: options.tools,
|
||||
},
|
||||
async () => {
|
||||
const result = await this.inner.completeWithTools(messages, options);
|
||||
const ctx = this.getContext();
|
||||
@@ -221,7 +184,6 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
}
|
||||
return result;
|
||||
},
|
||||
summarizeTools,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,9 +196,14 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
if (!inner.completeWithToolsStream) {
|
||||
return this.completeWithTools(messages, options);
|
||||
}
|
||||
return loggedLlm(
|
||||
"tools-stream",
|
||||
options.caller,
|
||||
return loggedLlmRequest(
|
||||
{
|
||||
kind: "tools-stream",
|
||||
caller: options.caller,
|
||||
label: options.label,
|
||||
messages,
|
||||
tools: options.tools,
|
||||
},
|
||||
async () => {
|
||||
let reasoningBuf = "";
|
||||
const result = await inner.completeWithToolsStream!(messages, options, {
|
||||
@@ -262,7 +229,6 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
}
|
||||
return result;
|
||||
},
|
||||
summarizeTools,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -97,6 +97,17 @@ const TOOL_LABEL: Record<string, string> = {
|
||||
ask_user: "问用户",
|
||||
review_blackboard: "审黑板",
|
||||
finish: "结束",
|
||||
insert: "写入产物",
|
||||
write_product: "写入产物",
|
||||
delete: "删除产物",
|
||||
declare_variable: "声明变量",
|
||||
undeclare_variable: "移除变量",
|
||||
declare_map: "声明映射",
|
||||
remove_map: "移除映射",
|
||||
clear_dialogue: "清空对话",
|
||||
chance: "机遇裁定",
|
||||
submit_present_packet: "提交呈现",
|
||||
submit_worker_result: "提交结果",
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
@@ -202,6 +213,11 @@ export function labelCaller(caller: string | undefined): string {
|
||||
const who = caller?.trim() || "未知";
|
||||
if (who === "main_agent" || who.startsWith("main_agent:")) return "总管";
|
||||
if (who === "intake_extract") return "需求抽取";
|
||||
if (who === "dictate_agent") return "对话落盘";
|
||||
if (who === "preset-probe") return "预设探测";
|
||||
if (who.startsWith("model-compare:")) {
|
||||
return `模型对比 ${who.slice("model-compare:".length)}`;
|
||||
}
|
||||
if (who.startsWith("worker:")) return `执行单元 ${who.slice("worker:".length)}`;
|
||||
if (who === "unknown") return "未知";
|
||||
return who;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 开场白 meta 里的用户角色(名字+简介)是本条开场的初始设定。
|
||||
* 用户选用后只覆盖本局 @玩家 / @人设,不写进全局角色列表。
|
||||
*/
|
||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||
import {
|
||||
CREATION_PLACEHOLDER_NAME,
|
||||
personaForLifecycle,
|
||||
type PersonaDirective,
|
||||
} from "./store.js";
|
||||
import {
|
||||
OPENING_PERSONA_CHOICE_TAG,
|
||||
OPENING_PERSONA_TAG,
|
||||
parseOpeningSealPayload,
|
||||
readSealedOpeningPersona,
|
||||
splitOpeningDocument,
|
||||
} from "../skills/opening-seal.js";
|
||||
|
||||
export const PROTAGONIST_TAG = "设计.主角设定";
|
||||
|
||||
const RESERVED_NAMES = new Set(["@玩家", "用户", "主角", CREATION_PLACEHOLDER_NAME]);
|
||||
|
||||
export type ProtagonistSpec = {
|
||||
name: string;
|
||||
background: string;
|
||||
identity: string;
|
||||
cheat: string;
|
||||
extra: string;
|
||||
};
|
||||
|
||||
export type SessionProtagonist = {
|
||||
name: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export function parseProtagonistSpec(
|
||||
raw: string | null | undefined,
|
||||
): ProtagonistSpec | null {
|
||||
const text = raw?.trim() ?? "";
|
||||
if (!text) return null;
|
||||
const fromJson = parseFromJson(text);
|
||||
if (fromJson) return fromJson;
|
||||
return parseFromProse(text);
|
||||
}
|
||||
|
||||
export function readSessionProtagonist(
|
||||
board: Blackboard,
|
||||
): SessionProtagonist | null {
|
||||
const sealed = readSealedOpeningPersona(
|
||||
board.getContentByTag(OPENING_PERSONA_TAG),
|
||||
);
|
||||
const fromOpening =
|
||||
sealed ??
|
||||
parseOpeningSealPayload(board.getContentByTag("设计.开场白与开场变量"))
|
||||
?.persona ??
|
||||
splitOpeningDocument(board.getContentByTag("设计.开场白")).persona;
|
||||
if (fromOpening) {
|
||||
return {
|
||||
name: fromOpening.name,
|
||||
description: fromOpening.description,
|
||||
summary: firstSentence(fromOpening.description),
|
||||
};
|
||||
}
|
||||
const spec = parseProtagonistSpec(board.getContentByTag(PROTAGONIST_TAG));
|
||||
if (!spec) return null;
|
||||
return {
|
||||
name: spec.name,
|
||||
description: formatPersonaDescription(spec),
|
||||
summary: formatProtagonistSummary(spec),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatPersonaDescription(spec: ProtagonistSpec): string {
|
||||
const special: string[] = [];
|
||||
if (spec.identity && spec.identity !== "无") special.push(`身份:${spec.identity}`);
|
||||
if (spec.cheat && spec.cheat !== "无") special.push(`金手指:${spec.cheat}`);
|
||||
if (spec.extra && spec.extra !== "无") special.push(spec.extra);
|
||||
const parts: string[] = [];
|
||||
if (spec.background && spec.background !== "未定") {
|
||||
parts.push(`背景:${spec.background}`);
|
||||
}
|
||||
if (special.length) parts.push(`特殊设定:${special.join(";")}`);
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
export function formatProtagonistSummary(spec: ProtagonistSpec): string {
|
||||
const bits = [spec.identity, spec.cheat, spec.background]
|
||||
.map((s) => firstSentence(s))
|
||||
.filter((s) => s && s !== "无" && s !== "未定");
|
||||
return bits.slice(0, 2).join(" · ");
|
||||
}
|
||||
|
||||
/** 用户选用开场预设后,用开场里的名字+简介替换 @玩家 / @人设。 */
|
||||
export function overlayPlayPersona(
|
||||
stage: "design" | "play",
|
||||
board: Blackboard | null | undefined,
|
||||
): PersonaDirective | null {
|
||||
const base = personaForLifecycle(stage);
|
||||
if (stage !== "play" || !board || !base) return base;
|
||||
const choice = board.getContentByTag(OPENING_PERSONA_CHOICE_TAG)?.trim();
|
||||
if (choice === "global") return base;
|
||||
const preset = readSessionProtagonist(board);
|
||||
if (!preset) return base;
|
||||
if (choice === "opening") {
|
||||
return {
|
||||
name: preset.name,
|
||||
description: preset.description || base.description,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function parseFromJson(text: string): ProtagonistSpec | null {
|
||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
const candidates = fenced ? [fenced[1].trim(), text] : [text];
|
||||
for (const candidate of candidates) {
|
||||
let doc: unknown;
|
||||
try {
|
||||
doc = JSON.parse(candidate);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const spec = specFromUnknown(doc);
|
||||
if (spec) return spec;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function specFromUnknown(doc: unknown): ProtagonistSpec | null {
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
|
||||
const row = doc as Record<string, unknown>;
|
||||
const body =
|
||||
row.正文 && typeof row.正文 === "object" && !Array.isArray(row.正文)
|
||||
? (row.正文 as Record<string, unknown>)
|
||||
: row;
|
||||
const name = readName(body.名字 ?? body.name);
|
||||
if (!name) return null;
|
||||
const special = body.特殊设定;
|
||||
const specialObj =
|
||||
special && typeof special === "object" && !Array.isArray(special)
|
||||
? (special as Record<string, unknown>)
|
||||
: null;
|
||||
const specialText = typeof special === "string" ? special.trim() : "";
|
||||
return {
|
||||
name,
|
||||
background: readText(body.背景 ?? body.background) || "未定",
|
||||
identity:
|
||||
readText(specialObj?.身份 ?? specialObj?.identity ?? body.身份) ||
|
||||
(specialText && !specialObj ? specialText : "") ||
|
||||
"无",
|
||||
cheat: readText(specialObj?.金手指 ?? specialObj?.cheat ?? body.金手指) || "无",
|
||||
extra: readText(specialObj?.其它 ?? specialObj?.extra ?? body.其它) || "无",
|
||||
};
|
||||
}
|
||||
|
||||
function parseFromProse(text: string): ProtagonistSpec | null {
|
||||
const name = extractName(
|
||||
matchField(text, "名字") ||
|
||||
matchField(text, "姓名") ||
|
||||
matchField(text, "name"),
|
||||
);
|
||||
if (!name) return null;
|
||||
const cheat =
|
||||
matchField(text, "金手指") || matchField(text, "能做什么") || "无";
|
||||
const background =
|
||||
matchField(text, "背景") || matchField(text, "关键背景") || "未定";
|
||||
return {
|
||||
name,
|
||||
background,
|
||||
identity: matchField(text, "身份") || "无",
|
||||
cheat,
|
||||
extra: matchField(text, "其它") || matchField(text, "特殊设定") || "无",
|
||||
};
|
||||
}
|
||||
|
||||
function readName(raw: unknown): string | null {
|
||||
return extractName(readText(raw));
|
||||
}
|
||||
|
||||
function extractName(raw: string): string | null {
|
||||
const stripped = stripMd(raw).trim();
|
||||
if (!stripped) return null;
|
||||
const firstLine = stripped.split(/\n/)[0]?.trim() ?? "";
|
||||
const clause = firstLine.split(/[。!?;.!?;]/)[0]?.trim() ?? "";
|
||||
const noQuote = clause.replace(/^[「『“"']|[」』”"']$/g, "").trim();
|
||||
const noParen = noQuote.replace(/[((].*$/, "").trim();
|
||||
const candidate = noParen || noQuote;
|
||||
if (!usableName(candidate)) return null;
|
||||
if (candidate.length > 16) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function usableName(name: string): boolean {
|
||||
const n = name.trim();
|
||||
if (!n) return false;
|
||||
if (RESERVED_NAMES.has(n)) return false;
|
||||
if (n.includes("@玩家")) return false;
|
||||
if (/^(?待/.test(n)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function readText(raw: unknown): string {
|
||||
if (typeof raw !== "string") return "";
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function stripMd(text: string): string {
|
||||
return text.replace(/\*\*/g, "").replace(/^#+\s*/, "").trim();
|
||||
}
|
||||
|
||||
function firstSentence(text: string): string {
|
||||
const flat = stripMd(text).replace(/\s+/g, " ").trim();
|
||||
if (!flat) return "";
|
||||
const clause = flat.split(/[。!?\n]/)[0]?.trim() ?? "";
|
||||
if (clause.length <= 36) return clause;
|
||||
return `${clause.slice(0, 36)}…`;
|
||||
}
|
||||
|
||||
function matchField(text: string, label: string): string {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const sameLine = text.match(new RegExp(`${escaped}[::]\\s*([^\\n]+)`));
|
||||
if (sameLine?.[1]?.trim()) return sameLine[1].trim();
|
||||
return markdownSection(text, escaped);
|
||||
}
|
||||
|
||||
function markdownSection(text: string, escapedTitle: string): string {
|
||||
const re = new RegExp(
|
||||
`^#{1,3}\\s*${escapedTitle}(?=\\s|[((::]|$)[^\\n]*$`,
|
||||
"im",
|
||||
);
|
||||
const m = re.exec(text);
|
||||
if (!m) return "";
|
||||
const rest = text.slice(m.index + m[0].length);
|
||||
const next = rest.search(/^#{1,3}\s+/m);
|
||||
const body = (next >= 0 ? rest.slice(0, next) : rest).trim();
|
||||
return body.split(/\n---\s*\n/)[0]?.trim() ?? "";
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
ToolDefinition,
|
||||
} from "../llm/client.js";
|
||||
import { supportsToolStream } from "../llm/stream-complete.js";
|
||||
import { debugLog } from "../log.js";
|
||||
|
||||
export type DriverToolCall = {
|
||||
id: string;
|
||||
@@ -87,9 +86,10 @@ async function completeToolsPreferStream(
|
||||
messages: ChatMessage[],
|
||||
tools: ToolDefinition[],
|
||||
caller: string | undefined,
|
||||
label: string | undefined,
|
||||
callbacks: StreamCallbacks,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const options = { tools, caller };
|
||||
const options = { tools, caller, label };
|
||||
if (supportsToolStream(llm)) {
|
||||
return llm.completeWithToolsStream!(messages, options, callbacks);
|
||||
}
|
||||
@@ -115,12 +115,12 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver {
|
||||
const label = input.label ?? "driver";
|
||||
|
||||
for (let iteration = 1; iteration <= maxIterations; iteration++) {
|
||||
debugLog("llm", `${label} 第${iteration}轮`);
|
||||
const result = await completeToolsPreferStream(
|
||||
llm,
|
||||
messages,
|
||||
input.tools,
|
||||
input.caller,
|
||||
`${label} 第${iteration}轮`,
|
||||
streamCallbacks,
|
||||
);
|
||||
emitThinkingDone(input, result.reasoning);
|
||||
|
||||
@@ -36,7 +36,7 @@ import { toActiveSkillSnapshot } from "../skills/snapshot.js";
|
||||
import { runWorkerSkill } from "../worker/executor.js";
|
||||
import { resolveWorkerId } from "../worker/resolve-id.js";
|
||||
import { resolveWorkerLlmProvider } from "../skills/worker-llm.js";
|
||||
import { personaForLifecycle } from "../persona/store.js";
|
||||
import { overlayPlayPersona } from "../persona/protagonist-bind.js";
|
||||
import {
|
||||
DIALOGUE_HISTORY_TAG,
|
||||
appendDialogueHistoryTurn,
|
||||
@@ -127,9 +127,11 @@ import {
|
||||
OPENING_CURRENT_VARS_TAG,
|
||||
OPENING_INITIAL_VARS_TAG,
|
||||
OPENING_OUTPUT_TAG,
|
||||
OPENING_PERSONA_TAG,
|
||||
OPENING_SETUP_ARTIFACT_TAG,
|
||||
parseOpeningSealPayload,
|
||||
reopenCreationFlowRaw,
|
||||
serializeOpeningPersona,
|
||||
SLOT_CREATION_SEALED_BY_OPENING,
|
||||
SLOT_OPENING_SELECTED_INDEX,
|
||||
} from "../skills/opening-seal.js";
|
||||
@@ -138,6 +140,10 @@ import {
|
||||
isDictateModeValue,
|
||||
} from "../dictate/types.js";
|
||||
import { applyDictatePlayBind } from "../dictate/play-bind.js";
|
||||
import {
|
||||
dictateProductFamily,
|
||||
mergeGenerationRulesArtifacts,
|
||||
} from "../dictate/repeatable-tags.js";
|
||||
import {
|
||||
VARIABLE_CATALOG_TAG,
|
||||
seedVariablesFromCatalog,
|
||||
@@ -172,7 +178,9 @@ import {
|
||||
import {
|
||||
mergeTableCells,
|
||||
parseTableDoc,
|
||||
patchUserTableCells,
|
||||
stringifyTableDoc,
|
||||
type TableCellPatch,
|
||||
type TableDoc,
|
||||
} from "../blackboard/table-cells.js";
|
||||
import {
|
||||
@@ -332,6 +340,16 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
setOpeningSelectedIndex(index: number): void {
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[SLOT_OPENING_SELECTED_INDEX]: Math.trunc(index),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getLastWorkerRunSnapshot(): {
|
||||
workerId: string;
|
||||
runtimeSession: RuntimeSession;
|
||||
@@ -741,6 +759,29 @@ export class PhaseRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户按格手改表:须带读到的 rev。版本冲突或只读格跳过,不整表覆盖。
|
||||
*/
|
||||
patchUserTable(
|
||||
tag: string,
|
||||
patches: TableCellPatch[],
|
||||
): ReturnType<typeof patchUserTableCells> {
|
||||
const allowed = tag === "变量.当前" || tag === "运行.初始变量";
|
||||
if (!allowed) throw new Error("该表不可手改");
|
||||
const prev = parseTableDoc(this.blackboard.getContentByTag(tag));
|
||||
const result = patchUserTableCells(prev, patches);
|
||||
if (result.applied.length) {
|
||||
this.blackboard.write({
|
||||
tag,
|
||||
content: stringifyTableDoc(result.doc),
|
||||
source: "user",
|
||||
});
|
||||
this.applyTableSideEffects(prev, result.doc, "user");
|
||||
if (tag === "变量.当前") this.applyValueMapProjections("user");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户文本输入的统一入口。
|
||||
* legacy skill_selection 阶段会先解析为 selectSkill,否则走 user_submitted_input。
|
||||
@@ -1944,6 +1985,14 @@ export class PhaseRuntime {
|
||||
this.writeWorkerTagContent(OPENING_OUTPUT_TAG, openingText, "opening-setup");
|
||||
}
|
||||
|
||||
if (payload?.persona) {
|
||||
this.writeWorkerTagContent(
|
||||
OPENING_PERSONA_TAG,
|
||||
serializeOpeningPersona(payload.persona),
|
||||
"opening-setup",
|
||||
);
|
||||
}
|
||||
|
||||
if (payload?.variables.length) {
|
||||
const source = "worker:opening-setup";
|
||||
const initialPatch = mergeOpeningTablePatch(
|
||||
@@ -1995,15 +2044,15 @@ export class PhaseRuntime {
|
||||
|
||||
/** 收口后若还没有运行规格,用草稿或默认槽位补一份,才能进游玩 */
|
||||
private ensurePlaySpecReady(): void {
|
||||
// Boss 直聘:落档/开玩时自动挂载产物;种变量初值;按映射重投影
|
||||
// 对话落盘:落档/开玩时自动挂载产物;种变量初值;按映射重投影
|
||||
if (this.isDictateIntakeMode()) {
|
||||
const bound = applyDictatePlayBind(this.blackboard);
|
||||
if (bound.boundTags.length) {
|
||||
this.onMessage(
|
||||
`[Boss直聘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`,
|
||||
`[对话落盘] 已自动挂载 ${bound.boundTags.length} 个产物到游玩上下文:${bound.boundTags.join("、")}`,
|
||||
);
|
||||
} else {
|
||||
this.onMessage("[Boss直聘] 已生成默认游玩规格(尚无产物可挂载)");
|
||||
this.onMessage("[对话落盘] 已生成默认游玩规格(尚无产物可挂载)");
|
||||
}
|
||||
this.seedVariablesAndReproject("opening-setup");
|
||||
} else {
|
||||
@@ -2274,7 +2323,7 @@ export class PhaseRuntime {
|
||||
slots,
|
||||
fallbackLlm: this.llm,
|
||||
getPersona: () =>
|
||||
personaForLifecycle(inferLifecycleStage(this.session)),
|
||||
overlayPlayPersona(inferLifecycleStage(this.session), this.blackboard),
|
||||
});
|
||||
|
||||
this.onMessage(
|
||||
@@ -2670,7 +2719,19 @@ export class PhaseRuntime {
|
||||
need: { rule_id: string; reason: string },
|
||||
workerId: string,
|
||||
): void {
|
||||
const rulesRaw = this.blackboard.getContentByTag("设计.生成规则");
|
||||
const board = this.blackboard;
|
||||
const rawPieces: string[] = [];
|
||||
for (const e of board.listTagIndex()) {
|
||||
if (dictateProductFamily(e.tag) !== "设计.生成规则") continue;
|
||||
const c = board.getContentByTag(e.tag)?.trim();
|
||||
if (c) rawPieces.push(c);
|
||||
}
|
||||
const rulesRaw =
|
||||
rawPieces.length === 0
|
||||
? board.getContentByTag("设计.生成规则")
|
||||
: rawPieces.length === 1
|
||||
? rawPieces[0]
|
||||
: mergeGenerationRulesArtifacts(rawPieces);
|
||||
const result = runMaintainNeedGenerateSampling({
|
||||
need,
|
||||
generationRulesRaw: rulesRaw,
|
||||
|
||||
@@ -56,6 +56,7 @@ async function loadModulesPayload(skillId: string): Promise<{
|
||||
loadModuleCatalog,
|
||||
loadModulePrompt,
|
||||
parseModulePromptSections,
|
||||
catalogModulesForIntake,
|
||||
MODULE_SECTION_IDS,
|
||||
} = await import("../skills/creation-flow.js");
|
||||
const skill = await loadSkill(skillId);
|
||||
@@ -64,7 +65,7 @@ async function loadModulesPayload(skillId: string): Promise<{
|
||||
}
|
||||
const catalog = await loadModuleCatalog(skill.skillPackRoot);
|
||||
const modules = [];
|
||||
for (const m of catalog?.modules ?? []) {
|
||||
for (const m of catalogModulesForIntake(catalog ?? { modules: [] }, "recipe")) {
|
||||
const prompt = await loadModulePrompt(skill.skillPackRoot, m.id);
|
||||
const sections = prompt
|
||||
? parseModulePromptSections(prompt)
|
||||
@@ -423,16 +424,27 @@ export async function handleBooksApi(
|
||||
}
|
||||
try {
|
||||
let instanceId: string | undefined;
|
||||
let useOpeningPersona: boolean | undefined;
|
||||
try {
|
||||
const raw = await readBody(req);
|
||||
if (raw.trim()) {
|
||||
const body = JSON.parse(raw) as { instanceId?: string };
|
||||
const body = JSON.parse(raw) as {
|
||||
instanceId?: string;
|
||||
useOpeningPersona?: boolean;
|
||||
};
|
||||
instanceId = body.instanceId?.trim() || undefined;
|
||||
useOpeningPersona =
|
||||
typeof body.useOpeningPersona === "boolean"
|
||||
? body.useOpeningPersona
|
||||
: undefined;
|
||||
}
|
||||
} catch {
|
||||
instanceId = undefined;
|
||||
useOpeningPersona = undefined;
|
||||
}
|
||||
const session = await sessionManager.startNewPlayRun(active.id, instanceId);
|
||||
const session = await sessionManager.startNewPlayRun(active.id, instanceId, {
|
||||
useOpeningPersona,
|
||||
});
|
||||
json(res, 200, { session });
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 用户可见中文标签(与 docs/ui-glossary.md 同步)。
|
||||
* 内部 id 不变;仅展示层映射。
|
||||
* 口径:配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元
|
||||
* 口径:进料方式 / 配方 / 编排器 / 技能 / 工作流计划 / 运行规格 / 执行单元
|
||||
*/
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
@@ -39,9 +39,12 @@ const FIXED_TOPIC_LABELS: Record<string, string> = {
|
||||
"aesthetics-interaction": "美学纲领与交互范式",
|
||||
interaction: "交互范式",
|
||||
narrative_guide: "叙事指南与故事推进",
|
||||
"narrative-guide": "叙事指南",
|
||||
"story-progression": "故事推进",
|
||||
input_protocol: "输入协议",
|
||||
core_premise: "核心前提",
|
||||
aesthetics: "美学纲领",
|
||||
protagonist: "主角设定",
|
||||
};
|
||||
|
||||
const PHASE_UNIT_LABELS: Record<string, string> = {
|
||||
@@ -49,9 +52,15 @@ const PHASE_UNIT_LABELS: Record<string, string> = {
|
||||
refine: "细化",
|
||||
};
|
||||
|
||||
const CREATION_MODE_LABELS: Record<string, string> = {
|
||||
recipe: "工序编排",
|
||||
dictate: "对话落盘",
|
||||
};
|
||||
|
||||
const SKILL_PACK_LABELS: Record<string, string> = {
|
||||
"world-simulator": "世界模拟器",
|
||||
"world-simulator": "回合推演",
|
||||
"expand-assistant": "扩写助手",
|
||||
快穿短局: "快穿短局",
|
||||
文本生成器: "文本生成器",
|
||||
"text-generator": "文本生成器",
|
||||
交互式长文生成器: "交互式长文生成器",
|
||||
@@ -64,6 +73,12 @@ export function displayStageLabel(id: string | undefined | null): string {
|
||||
return STAGE_LABELS[id] ?? id;
|
||||
}
|
||||
|
||||
/** 进料方式展示名(新建对话框 / 工作面 kicker) */
|
||||
export function displayCreationModeLabel(id: string | undefined | null): string {
|
||||
if (!id) return "";
|
||||
return CREATION_MODE_LABELS[id] ?? id;
|
||||
}
|
||||
|
||||
/** 配方选项 / skill pack 展示名 */
|
||||
export function displaySkillPackLabel(id: string | undefined | null): string {
|
||||
if (!id) return "";
|
||||
|
||||
+230
-21
@@ -18,6 +18,13 @@ import {
|
||||
import {
|
||||
INSTANCE_OPENING_SNAPSHOT_LABEL,
|
||||
OPENING_OUTPUT_TAG,
|
||||
OPENING_PERSONA_CHOICE_TAG,
|
||||
OPENING_PERSONA_TAG,
|
||||
SLOT_OPENING_SELECTED_INDEX,
|
||||
parseOpeningSealPayload,
|
||||
readSealedOpeningPersona,
|
||||
serializeOpeningPersona,
|
||||
splitOpeningDocument,
|
||||
} from "../skills/opening-seal.js";
|
||||
import {
|
||||
PLAY_WORKING_SNAPSHOT_ID,
|
||||
@@ -150,9 +157,11 @@ import {
|
||||
CREATION_INTAKE_MODE_TAG,
|
||||
DICTATE_MODE_VALUE,
|
||||
DICTATE_ORDER_META_KEY,
|
||||
buildDictateInsertFeedbackIndex,
|
||||
defaultDictateOrder,
|
||||
extractDictateDialogue,
|
||||
isDictateModeValue,
|
||||
lookupDictateInsertFeedback,
|
||||
runDictateTurn,
|
||||
sortDictateProducts,
|
||||
type DictateProduct,
|
||||
@@ -173,6 +182,12 @@ import {
|
||||
listPersonas,
|
||||
personaForLifecycle,
|
||||
} from "../persona/store.js";
|
||||
import {
|
||||
overlayPlayPersona,
|
||||
readSessionProtagonist,
|
||||
} from "../persona/protagonist-bind.js";
|
||||
import { buildPlayAuxView } from "../skills/play-aux.js";
|
||||
import type { TableCellPatch } from "../blackboard/table-cells.js";
|
||||
import {
|
||||
appendBranchVariant,
|
||||
cloneCheckpoint,
|
||||
@@ -290,6 +305,11 @@ export type SessionView = {
|
||||
agentThinking?: string;
|
||||
/** Agent / Worker 流式输出(轮询用) */
|
||||
liveStream?: LiveStreamView;
|
||||
/**
|
||||
* 会话是否有进行中的排他运行(send/action 的 runExclusive)。
|
||||
* 前端 loading 应对齐此标志,避免 POST 挂住时底栏永远「生成中」。
|
||||
*/
|
||||
runActive?: boolean;
|
||||
resumed?: boolean;
|
||||
/** 创作进料:recipe=配方节点流;dictate=转述整理直填 */
|
||||
creationMode?: "recipe" | "dictate";
|
||||
@@ -310,6 +330,12 @@ export type SessionView = {
|
||||
personas?: Array<{ id: string; name: string }>;
|
||||
/** 当前选中的用户角色 */
|
||||
activePersona?: { id: string; name: string; description: string } | null;
|
||||
/** 本局开场自带的用户角色预设(名字+简介);关掉这局就不加载 */
|
||||
sessionProtagonist?: { name: string; description: string; summary: string } | null;
|
||||
/** 用户是否选用开场预设:opening / global */
|
||||
openingPersonaChoice?: "opening" | "global" | null;
|
||||
/** 游玩辅助 Tab:表格 / 开场与角色 */
|
||||
playAux?: import("../skills/play-aux.js").PlayAuxView;
|
||||
};
|
||||
|
||||
export type SessionAction =
|
||||
@@ -419,14 +445,21 @@ export class SessionManager {
|
||||
return this.createForBook();
|
||||
}
|
||||
|
||||
setLifecycleStage(sessionId: string, stage: LifecycleStage): SessionView {
|
||||
setLifecycleStage(
|
||||
sessionId: string,
|
||||
stage: LifecycleStage,
|
||||
opts?: { useOpeningPersona?: boolean },
|
||||
): SessionView {
|
||||
const s = this.require(sessionId);
|
||||
if (stage === "play") {
|
||||
s.runtime.prepareEnterPlay();
|
||||
if (!canEnterPlay(s.runtime.getSession())) {
|
||||
throw new Error("实例尚未就绪,无法进入游玩");
|
||||
}
|
||||
this.enterPlayLayer(s, { resume: true });
|
||||
this.enterPlayLayer(s, {
|
||||
resume: true,
|
||||
useOpeningPersona: opts?.useOpeningPersona,
|
||||
});
|
||||
} else {
|
||||
this.enterDesignLayer(s);
|
||||
}
|
||||
@@ -452,6 +485,65 @@ export class SessionManager {
|
||||
return this.toView(sessionId);
|
||||
}
|
||||
|
||||
/** 游玩表按格手改。冲突格在 tablePatch.skipped,整表不会被旧内容盖掉。 */
|
||||
patchPlayTable(
|
||||
sessionId: string,
|
||||
tag: string,
|
||||
cells: TableCellPatch[],
|
||||
): SessionView & {
|
||||
tablePatch: ReturnType<PhaseRuntime["patchUserTable"]>;
|
||||
} {
|
||||
const s = this.require(sessionId);
|
||||
const tablePatch = s.runtime.patchUserTable(tag.trim() || "变量.当前", cells);
|
||||
if (s.bookId) this.persist(s);
|
||||
return { ...this.toView(sessionId), tablePatch };
|
||||
}
|
||||
|
||||
/**
|
||||
* 游玩辅助面点选开场。usePersona=true 用该开场自带角色;false 保持当前用户角色卡。
|
||||
*/
|
||||
selectPlayOpening(
|
||||
sessionId: string,
|
||||
index: number,
|
||||
usePersona?: boolean,
|
||||
): SessionView {
|
||||
const s = this.require(sessionId);
|
||||
const board = s.runtime.getBlackboard();
|
||||
const aux = buildPlayAuxView(board, s.runtime.getSession().slots);
|
||||
if (!aux.openings.length) throw new Error("没有可选开场");
|
||||
const idx = Math.min(Math.max(0, Math.trunc(index)), aux.openings.length - 1);
|
||||
const picked = aux.openings[idx]!;
|
||||
s.runtime.setOpeningSelectedIndex(idx);
|
||||
|
||||
const persona = picked.persona;
|
||||
if (usePersona === false) {
|
||||
this.writeOpeningPersonaChoice(s, false);
|
||||
} else if (persona) {
|
||||
board.write({
|
||||
tag: OPENING_PERSONA_TAG,
|
||||
content: serializeOpeningPersona(persona),
|
||||
source: "user",
|
||||
});
|
||||
this.writeOpeningPersonaChoice(s, true);
|
||||
}
|
||||
|
||||
const expanded = this.expandDirectivesText(
|
||||
picked.text,
|
||||
this.playDirectiveContext(s),
|
||||
);
|
||||
board.write({
|
||||
tag: OPENING_OUTPUT_TAG,
|
||||
content: expanded,
|
||||
source: "user",
|
||||
});
|
||||
const first = s.messages[0];
|
||||
if (first?.title === "开场白") {
|
||||
s.messages[0] = this.playOpeningMessage(expanded);
|
||||
}
|
||||
if (s.bookId) this.persist(s);
|
||||
return this.toView(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编排上下文投影排序:写入 设计.worker集.context_order(若有规格),
|
||||
* 并同步 设计.上下文投影排序。下次声明驱动拼装即按新序。
|
||||
@@ -625,7 +717,7 @@ export class SessionManager {
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
/** 切换到转述整理进料(与配方并行;不跑 design-flow) */
|
||||
/** 切换到对话落盘进料(与工序编排并行;不跑 design-flow) */
|
||||
private enterDictateMode(s: ManagedSession): void {
|
||||
const board = s.runtime.getBlackboard();
|
||||
board.write({
|
||||
@@ -641,7 +733,7 @@ export class SessionManager {
|
||||
}。`
|
||||
: "";
|
||||
const welcome =
|
||||
`Boss 直聘模式:直接说你想要的体验与设定。我会整理进产物;变量与映射用工具钉死。对话过长时可清空对话(产物保留)。${recipeLine}`;
|
||||
`对话落盘:直接说你想要的体验与设定。我会整理进产物;变量与映射用工具钉死。对话过长时可清空对话(产物保留)。${recipeLine}`;
|
||||
s.messages.push({
|
||||
id: randomUUID(),
|
||||
role: "system",
|
||||
@@ -649,7 +741,7 @@ export class SessionManager {
|
||||
createdAt: new Date().toISOString(),
|
||||
kind: "orchestrator_prompt",
|
||||
actor: "orchestrator",
|
||||
title: "Boss直聘",
|
||||
title: "对话落盘",
|
||||
body: welcome,
|
||||
});
|
||||
}
|
||||
@@ -726,6 +818,13 @@ export class SessionManager {
|
||||
skillName: s.runtime.getActiveSkill()?.name,
|
||||
moduleCatalog: s.moduleCatalog,
|
||||
});
|
||||
const insertFeedbackIndex = await resolveDictateInsertFeedbackIndexForSession({
|
||||
skillName: s.runtime.getActiveSkill()?.name,
|
||||
moduleCatalog: s.moduleCatalog,
|
||||
});
|
||||
const repeatableFamilies = (s.moduleCatalog?.modules ?? [])
|
||||
.filter((m) => m.repeatable === true && m.artifact?.trim())
|
||||
.map((m) => m.artifact!.trim());
|
||||
|
||||
const result = await runDictateTurn({
|
||||
llm,
|
||||
@@ -733,6 +832,7 @@ export class SessionManager {
|
||||
recipeName: recipeView?.name,
|
||||
recipeBrief,
|
||||
moduleGuide,
|
||||
repeatableFamilies,
|
||||
handlers: {
|
||||
listProducts: () => this.listDictateProducts(s),
|
||||
writeProduct: (tag, content, order) => {
|
||||
@@ -755,6 +855,7 @@ export class SessionManager {
|
||||
},
|
||||
});
|
||||
},
|
||||
deleteProduct: (tag) => s.runtime.getBlackboard().deleteByTag(tag),
|
||||
readTag: (tag) => s.runtime.getBlackboard().getContentByTag(tag),
|
||||
writeTag: (tag, content) => {
|
||||
const board = s.runtime.getBlackboard();
|
||||
@@ -768,6 +869,7 @@ export class SessionManager {
|
||||
},
|
||||
});
|
||||
},
|
||||
deleteTag: (tag) => s.runtime.getBlackboard().deleteByTag(tag),
|
||||
clearDialogue: () => {
|
||||
const kept = this.listDictateProducts(s);
|
||||
const lastUser = [...s.messages]
|
||||
@@ -813,6 +915,8 @@ export class SessionManager {
|
||||
if (text.trim()) this.agentThinkingLive.set(id, text);
|
||||
this.agentThinkingClosed.add(id);
|
||||
},
|
||||
lookupInsertFeedback: (tag) =>
|
||||
lookupDictateInsertFeedback(insertFeedbackIndex, tag),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -949,6 +1053,7 @@ export class SessionManager {
|
||||
async startNewPlayRun(
|
||||
sessionId: string,
|
||||
instanceId?: string,
|
||||
opts?: { useOpeningPersona?: boolean },
|
||||
): Promise<SessionView> {
|
||||
const s = this.require(sessionId);
|
||||
if (!s.bookId) throw new Error("仅绑定作品时可新建游玩");
|
||||
@@ -958,14 +1063,22 @@ export class SessionManager {
|
||||
throw new Error("须先验收 Worker 集、完成收口,或(转述)先写入产物并落档,才能开始游玩");
|
||||
}
|
||||
this.archivePlayWorking(s.bookId);
|
||||
this.enterPlayLayer(s, { resume: false, instanceId });
|
||||
this.enterPlayLayer(s, {
|
||||
resume: false,
|
||||
instanceId,
|
||||
useOpeningPersona: opts?.useOpeningPersona,
|
||||
});
|
||||
return this.toView(sessionId);
|
||||
}
|
||||
|
||||
/** 进入游玩层:resume 时恢复「当前游玩」,否则从产物新开一条聊天 */
|
||||
private enterPlayLayer(
|
||||
s: ManagedSession,
|
||||
opts: { resume: boolean; instanceId?: string },
|
||||
opts: {
|
||||
resume: boolean;
|
||||
instanceId?: string;
|
||||
useOpeningPersona?: boolean;
|
||||
},
|
||||
): void {
|
||||
this.persistCreationIfDesign(s);
|
||||
s.runtime.prepareEnterPlay();
|
||||
@@ -978,6 +1091,7 @@ export class SessionManager {
|
||||
isPlayLayerActive(session.slots) &&
|
||||
inferLifecycleStage(session) === "play"
|
||||
) {
|
||||
this.writeOpeningPersonaChoice(s, opts.useOpeningPersona);
|
||||
this.sanitizePlayHitl(s);
|
||||
return;
|
||||
}
|
||||
@@ -986,10 +1100,11 @@ export class SessionManager {
|
||||
const working = loadRunSnapshotFile(s.bookId, PLAY_WORKING_SNAPSHOT_ID);
|
||||
if (working?.kind === "run") {
|
||||
this.restoreSnapshotInPlace(s, working, "play");
|
||||
this.writeOpeningPersonaChoice(s, opts.useOpeningPersona);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.beginNewPlayFromInstance(s, opts.instanceId);
|
||||
this.beginNewPlayFromInstance(s, opts.instanceId, opts.useOpeningPersona);
|
||||
if (s.bookId) this.savePlayWorking(s);
|
||||
}
|
||||
|
||||
@@ -1009,7 +1124,11 @@ export class SessionManager {
|
||||
this.restoreCreationFromDisk(s);
|
||||
}
|
||||
|
||||
private beginNewPlayFromInstance(s: ManagedSession, instanceId?: string): void {
|
||||
private beginNewPlayFromInstance(
|
||||
s: ManagedSession,
|
||||
instanceId?: string,
|
||||
useOpeningPersona?: boolean,
|
||||
): void {
|
||||
const keepId = s.runtime.getSession().id;
|
||||
// 换上 instance 快照前先记下当前创作里的开场白(可能比上次定稿更新)
|
||||
const liveDesignOpening =
|
||||
@@ -1055,6 +1174,7 @@ export class SessionManager {
|
||||
source: "runtime",
|
||||
});
|
||||
}
|
||||
this.writeOpeningPersonaChoice(s, useOpeningPersona);
|
||||
const opening = this.ensurePlayOpeningWritten(
|
||||
s,
|
||||
instance?.messages as ChatMessage[] | undefined,
|
||||
@@ -1072,6 +1192,19 @@ export class SessionManager {
|
||||
s.branchState = createMessageBranchState();
|
||||
}
|
||||
|
||||
/** 开玩时记下是否用这条开场自带的用户角色;须在恢复快照之后写,否则会被冲掉。 */
|
||||
private writeOpeningPersonaChoice(
|
||||
s: ManagedSession,
|
||||
useOpeningPersona?: boolean,
|
||||
): void {
|
||||
if (useOpeningPersona === undefined) return;
|
||||
s.runtime.getBlackboard().write({
|
||||
tag: OPENING_PERSONA_CHOICE_TAG,
|
||||
content: useOpeningPersona ? "opening" : "global",
|
||||
source: "user",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证黑板上有 输出.开场白,供开玩主区展示。
|
||||
* 优先级:设计.开场白(转述产物)> 已有 输出.开场白 > 其它产物拼装 > 非转述聊天兜底。
|
||||
@@ -1082,6 +1215,7 @@ export class SessionManager {
|
||||
s: ManagedSession,
|
||||
messageSource?: ChatMessage[],
|
||||
): string {
|
||||
this.sealOpeningPersonaFromBoard(s);
|
||||
const board = s.runtime.getBlackboard();
|
||||
const fromDesign =
|
||||
board.getContentByTag("设计.开场白")?.trim() ||
|
||||
@@ -1089,9 +1223,10 @@ export class SessionManager {
|
||||
?.content?.trim() ||
|
||||
"";
|
||||
if (fromDesign) {
|
||||
const body = splitOpeningDocument(fromDesign).text || fromDesign;
|
||||
const expanded = this.expandDirectivesText(
|
||||
fromDesign,
|
||||
this.playDirectiveContext(),
|
||||
body,
|
||||
this.playDirectiveContext(s),
|
||||
);
|
||||
const existing = board.getContentByTag(OPENING_OUTPUT_TAG)?.trim() ?? "";
|
||||
if (existing !== expanded) {
|
||||
@@ -1108,7 +1243,7 @@ export class SessionManager {
|
||||
if (existing) {
|
||||
const expanded = this.expandDirectivesText(
|
||||
existing,
|
||||
this.playDirectiveContext(),
|
||||
this.playDirectiveContext(s),
|
||||
);
|
||||
if (expanded !== existing) {
|
||||
board.write({
|
||||
@@ -1129,7 +1264,7 @@ export class SessionManager {
|
||||
|
||||
const expanded = this.expandDirectivesText(
|
||||
opening,
|
||||
this.playDirectiveContext(),
|
||||
this.playDirectiveContext(s),
|
||||
);
|
||||
board.write({
|
||||
tag: OPENING_OUTPUT_TAG,
|
||||
@@ -1139,19 +1274,46 @@ export class SessionManager {
|
||||
return expanded;
|
||||
}
|
||||
|
||||
/** 开场 meta 里的用户角色:落到 运行.开场用户角色,不进全局列表。 */
|
||||
private sealOpeningPersonaFromBoard(s: ManagedSession): void {
|
||||
const board = s.runtime.getBlackboard();
|
||||
if (readSealedOpeningPersona(board.getContentByTag(OPENING_PERSONA_TAG))) {
|
||||
return;
|
||||
}
|
||||
const selectedRaw = s.runtime.getSession().slots[SLOT_OPENING_SELECTED_INDEX];
|
||||
const selectedIndex =
|
||||
typeof selectedRaw === "number"
|
||||
? selectedRaw
|
||||
: typeof selectedRaw === "string" && selectedRaw.trim()
|
||||
? Number(selectedRaw)
|
||||
: 0;
|
||||
const persona =
|
||||
parseOpeningSealPayload(
|
||||
board.getContentByTag("设计.开场白与开场变量"),
|
||||
selectedIndex,
|
||||
)?.persona ?? splitOpeningDocument(board.getContentByTag("设计.开场白")).persona;
|
||||
if (!persona) return;
|
||||
board.write({
|
||||
tag: OPENING_PERSONA_TAG,
|
||||
content: serializeOpeningPersona(persona),
|
||||
source: "runtime",
|
||||
});
|
||||
}
|
||||
|
||||
private personaForManaged(s: ManagedSession): ReturnType<
|
||||
typeof personaForLifecycle
|
||||
> {
|
||||
if (!s.runtime) return personaForLifecycle("design");
|
||||
return personaForLifecycle(inferLifecycleStage(s.runtime.getSession()));
|
||||
return overlayPlayPersona(
|
||||
inferLifecycleStage(s.runtime.getSession()),
|
||||
s.runtime.getBlackboard(),
|
||||
);
|
||||
}
|
||||
|
||||
private playDirectiveContext(): DirectiveContext {
|
||||
const p = getActivePersona();
|
||||
private playDirectiveContext(s: ManagedSession): DirectiveContext {
|
||||
const p = this.personaForManaged(s);
|
||||
if (!p) return {};
|
||||
return {
|
||||
persona: { name: p.name, description: p.description },
|
||||
};
|
||||
return { persona: p };
|
||||
}
|
||||
|
||||
private directiveContextFor(s: ManagedSession): DirectiveContext {
|
||||
@@ -3097,6 +3259,7 @@ export class SessionManager {
|
||||
private toView(id: string, resumed = false, resumeHint?: string): SessionView {
|
||||
const s = this.require(id);
|
||||
this.sanitizePlayHitl(s);
|
||||
this.sealOpeningPersonaFromBoard(s);
|
||||
const session = s.runtime.getSession();
|
||||
const reason = session.waitingReason;
|
||||
const book = s.bookId ? getBook(s.bookId) : null;
|
||||
@@ -3482,6 +3645,7 @@ export class SessionManager {
|
||||
: undefined,
|
||||
liveStream: this.buildLiveStreamView(id),
|
||||
agentThinking: this.agentThinkingLive.get(id),
|
||||
runActive: Boolean(s.runAbort),
|
||||
resumed: resumed || undefined,
|
||||
tokenStats: {
|
||||
sessionTotal: tokens.totalTokens,
|
||||
@@ -3498,6 +3662,19 @@ export class SessionManager {
|
||||
? { id: p.id, name: p.name, description: p.description }
|
||||
: null;
|
||||
})(),
|
||||
sessionProtagonist: readSessionProtagonist(s.runtime.getBlackboard()),
|
||||
openingPersonaChoice: ((): "opening" | "global" | null => {
|
||||
const raw = s.runtime
|
||||
.getBlackboard()
|
||||
.getContentByTag(OPENING_PERSONA_CHOICE_TAG)
|
||||
?.trim();
|
||||
if (raw === "opening" || raw === "global") return raw;
|
||||
return null;
|
||||
})(),
|
||||
playAux: buildPlayAuxView(
|
||||
s.runtime.getBlackboard(),
|
||||
s.runtime.getSession().slots,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3927,7 +4104,7 @@ function resolveSelectedRecipeView(
|
||||
return { id: ref, name: ref, declaration: "" };
|
||||
}
|
||||
|
||||
/** Boss 直聘:优先注入 recipe.yaml 方法论,缺则退回 catalog declaration */
|
||||
/** 对话落盘:优先注入 recipe.yaml 方法论,缺则退回 catalog declaration */
|
||||
async function resolveDictateRecipeBriefForSession(params: {
|
||||
skillName?: string | null;
|
||||
recipeView: { id: string; name: string; declaration: string } | null;
|
||||
@@ -3956,7 +4133,7 @@ async function resolveDictateRecipeBriefForSession(params: {
|
||||
return view.declaration?.trim() || undefined;
|
||||
}
|
||||
|
||||
/** Boss 直聘:注入能力何时落盘(modules meta);优先会话已载 catalog */
|
||||
/** 对话落盘:注入能力何时落盘(modules meta);优先会话已载 catalog */
|
||||
async function resolveDictateModuleGuideForSession(params: {
|
||||
skillName?: string | null;
|
||||
moduleCatalog?: ModuleCatalog | null;
|
||||
@@ -3979,6 +4156,38 @@ async function resolveDictateModuleGuideForSession(params: {
|
||||
return formatModuleCatalogForDictate(catalog);
|
||||
}
|
||||
|
||||
/** 对话落盘:insert(tag) → 用户可见规范索引 */
|
||||
async function resolveDictateInsertFeedbackIndexForSession(params: {
|
||||
skillName?: string | null;
|
||||
moduleCatalog?: ModuleCatalog | null;
|
||||
}): Promise<Map<string, string> | undefined> {
|
||||
let catalog = params.moduleCatalog ?? null;
|
||||
let packRoot: string | undefined;
|
||||
const skillName = params.skillName?.trim();
|
||||
if (skillName) {
|
||||
try {
|
||||
const skill = await loadSkill(skillName);
|
||||
packRoot = skill.skillPackRoot?.trim() || undefined;
|
||||
if (!catalog?.modules?.length && packRoot) {
|
||||
catalog = await loadModuleCatalog(packRoot);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[会话] 加载 insert 反馈索引失败", err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (!catalog?.modules?.length || !packRoot) return undefined;
|
||||
try {
|
||||
return await buildDictateInsertFeedbackIndex({
|
||||
catalog,
|
||||
skillPackRoot: packRoot,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[会话] 构建 insert 反馈索引失败", err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkerSetUserView(
|
||||
blackboard: Blackboard,
|
||||
session?: RuntimeSession,
|
||||
|
||||
@@ -136,7 +136,10 @@ export async function handleSettingsApi(
|
||||
|
||||
if (pathname === "/api/profiles" && req.method === "GET") {
|
||||
ensureActiveProfileDefault();
|
||||
json(res, 200, { profiles: listApiProfiles() });
|
||||
json(res, 200, {
|
||||
profiles: listApiProfiles(),
|
||||
activeProfileId: loadAppSettings().activeProfileId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,14 @@ async function serveStatic(res: ServerResponse, filePath: string): Promise<void>
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
};
|
||||
const content = await readFile(filePath);
|
||||
res.writeHead(200, { "Content-Type": types[ext] ?? "application/octet-stream" });
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": types[ext] ?? "application/octet-stream",
|
||||
};
|
||||
// 开发期:避免 ES module(agent-ui.js 等)被强缓存,改完 import 仍吃旧脚本导致主区空白
|
||||
if (ext === ".html" || ext === ".js" || ext === ".css") {
|
||||
headers["Cache-Control"] = "no-cache, must-revalidate";
|
||||
}
|
||||
res.writeHead(200, headers);
|
||||
res.end(content);
|
||||
}
|
||||
|
||||
@@ -339,13 +346,21 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/lifecycle") {
|
||||
const body = JSON.parse(await readBody(req)) as { stage?: string };
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
stage?: string;
|
||||
useOpeningPersona?: boolean;
|
||||
};
|
||||
if (body.stage !== "design" && body.stage !== "play") {
|
||||
json(res, 400, { error: "stage 须为 design 或 play" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const view = sessionManager.setLifecycleStage(sessionId, body.stage);
|
||||
const view = sessionManager.setLifecycleStage(sessionId, body.stage, {
|
||||
useOpeningPersona:
|
||||
typeof body.useOpeningPersona === "boolean"
|
||||
? body.useOpeningPersona
|
||||
: undefined,
|
||||
});
|
||||
json(res, 200, view);
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
@@ -399,6 +414,68 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/table") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
tag?: string;
|
||||
cells?: Array<{ key?: string; value?: unknown; expectedRev?: number }>;
|
||||
};
|
||||
const cells = Array.isArray(body.cells)
|
||||
? body.cells
|
||||
.filter(
|
||||
(c) =>
|
||||
typeof c?.key === "string" &&
|
||||
c.key.trim() &&
|
||||
typeof c.expectedRev === "number",
|
||||
)
|
||||
.map((c) => ({
|
||||
key: String(c.key).trim(),
|
||||
value: c.value,
|
||||
expectedRev: c.expectedRev as number,
|
||||
}))
|
||||
: [];
|
||||
if (!cells.length) {
|
||||
json(res, 400, { error: "缺少 cells(每格需要 key 与 expectedRev)" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const view = sessionManager.patchPlayTable(
|
||||
sessionId,
|
||||
body.tag?.trim() || "变量.当前",
|
||||
cells,
|
||||
);
|
||||
json(res, 200, view);
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : "表写入失败",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/play/opening") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
index?: number;
|
||||
usePersona?: boolean;
|
||||
};
|
||||
if (typeof body.index !== "number" || !Number.isFinite(body.index)) {
|
||||
json(res, 400, { error: "缺少 index" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const view = sessionManager.selectPlayOpening(
|
||||
sessionId,
|
||||
body.index,
|
||||
typeof body.usePersona === "boolean" ? body.usePersona : undefined,
|
||||
);
|
||||
json(res, 200, view);
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : "无法选用开场",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/context-order") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
action?: string;
|
||||
|
||||
@@ -512,6 +512,8 @@ const SYNTH_FALLBACK_CONTEXT: Record<
|
||||
"设计.变量设计与更新规则",
|
||||
"设计.生成规则",
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
"设计.故事推进",
|
||||
"设计.正文组成",
|
||||
"设计.监控栏",
|
||||
],
|
||||
@@ -525,6 +527,8 @@ const SYNTH_FALLBACK_CONTEXT: Record<
|
||||
narrator: {
|
||||
static: [
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
"设计.故事推进",
|
||||
"设计.正文组成",
|
||||
"设计.监控栏",
|
||||
],
|
||||
|
||||
+63
-17
@@ -206,6 +206,11 @@ export type ModuleCatalogEntry = {
|
||||
* 〔先验产物〕:规划产物字段;可提前写入 params,空则步内钉,不拦确认开干。
|
||||
*/
|
||||
params?: ModuleParamSpec[];
|
||||
/**
|
||||
* 可选:进料族。缺省(不写)= 工序编排与对话落盘都能见。
|
||||
* recipe = 只进工序编排 DAG;dictate = 只进对话落盘何时落盘。
|
||||
*/
|
||||
intake?: RecipeIntakeFamily;
|
||||
/** 来自 prompt.md ```meta:何时该选用(编排选型) */
|
||||
when?: string;
|
||||
/** 来自 prompt.md ```meta:何时不该选用 */
|
||||
@@ -214,6 +219,27 @@ export type ModuleCatalogEntry = {
|
||||
boundary?: string;
|
||||
};
|
||||
|
||||
export function parseModuleIntake(raw: unknown): RecipeIntakeFamily | undefined {
|
||||
if (raw === "recipe" || raw === "dictate") return raw;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 缺省 intake = 两族都可见 */
|
||||
export function moduleVisibleForIntake(
|
||||
module: Pick<ModuleCatalogEntry, "intake">,
|
||||
family: RecipeIntakeFamily,
|
||||
): boolean {
|
||||
if (!module.intake) return true;
|
||||
return module.intake === family;
|
||||
}
|
||||
|
||||
export function catalogModulesForIntake(
|
||||
catalog: ModuleCatalog,
|
||||
family: RecipeIntakeFamily,
|
||||
): ModuleCatalogEntry[] {
|
||||
return catalog.modules.filter((m) => moduleVisibleForIntake(m, family));
|
||||
}
|
||||
|
||||
/** 本步程序开场白正文(design-step 发出后写入,供 LLM 看见) */
|
||||
export const CREATION_MODULE_OPENING_TAG = "创作.能力开场白";
|
||||
/** JSON:{ [步骤中文名]: "shown" | "answered" } */
|
||||
@@ -924,6 +950,7 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
|
||||
const auto = m.auto === true;
|
||||
const kind = parseModuleNodeKind(m.kind);
|
||||
const params = parseModuleParamSpecs(m.params);
|
||||
const intake = parseModuleIntake(m.intake);
|
||||
modules.push({
|
||||
id,
|
||||
name,
|
||||
@@ -935,6 +962,7 @@ export function parseModuleCatalog(raw: string): ModuleCatalog | null {
|
||||
...(kind ? { kind } : {}),
|
||||
...(opening ? { opening } : {}),
|
||||
...(params ? { params } : {}),
|
||||
...(intake ? { intake } : {}),
|
||||
});
|
||||
}
|
||||
if (modules.length === 0) return null;
|
||||
@@ -1240,8 +1268,9 @@ function slugFromName(name: string): string {
|
||||
美学纲领与交互范式: "aesthetics-interaction",
|
||||
交互范式: "interaction",
|
||||
美学纲领: "aesthetics",
|
||||
叙事指南: "narrative",
|
||||
叙事指南: "narrative-guide",
|
||||
叙事指南与故事推进: "narrative",
|
||||
故事推进: "story-progression",
|
||||
实现机制: "mechanism",
|
||||
舞台骨架: "world-blueprint",
|
||||
世界蓝图与人文地理: "world-blueprint", // 旧称
|
||||
@@ -1283,7 +1312,7 @@ export async function loadModuleCatalog(
|
||||
|
||||
/** 注入 design-flow:能力名 + 选型字段(meta)+ 编排参数;非执行全文 */
|
||||
export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
|
||||
const lines = catalog.modules.map((m) => {
|
||||
const lines = catalogModulesForIntake(catalog, "recipe").map((m) => {
|
||||
const flags = [
|
||||
m.repeatable ? "〔可反复〕" : "",
|
||||
m.closer ? "〔收口〕" : "",
|
||||
@@ -1332,7 +1361,7 @@ export function formatModuleCatalogForAgent(catalog: ModuleCatalog): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Boss 直聘不经 DAG:这些能力由落档/开玩程序处理,创作对话一般不 insert */
|
||||
/** 对话落盘不经 DAG:这些能力由落档/开玩程序处理,创作对话一般不 insert */
|
||||
const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
|
||||
"worker-spec",
|
||||
"context-order",
|
||||
@@ -1340,11 +1369,11 @@ const DICTATE_PROGRAM_SIDE_MODULE_IDS = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* 注入 Boss 直聘:能力何时落盘(meta when/when_not)+ insert 目标 tag。
|
||||
* 注入对话落盘:能力何时落盘(meta when/when_not)+ insert 目标 tag。
|
||||
* 不写 DAG / design-step 话术;不塞执行全文。
|
||||
*/
|
||||
export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
|
||||
const lines = catalog.modules.map((m) => {
|
||||
const lines = catalogModulesForIntake(catalog, "dictate").map((m) => {
|
||||
const programSide = DICTATE_PROGRAM_SIDE_MODULE_IDS.has(m.id) || m.auto;
|
||||
const flags = [
|
||||
m.repeatable ? "〔可反复〕" : "",
|
||||
@@ -1366,11 +1395,13 @@ export function formatModuleCatalogForDictate(catalog: ModuleCatalog): string {
|
||||
});
|
||||
return [
|
||||
"【能力 · 何时落盘】",
|
||||
"对照下方「何时用 / 何时不用 / 边界」与已有产物判定:条件成立才 insert(或 declare_*);含糊或未到时机 → 本轮不落、只短确认或问挡住的 1 点。",
|
||||
"每轮通常只推进条件已成立的一刀;勿因用户一次说了很多就批量抢跑下游。",
|
||||
"对照下方「何时用 / 何时不用 / 边界」与已有产物判定:条件成立才 insert(或 declare_*);含糊或未到时机 → 本轮不落该能力,改为短确认或问挡住的 1 点。",
|
||||
"同轮可多次落盘:凡本轮材料已够且「何时用」成立的能力,都可 insert / declare_*(可并行);每个 insert 各回 reply_module,可见回复分段附带。",
|
||||
"不抢跑:条件未成立的下游本轮不落,放进「建议下一刀」。",
|
||||
"标〔落档程序〕的:创作对话一般不 insert,开玩/落档时由程序处理。",
|
||||
"标〔收口〕的开场白:美学与必要上游齐后再写;未齐不硬塞。",
|
||||
"不要把能力执行全文塞进聊天;规格只走 toolcall。",
|
||||
"标〔收口〕的开场白:美学与必要上游齐后再写;未齐则先补上游。",
|
||||
"标〔可反复〕的(生成规则/具体实例等):每条增殖用「基名#唯一id」拆分落盘(如 设计.具体实例#lei-ying);禁止反复 insert 基名覆盖旧条。改某一条则 insert 同一完整 tag。",
|
||||
"能力执行全文不进聊天;规格只走 toolcall。insert 成功后按返回的 reply_module 写可见回复(见系统提示「对用户可见回复」)。",
|
||||
lines.join("\n"),
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1383,7 +1414,17 @@ function dictateLandHint(m: ModuleCatalogEntry): string {
|
||||
return "declare_variable / declare_map;长文规则可另 insert 「设计.变量设计与更新规则」";
|
||||
}
|
||||
if (m.id === "opening-setup") {
|
||||
return "insert 「设计.开场白」(Boss 直聘简化 tag;初值与开场同真相)";
|
||||
return "insert 「设计.开场白」(对话落盘简化 tag;初值与开场同真相)";
|
||||
}
|
||||
if (m.artifact?.trim() && m.repeatable) {
|
||||
const art = m.artifact.trim();
|
||||
const slotHint =
|
||||
m.id === "generation-rules"
|
||||
? "rule_id"
|
||||
: m.id === "concrete-instances"
|
||||
? "batch_id或姓名"
|
||||
: "唯一id";
|
||||
return `insert 「${art}#${slotHint}」——可增殖:每条对象/批次各占一 tag;同完整 tag 才覆盖改写,禁止反复写基名「${art}」覆盖`;
|
||||
}
|
||||
if (m.artifact?.trim()) {
|
||||
return `insert 「${m.artifact.trim()}」`;
|
||||
@@ -1618,7 +1659,7 @@ export function formatRecipeCatalogForAgent(catalog: RecipeCatalog): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Boss 直聘 / 转述进料:把已选配方方法论收成短要点(进系统提示「本局配方」)。
|
||||
* 对话落盘:把已选配方方法论收成短要点(进系统提示「本局配方」)。
|
||||
* 不含 DAG steps / design-flow 选型话术。
|
||||
*/
|
||||
export function formatDictateRecipeBrief(detail: RecipeDetail): string {
|
||||
@@ -1744,7 +1785,7 @@ export function formatFlowProgressForAgent(params: {
|
||||
const catalog = params.catalog ?? null;
|
||||
const filled = new Set(params.filledArtifactTags ?? []);
|
||||
const repeatableNames = new Set(
|
||||
(catalog?.modules ?? [])
|
||||
(catalog ? catalogModulesForIntake(catalog, "recipe") : [])
|
||||
.filter((m) => m.repeatable === true)
|
||||
.map((m) => m.name),
|
||||
);
|
||||
@@ -1793,7 +1834,7 @@ export function formatFlowProgressForAgent(params: {
|
||||
}
|
||||
|
||||
if (catalog) {
|
||||
for (const mod of catalog.modules) {
|
||||
for (const mod of catalogModulesForIntake(catalog, "recipe")) {
|
||||
if (mod.repeatable === true) continue;
|
||||
if (!mod.artifact || !filled.has(mod.artifact)) continue;
|
||||
if (doneNames.has(mod.name)) continue;
|
||||
@@ -1812,7 +1853,10 @@ export function formatFlowProgressForAgent(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const repeatableList = (catalog?.modules ?? [])
|
||||
const repeatableList = (catalog
|
||||
? catalogModulesForIntake(catalog, "recipe")
|
||||
: []
|
||||
)
|
||||
.filter((m) => m.repeatable === true)
|
||||
.map((m) => m.name);
|
||||
|
||||
@@ -1940,7 +1984,9 @@ export function validateCreationFlow(
|
||||
const errors: string[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const allowed = catalog
|
||||
? new Set(catalog.modules.map((m) => m.name))
|
||||
? new Set(
|
||||
catalogModulesForIntake(catalog, "recipe").map((m) => m.name),
|
||||
)
|
||||
: null;
|
||||
const reportedFreshDup = new Set<string>();
|
||||
|
||||
@@ -2224,7 +2270,7 @@ export function listCallableCatalogModules(params: {
|
||||
(params.acceptedStepIds ?? []).map((id) => id.trim()).filter(Boolean),
|
||||
);
|
||||
const out: CreationFlowAvailableModule[] = [];
|
||||
for (const mod of catalog.modules) {
|
||||
for (const mod of catalogModulesForIntake(catalog, "recipe")) {
|
||||
if (scheduled.has(mod.name)) continue;
|
||||
if (!mod.repeatable) {
|
||||
if (mod.artifact && filled.has(mod.artifact)) continue;
|
||||
@@ -2598,7 +2644,7 @@ export function listRepeatableSpawns(
|
||||
catalog: ModuleCatalog,
|
||||
acceptedStepIds: readonly string[],
|
||||
): CreationFlowSpawnView[] {
|
||||
return catalog.modules
|
||||
return catalogModulesForIntake(catalog, "recipe")
|
||||
.filter((m) => m.repeatable === true && !m.closer)
|
||||
.filter(
|
||||
(m) => !flow.steps.some((s) => s.name === m.name && isPrototypeStep(s)),
|
||||
|
||||
+254
-17
@@ -12,11 +12,20 @@ import {
|
||||
stringifyCreationFlow,
|
||||
} from "./creation-flow.js";
|
||||
import { parseContextFragment } from "./context-fragment.js";
|
||||
import {
|
||||
isPresentLikeObject,
|
||||
normalizePresentPacket,
|
||||
stringifyPresentPacket,
|
||||
} from "./present-packet.js";
|
||||
|
||||
export const OPENING_SETUP_ARTIFACT_TAG = "设计.开场白与开场变量";
|
||||
export const OPENING_OUTPUT_TAG = "输出.开场白";
|
||||
export const OPENING_INITIAL_VARS_TAG = "运行.初始变量";
|
||||
export const OPENING_CURRENT_VARS_TAG = "变量.当前";
|
||||
/** 本条开场自带的用户角色预设(名字+简介),不进全局列表 */
|
||||
export const OPENING_PERSONA_TAG = "运行.开场用户角色";
|
||||
/** opening = 本局用开场预设;global = 保持当前用户角色 */
|
||||
export const OPENING_PERSONA_CHOICE_TAG = "运行.开场用户角色.选用";
|
||||
|
||||
export const SLOT_CREATION_SEALED_BY_OPENING = "creationSealedByOpening";
|
||||
export const SLOT_OPENING_SELECTED_INDEX = "openingSelectedIndex";
|
||||
@@ -58,11 +67,17 @@ export type OpeningVariable = {
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type OpeningUserPersona = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type OpeningSealPayload = {
|
||||
candidates: string[];
|
||||
selectedIndex: number;
|
||||
selectedText: string;
|
||||
variables: OpeningVariable[];
|
||||
persona: OpeningUserPersona | null;
|
||||
};
|
||||
|
||||
function asTrimmed(v: unknown): string | undefined {
|
||||
@@ -71,22 +86,96 @@ function asTrimmed(v: unknown): string | undefined {
|
||||
return t || undefined;
|
||||
}
|
||||
|
||||
function collectCandidateTexts(raw: unknown): string[] {
|
||||
type OpeningCandidateEntry = {
|
||||
text: string;
|
||||
persona: OpeningUserPersona | null;
|
||||
variables: OpeningVariable[];
|
||||
};
|
||||
|
||||
function asOpeningBodyText(raw: unknown): string | undefined {
|
||||
if (typeof raw === "string") {
|
||||
const t = raw.trim();
|
||||
if (!t) return undefined;
|
||||
if (t.startsWith("{")) {
|
||||
try {
|
||||
const doc = JSON.parse(t) as unknown;
|
||||
if (
|
||||
isPresentLikeObject(doc) ||
|
||||
(doc &&
|
||||
typeof doc === "object" &&
|
||||
!Array.isArray(doc) &&
|
||||
(doc as Record<string, unknown>).blocks != null)
|
||||
) {
|
||||
return stringifyPresentPacket(
|
||||
normalizePresentPacket(doc as Record<string, unknown>),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* 散文 */
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const row = raw as Record<string, unknown>;
|
||||
if (
|
||||
isPresentLikeObject(row) ||
|
||||
row.blocks != null ||
|
||||
typeof row.shell === "string" ||
|
||||
typeof row.shell_id === "string"
|
||||
) {
|
||||
return stringifyPresentPacket(normalizePresentPacket(row));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function variablesFromCandidate(row: Record<string, unknown>): OpeningVariable[] {
|
||||
const fromRow = collectVariables(row.开场变量);
|
||||
if (fromRow.length) return fromRow;
|
||||
const meta = row.meta;
|
||||
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
||||
return collectVariables((meta as Record<string, unknown>).开场变量);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectCandidateEntries(raw: unknown): OpeningCandidateEntry[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: string[] = [];
|
||||
const out: OpeningCandidateEntry[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item === "string" && item.trim()) {
|
||||
out.push(item.trim());
|
||||
const split = splitYamlFrontmatter(item);
|
||||
out.push({
|
||||
text: asOpeningBodyText(split.text) || split.text,
|
||||
persona: split.persona,
|
||||
variables: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (item && typeof item === "object" && !Array.isArray(item)) {
|
||||
const row = item as Record<string, unknown>;
|
||||
const text =
|
||||
asTrimmed(row.全文) ||
|
||||
asTrimmed(row.text) ||
|
||||
asTrimmed(row.开场白) ||
|
||||
asTrimmed(row.内容);
|
||||
if (text) out.push(text);
|
||||
asOpeningBodyText(row.正文) ||
|
||||
asOpeningBodyText(row.全文) ||
|
||||
asOpeningBodyText(row.text) ||
|
||||
asOpeningBodyText(row.开场白) ||
|
||||
asOpeningBodyText(row.内容);
|
||||
if (!text) continue;
|
||||
const split = splitYamlFrontmatter(text);
|
||||
const persona =
|
||||
parseUserPersonaField(row.用户角色) ||
|
||||
parseUserPersonaField(row.meta) ||
|
||||
parseUserPersonaField(
|
||||
row.meta && typeof row.meta === "object" && !Array.isArray(row.meta)
|
||||
? (row.meta as Record<string, unknown>).用户角色
|
||||
: undefined,
|
||||
) ||
|
||||
split.persona;
|
||||
out.push({
|
||||
text: asOpeningBodyText(split.text) || split.text,
|
||||
persona,
|
||||
variables: variablesFromCandidate(row),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -111,6 +200,128 @@ function collectVariables(raw: unknown): OpeningVariable[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseUserPersonaField(raw: unknown): OpeningUserPersona | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "string") return parseOpeningUserPersona(raw);
|
||||
if (typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const nested = row.用户角色;
|
||||
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
||||
const fromNested = parseUserPersonaField(nested);
|
||||
if (fromNested) return fromNested;
|
||||
}
|
||||
const name =
|
||||
asTrimmed(row.名字) || asTrimmed(row.name) || asTrimmed(row.姓名) || "";
|
||||
if (!name || name === "@玩家" || name === "用户" || name === "主角") return null;
|
||||
const description =
|
||||
asTrimmed(row.简介) ||
|
||||
asTrimmed(row.description) ||
|
||||
asTrimmed(row.人设) ||
|
||||
asTrimmed(row.描述) ||
|
||||
"";
|
||||
return { name, description };
|
||||
}
|
||||
|
||||
function splitYamlFrontmatter(raw: string): {
|
||||
text: string;
|
||||
persona: OpeningUserPersona | null;
|
||||
} {
|
||||
const text = raw.trim();
|
||||
const fence = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!fence) return { text, persona: parseOpeningUserPersona(text) };
|
||||
const persona =
|
||||
parseUserPersonaField(parseSimpleYamlMap(fence[1] ?? "")) ||
|
||||
parseOpeningUserPersona(fence[1] ?? "");
|
||||
return { text: (fence[2] ?? "").trim(), persona };
|
||||
}
|
||||
|
||||
/** 开场正文前的 YAML 式 meta:用户角色名字+简介,其余当正文。 */
|
||||
export function splitOpeningDocument(raw: string | null | undefined): {
|
||||
text: string;
|
||||
persona: OpeningUserPersona | null;
|
||||
} {
|
||||
const text = raw?.trim() ?? "";
|
||||
if (!text) return { text: "", persona: null };
|
||||
const payload = parseOpeningSealPayload(text, 0);
|
||||
if (payload?.selectedText) {
|
||||
return { text: payload.selectedText, persona: payload.persona };
|
||||
}
|
||||
return splitYamlFrontmatter(text);
|
||||
}
|
||||
|
||||
function parseSimpleYamlMap(raw: string): Record<string, unknown> {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const root: Record<string, unknown> = {};
|
||||
let currentKey = "";
|
||||
let currentObj: Record<string, string> | null = null;
|
||||
for (const line of lines) {
|
||||
const nested = line.match(/^[ \t]+([^\s:]+)\s*:\s*(.*)$/);
|
||||
if (nested && currentObj) {
|
||||
currentObj[nested[1]] = nested[2].trim();
|
||||
continue;
|
||||
}
|
||||
const top = line.match(/^([^\s:]+)\s*:\s*(.*)$/);
|
||||
if (!top) continue;
|
||||
const key = top[1];
|
||||
const val = top[2].trim();
|
||||
if (val === "" || val === "|" || val === ">") {
|
||||
currentKey = key;
|
||||
currentObj = {};
|
||||
root[key] = currentObj;
|
||||
continue;
|
||||
}
|
||||
currentKey = "";
|
||||
currentObj = null;
|
||||
root[key] = val;
|
||||
}
|
||||
void currentKey;
|
||||
return root;
|
||||
}
|
||||
|
||||
export function parseOpeningUserPersona(
|
||||
raw: string | null | undefined,
|
||||
): OpeningUserPersona | null {
|
||||
const text = raw?.trim() ?? "";
|
||||
if (!text) return null;
|
||||
try {
|
||||
const doc = JSON.parse(text) as unknown;
|
||||
const fromJson =
|
||||
parseUserPersonaField(doc) ||
|
||||
parseUserPersonaField(
|
||||
doc && typeof doc === "object" && !Array.isArray(doc)
|
||||
? (doc as Record<string, unknown>).用户角色
|
||||
: undefined,
|
||||
);
|
||||
if (fromJson) return fromJson;
|
||||
} catch {
|
||||
/* not json */
|
||||
}
|
||||
const name =
|
||||
text.match(/(?:用户角色\.)?名字[::]\s*([^\n]+)/)?.[1]?.trim() ||
|
||||
text.match(/^名字[::]\s*([^\n]+)/m)?.[1]?.trim() ||
|
||||
"";
|
||||
if (!name || name === "@玩家" || name === "用户" || name === "主角") return null;
|
||||
const description =
|
||||
text.match(/(?:用户角色\.)?简介[::]\s*([^\n]+)/)?.[1]?.trim() ||
|
||||
text.match(/^简介[::]\s*([^\n]+)/m)?.[1]?.trim() ||
|
||||
"";
|
||||
return { name, description };
|
||||
}
|
||||
|
||||
export function serializeOpeningPersona(persona: OpeningUserPersona): string {
|
||||
return JSON.stringify(
|
||||
{ 名字: persona.name, 简介: persona.description },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function readSealedOpeningPersona(
|
||||
raw: string | null | undefined,
|
||||
): OpeningUserPersona | null {
|
||||
return parseUserPersonaField(raw) || parseOpeningUserPersona(raw);
|
||||
}
|
||||
|
||||
function bodyRecord(fragmentBody: unknown): Record<string, unknown> | null {
|
||||
if (!fragmentBody || typeof fragmentBody !== "object" || Array.isArray(fragmentBody)) {
|
||||
return null;
|
||||
@@ -135,32 +346,58 @@ export function parseOpeningSealPayload(
|
||||
})();
|
||||
|
||||
const primary =
|
||||
asTrimmed(body?.开场白全文) ||
|
||||
asTrimmed(body?.["输出.开场白"]) ||
|
||||
(typeof fragment?.正文 === "string" ? asTrimmed(fragment.正文) : undefined);
|
||||
asOpeningBodyText(body?.开场白全文) ||
|
||||
asOpeningBodyText(body?.["输出.开场白"]) ||
|
||||
(typeof fragment?.正文 === "string"
|
||||
? asOpeningBodyText(fragment.正文)
|
||||
: undefined);
|
||||
|
||||
const fromList = collectCandidateTexts(body?.开场白候选);
|
||||
const defaultPersona = parseUserPersonaField(body?.用户角色);
|
||||
const sharedVariables = collectVariables(body?.开场变量);
|
||||
const fromList = collectCandidateEntries(body?.开场白候选);
|
||||
const candidates: string[] = [];
|
||||
const personas: Array<OpeningUserPersona | null> = [];
|
||||
const variableSets: OpeningVariable[][] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (text: string | undefined) => {
|
||||
if (!text || seen.has(text)) return;
|
||||
const push = (
|
||||
text: string | undefined,
|
||||
persona: OpeningUserPersona | null,
|
||||
variables: OpeningVariable[],
|
||||
) => {
|
||||
if (!text) return;
|
||||
if (seen.has(text)) {
|
||||
const i = candidates.indexOf(text);
|
||||
if (i >= 0) {
|
||||
if (persona && !personas[i]) personas[i] = persona;
|
||||
if (variables.length && !variableSets[i]?.length) {
|
||||
variableSets[i] = variables;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
seen.add(text);
|
||||
candidates.push(text);
|
||||
personas.push(persona);
|
||||
variableSets.push(variables);
|
||||
};
|
||||
push(primary);
|
||||
for (const t of fromList) push(t);
|
||||
push(primary, defaultPersona, sharedVariables);
|
||||
for (const row of fromList) {
|
||||
push(row.text, row.persona || defaultPersona, row.variables);
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const max = candidates.length - 1;
|
||||
const idx = Number.isFinite(selectedIndex)
|
||||
? Math.min(max, Math.max(0, Math.trunc(selectedIndex)))
|
||||
: 0;
|
||||
const selectedVars = variableSets[idx] ?? [];
|
||||
|
||||
return {
|
||||
candidates,
|
||||
selectedIndex: idx,
|
||||
selectedText: candidates[idx] ?? candidates[0]!,
|
||||
variables: collectVariables(body?.开场变量),
|
||||
variables: selectedVars.length ? selectedVars : sharedVariables,
|
||||
persona: personas[idx] ?? defaultPersona,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 游玩辅助面:表格 / 开场与角色 等 Tab 的视图截面。
|
||||
*/
|
||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||
import {
|
||||
isTableCellEditable,
|
||||
parseTableDoc,
|
||||
} from "../blackboard/table-cells.js";
|
||||
import {
|
||||
OPENING_PERSONA_CHOICE_TAG,
|
||||
OPENING_SETUP_ARTIFACT_TAG,
|
||||
SLOT_OPENING_SELECTED_INDEX,
|
||||
parseOpeningSealPayload,
|
||||
splitOpeningDocument,
|
||||
type OpeningUserPersona,
|
||||
} from "./opening-seal.js";
|
||||
import { CURRENT_VARS_TAG } from "./variable-catalog.js";
|
||||
import { readSessionProtagonist } from "../persona/protagonist-bind.js";
|
||||
import { presentReadablePlain } from "./present-packet.js";
|
||||
|
||||
export type PlayAuxTableRow = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
rev: number;
|
||||
visibility: "visible" | "hidden";
|
||||
editable: boolean;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type PlayAuxOpening = {
|
||||
index: number;
|
||||
text: string;
|
||||
persona: OpeningUserPersona | null;
|
||||
};
|
||||
|
||||
export type PlayAuxView = {
|
||||
table: { tag: string; rows: PlayAuxTableRow[] } | null;
|
||||
openings: PlayAuxOpening[];
|
||||
selectedOpeningIndex: number;
|
||||
openingPersona: OpeningUserPersona | null;
|
||||
openingPersonaChoice: "opening" | "global" | null;
|
||||
};
|
||||
|
||||
function selectedOpeningIndex(slots: Record<string, unknown> | undefined): number {
|
||||
const raw = slots?.[SLOT_OPENING_SELECTED_INDEX];
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(0, Math.trunc(raw));
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) return Math.max(0, Math.trunc(n));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function collectOpenings(board: Blackboard): PlayAuxOpening[] {
|
||||
const payload = parseOpeningSealPayload(
|
||||
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
|
||||
0,
|
||||
);
|
||||
if (payload?.candidates.length) {
|
||||
return payload.candidates.map((text, index) => {
|
||||
const one = parseOpeningSealPayload(
|
||||
board.getContentByTag(OPENING_SETUP_ARTIFACT_TAG),
|
||||
index,
|
||||
);
|
||||
return {
|
||||
index,
|
||||
text: presentReadablePlain(text) || text,
|
||||
persona: one?.persona ?? payload.persona,
|
||||
};
|
||||
});
|
||||
}
|
||||
const split = splitOpeningDocument(board.getContentByTag("设计.开场白"));
|
||||
if (split.text) {
|
||||
return [{ index: 0, text: split.text, persona: split.persona }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildPlayAuxView(
|
||||
board: Blackboard,
|
||||
slots?: Record<string, unknown>,
|
||||
): PlayAuxView {
|
||||
const tableDoc = parseTableDoc(board.getContentByTag(CURRENT_VARS_TAG));
|
||||
const table = tableDoc?.rows.length
|
||||
? {
|
||||
tag: CURRENT_VARS_TAG,
|
||||
rows: tableDoc.rows.map((row) => ({
|
||||
key: row.key,
|
||||
value: row.value,
|
||||
rev: row.rev,
|
||||
visibility: row.visibility === "hidden" ? "hidden" : "visible",
|
||||
editable: isTableCellEditable(row),
|
||||
...(row.note ? { note: row.note } : {}),
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
const openings = collectOpenings(board);
|
||||
const idx = selectedOpeningIndex(slots);
|
||||
const selectedOpeningIndexClamped = openings.length
|
||||
? Math.min(idx, openings.length - 1)
|
||||
: 0;
|
||||
const sealed = readSessionProtagonist(board);
|
||||
const choiceRaw = board.getContentByTag(OPENING_PERSONA_CHOICE_TAG)?.trim();
|
||||
const openingPersonaChoice =
|
||||
choiceRaw === "opening" || choiceRaw === "global" ? choiceRaw : null;
|
||||
|
||||
return {
|
||||
table,
|
||||
openings,
|
||||
selectedOpeningIndex: selectedOpeningIndexClamped,
|
||||
openingPersona: sealed
|
||||
? { name: sealed.name, description: sealed.description }
|
||||
: openings[selectedOpeningIndexClamped]?.persona ?? null,
|
||||
openingPersonaChoice,
|
||||
};
|
||||
}
|
||||
@@ -381,9 +381,29 @@ export function isPresentLikeObject(doc: unknown): boolean {
|
||||
const row = doc as Record<string, unknown>;
|
||||
if (row.schema === PRESENT_SCHEMA) return true;
|
||||
if (isPresentShellId(row.shell) && row.blocks != null) return true;
|
||||
if (isPresentShellId(row.shell_id) && row.blocks != null) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 开场/卡片预览:取出 present.v1 主读正文;散文原样返回。 */
|
||||
export function presentReadablePlain(raw: string | null | undefined): string {
|
||||
const text = (raw ?? "").trim();
|
||||
if (!text) return "";
|
||||
const view = parsePresentPacket(text);
|
||||
const body = view.packet.blocks.body;
|
||||
if (typeof body === "string" && body.trim()) return body.trim();
|
||||
if (body && typeof body === "object") {
|
||||
try {
|
||||
return JSON.stringify(body);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
const header = view.packet.blocks.header;
|
||||
if (typeof header === "string" && header.trim()) return header.trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从「设计.正文组成」context-fragment 解析壳适配。
|
||||
*/
|
||||
|
||||
@@ -234,6 +234,29 @@ export function upsertValueMapEntry(
|
||||
return { doc: { schema: VALUE_MAP_SCHEMA, maps } };
|
||||
}
|
||||
|
||||
/** 按 id 移除一条映射;不存在则 error。返回被删条目供调用方清投影 tag */
|
||||
export function removeValueMapEntry(
|
||||
current: ValueMapDoc | null,
|
||||
idRaw: string,
|
||||
): {
|
||||
doc: ValueMapDoc;
|
||||
error?: string;
|
||||
removed?: ValueMapEntry;
|
||||
} {
|
||||
const empty: ValueMapDoc = { schema: VALUE_MAP_SCHEMA, maps: [] };
|
||||
const id = idRaw.trim();
|
||||
if (!id) return { doc: current ?? empty, error: "id 不能为空" };
|
||||
const maps = current?.maps ?? [];
|
||||
const idx = maps.findIndex((m) => m.id === id);
|
||||
if (idx < 0) return { doc: current ?? empty, error: `映射无 id ${id}` };
|
||||
const removed = maps[idx]!;
|
||||
const next = maps.filter((_, i) => i !== idx);
|
||||
return {
|
||||
doc: { schema: VALUE_MAP_SCHEMA, maps: next },
|
||||
removed,
|
||||
};
|
||||
}
|
||||
|
||||
/** 按真值查一档正文;无命中返回 null */
|
||||
export function lookupValueMapContent(
|
||||
entry: ValueMapEntry,
|
||||
|
||||
@@ -22,6 +22,8 @@ export type VariableCatalogField = {
|
||||
initial: unknown;
|
||||
/** 对用户可见(监控栏等);默认 true */
|
||||
user_visible: boolean;
|
||||
/** 游玩表上手改;默认跟可见性走 */
|
||||
user_editable?: boolean;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
@@ -84,11 +86,19 @@ export function parseVariableCatalog(raw: string | undefined | null): VariableCa
|
||||
f.userVisible === false
|
||||
? false
|
||||
: true;
|
||||
const editableRaw = f.user_editable ?? f.可手改 ?? f.userEditable;
|
||||
const userEditable =
|
||||
editableRaw === false
|
||||
? false
|
||||
: editableRaw === true
|
||||
? true
|
||||
: userVisible;
|
||||
fields.push({
|
||||
key,
|
||||
type,
|
||||
initial: coerceInitial(type, f.initial ?? f.初值 ?? f.value),
|
||||
user_visible: userVisible,
|
||||
user_editable: userEditable,
|
||||
note: asString(f.note) ?? asString(f.备注),
|
||||
});
|
||||
}
|
||||
@@ -108,6 +118,7 @@ export function serializeVariableCatalog(doc: VariableCatalogDoc): string {
|
||||
type: f.type,
|
||||
initial: f.initial,
|
||||
user_visible: f.user_visible,
|
||||
user_editable: f.user_editable ?? f.user_visible,
|
||||
...(f.note ? { note: f.note } : {}),
|
||||
})),
|
||||
},
|
||||
@@ -121,6 +132,7 @@ export type UpsertVariableInput = {
|
||||
type?: string;
|
||||
initial?: unknown;
|
||||
user_visible?: boolean;
|
||||
user_editable?: boolean;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
@@ -136,6 +148,10 @@ export function upsertVariableField(
|
||||
type,
|
||||
initial: coerceInitial(type, input.initial),
|
||||
user_visible: input.user_visible !== false,
|
||||
user_editable:
|
||||
input.user_editable !== undefined
|
||||
? input.user_editable
|
||||
: input.user_visible !== false,
|
||||
note: input.note?.trim() || undefined,
|
||||
};
|
||||
const fields = [...(current?.fields ?? [])];
|
||||
@@ -145,17 +161,44 @@ export function upsertVariableField(
|
||||
return { doc: { schema: VARIABLE_CATALOG_SCHEMA, fields } };
|
||||
}
|
||||
|
||||
/** 从目录移除一个真值字段;不存在则 error */
|
||||
export function removeVariableField(
|
||||
current: VariableCatalogDoc | null,
|
||||
keyRaw: string,
|
||||
): { doc: VariableCatalogDoc; error?: string; removed?: boolean } {
|
||||
const empty: VariableCatalogDoc = {
|
||||
schema: VARIABLE_CATALOG_SCHEMA,
|
||||
fields: [],
|
||||
};
|
||||
const key = keyRaw.trim();
|
||||
if (!key) return { doc: current ?? empty, error: "key 不能为空" };
|
||||
const fields = current?.fields ?? [];
|
||||
const next = fields.filter((f) => f.key !== key);
|
||||
if (next.length === fields.length) {
|
||||
return { doc: current ?? empty, error: `目录无字段 ${key}` };
|
||||
}
|
||||
return {
|
||||
doc: { schema: VARIABLE_CATALOG_SCHEMA, fields: next },
|
||||
removed: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 目录 → 表文档(初值);用于种 运行.初始变量 / 变量.当前 */
|
||||
export function catalogToTableDoc(
|
||||
catalog: VariableCatalogDoc,
|
||||
actor: string,
|
||||
): TableDoc {
|
||||
const values: Record<string, unknown> = {};
|
||||
const meta: Record<string, { visibility?: "visible" | "hidden"; note?: string }> = {};
|
||||
const meta: Record<
|
||||
string,
|
||||
{ visibility?: "visible" | "hidden"; note?: string; editable?: boolean }
|
||||
> = {};
|
||||
for (const f of catalog.fields) {
|
||||
values[f.key] = f.initial;
|
||||
const editable = f.user_editable ?? f.user_visible;
|
||||
meta[f.key] = {
|
||||
visibility: f.user_visible ? "visible" : "hidden",
|
||||
editable,
|
||||
note: f.note,
|
||||
};
|
||||
}
|
||||
@@ -192,12 +235,16 @@ export function seedVariablesFromCatalog(params: {
|
||||
// 补缺键,不覆盖已有
|
||||
const have = new Set(existingCurrent.rows.map((r) => r.key));
|
||||
const patchValues: Record<string, unknown> = {};
|
||||
const meta: Record<string, { visibility?: "visible" | "hidden"; note?: string }> = {};
|
||||
const meta: Record<
|
||||
string,
|
||||
{ visibility?: "visible" | "hidden"; note?: string; editable?: boolean }
|
||||
> = {};
|
||||
for (const f of catalog.fields) {
|
||||
if (have.has(f.key)) continue;
|
||||
patchValues[f.key] = f.initial;
|
||||
meta[f.key] = {
|
||||
visibility: f.user_visible ? "visible" : "hidden",
|
||||
editable: f.user_editable ?? f.user_visible,
|
||||
note: f.note,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,6 +238,8 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
"世界.拓扑.*",
|
||||
"设计.变量设计与更新规则",
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
"设计.故事推进",
|
||||
"设计.正文组成",
|
||||
"设计.监控栏",
|
||||
],
|
||||
@@ -265,6 +267,7 @@ const DEFAULT_WORKER_CONTRACTS: Record<
|
||||
"设计.worker集",
|
||||
"设计.叙事指南与故事推进",
|
||||
"设计.叙事指南",
|
||||
"设计.故事推进",
|
||||
"设计.正文组成",
|
||||
"设计.监控栏",
|
||||
],
|
||||
|
||||
@@ -872,6 +872,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
messages: rest,
|
||||
stream: params.stream,
|
||||
caller: `worker:${worker.id}`,
|
||||
label: worker.name?.trim() || worker.id,
|
||||
});
|
||||
// 主世界 harness 终稿是 Markdown;勿强行抽 JSON
|
||||
return parseWorkerResponse(harness.content, worker.outputTags, {
|
||||
@@ -888,7 +889,11 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
: undefined;
|
||||
|
||||
if (playGmBody) {
|
||||
const completeOpts = { caller: `worker:${worker.id}`, responseFormat: "text" as const };
|
||||
const completeOpts = {
|
||||
caller: `worker:${worker.id}`,
|
||||
label: worker.name?.trim() || worker.id,
|
||||
responseFormat: "text" as const,
|
||||
};
|
||||
const result =
|
||||
streamCallbacks && params.llm.completeStream
|
||||
? await params.llm.completeStream(messages, completeOpts, streamCallbacks)
|
||||
@@ -908,6 +913,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
schemaIsLoose: !playPresent,
|
||||
capabilities: resolveActiveProfile()?.capabilities,
|
||||
caller: `worker:${worker.id}`,
|
||||
label: worker.name?.trim() || worker.id,
|
||||
stream: streamCallbacks,
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export type GmHarnessParams = {
|
||||
stream?: GmHarnessStreamCallbacks;
|
||||
maxIterations?: number;
|
||||
caller?: string;
|
||||
/** 日志短名;缺则「主世界层 harness」 */
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type GmHarnessResult = {
|
||||
@@ -38,7 +40,9 @@ export async function runGmHarness(
|
||||
params: GmHarnessParams,
|
||||
): Promise<GmHarnessResult> {
|
||||
const driver = createLocalLlmDriver(params.llm);
|
||||
const label = "主世界层 harness";
|
||||
const label = params.label?.trim()
|
||||
? `${params.label.trim()} · 主世界层`
|
||||
: "主世界层 harness";
|
||||
|
||||
const result = await driver.run({
|
||||
system: params.system,
|
||||
|
||||
Reference in New Issue
Block a user