Add capability-probed structured outputs and a prototype/instance creation graph.
Probe API profiles for json_schema/tools, flatten worker artifacts onto a strict schema, and let users spawn or revise graph nodes instead of a one-shot DAG. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+79
-28
@@ -1,6 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
normalizeCapabilities,
|
||||
probeProfileCapabilities,
|
||||
type ProfileCapabilities,
|
||||
} from "../llm/capabilities.js";
|
||||
import { sanitizeReasoningEffort } from "../llm/generation-compat.js";
|
||||
import type { LlmConfig } from "./env.js";
|
||||
import { loadLlmConfigOptional } from "./env.js";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
|
||||
@@ -11,8 +17,15 @@ export type ApiProfile = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/**
|
||||
* 该配置默认思考强度(写入请求的 reasoning_effort)。
|
||||
* 省略 / auto = 不发送;探测永远用 low,不受此项影响。
|
||||
*/
|
||||
reasoningEffort?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** 能力探测结果;改 baseUrl/model 后应重测 */
|
||||
capabilities?: ProfileCapabilities;
|
||||
};
|
||||
|
||||
type ApiProfilesFile = {
|
||||
@@ -45,6 +58,16 @@ function writeFile(data: ApiProfilesFile): void {
|
||||
writeFileSync(profilesPath(), JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
/** 存盘值:合法档位原样保留;空 / auto / 非法 → undefined(不发送) */
|
||||
export function normalizeProfileReasoningEffort(
|
||||
value: unknown,
|
||||
): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === "auto") return undefined;
|
||||
return sanitizeReasoningEffort(trimmed);
|
||||
}
|
||||
|
||||
function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
|
||||
if (data.profiles.length > 0) return data;
|
||||
const env = loadLlmConfigOptional();
|
||||
@@ -79,15 +102,18 @@ export function createApiProfile(input: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort?: string;
|
||||
}): ApiProfile {
|
||||
const data = readFile();
|
||||
const now = new Date().toISOString();
|
||||
const reasoningEffort = normalizeProfileReasoningEffort(input.reasoningEffort);
|
||||
const profile: ApiProfile = {
|
||||
id: randomUUID(),
|
||||
name: input.name.trim() || "未命名",
|
||||
baseUrl: input.baseUrl.trim() || "https://api.deepseek.com",
|
||||
apiKey: input.apiKey.trim(),
|
||||
model: input.model.trim() || "deepseek-v4-pro",
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -98,20 +124,52 @@ export function createApiProfile(input: {
|
||||
|
||||
export function updateApiProfile(
|
||||
id: string,
|
||||
input: Partial<Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model">>,
|
||||
input: Partial<
|
||||
Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model" | "reasoningEffort">
|
||||
>,
|
||||
): ApiProfile {
|
||||
const data = readFile();
|
||||
const index = data.profiles.findIndex((p) => p.id === id);
|
||||
if (index < 0) throw new Error("API 配置不存在");
|
||||
const current = data.profiles[index];
|
||||
const nextBase = input.baseUrl?.trim() || current.baseUrl;
|
||||
const nextModel = input.model?.trim() || current.model;
|
||||
const endpointChanged =
|
||||
nextBase !== current.baseUrl || nextModel !== current.model;
|
||||
const reasoningEffort =
|
||||
input.reasoningEffort !== undefined
|
||||
? normalizeProfileReasoningEffort(input.reasoningEffort)
|
||||
: current.reasoningEffort;
|
||||
const updated: ApiProfile = {
|
||||
...current,
|
||||
name: input.name?.trim() || current.name,
|
||||
baseUrl: input.baseUrl?.trim() || current.baseUrl,
|
||||
baseUrl: nextBase,
|
||||
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== ""
|
||||
? input.apiKey.trim()
|
||||
: current.apiKey,
|
||||
model: input.model?.trim() || current.model,
|
||||
model: nextModel,
|
||||
updatedAt: new Date().toISOString(),
|
||||
// 端点或模型变了,旧探测作废
|
||||
...(endpointChanged ? { capabilities: undefined } : {}),
|
||||
};
|
||||
if (reasoningEffort) updated.reasoningEffort = reasoningEffort;
|
||||
else delete updated.reasoningEffort;
|
||||
data.profiles[index] = updated;
|
||||
writeFile(data);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function saveProfileCapabilities(
|
||||
id: string,
|
||||
capabilities: ProfileCapabilities,
|
||||
): ApiProfile {
|
||||
const data = readFile();
|
||||
const index = data.profiles.findIndex((p) => p.id === id);
|
||||
if (index < 0) throw new Error("API 配置不存在");
|
||||
const current = data.profiles[index]!;
|
||||
const updated: ApiProfile = {
|
||||
...current,
|
||||
capabilities: normalizeCapabilities(capabilities) ?? capabilities,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
data.profiles[index] = updated;
|
||||
@@ -119,6 +177,20 @@ export function updateApiProfile(
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 探测并持久化;返回更新后的 profile */
|
||||
export async function probeAndSaveProfileCapabilities(
|
||||
id: string,
|
||||
): Promise<ApiProfile> {
|
||||
const profile = getApiProfile(id);
|
||||
if (!profile) throw new Error("API 配置不存在");
|
||||
const capabilities = await probeProfileCapabilities({
|
||||
baseUrl: profile.baseUrl,
|
||||
apiKey: profile.apiKey,
|
||||
model: profile.model,
|
||||
});
|
||||
return saveProfileCapabilities(id, capabilities);
|
||||
}
|
||||
|
||||
export function deleteApiProfile(id: string): void {
|
||||
const data = readFile();
|
||||
data.profiles = data.profiles.filter((p) => p.id !== id);
|
||||
@@ -130,30 +202,9 @@ export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
|
||||
baseUrl: profile.baseUrl,
|
||||
apiKey: profile.apiKey,
|
||||
model: profile.model,
|
||||
...(profile.reasoningEffort
|
||||
? { reasoningEffort: profile.reasoningEffort }
|
||||
: {}),
|
||||
capabilities: normalizeCapabilities(profile.capabilities),
|
||||
};
|
||||
}
|
||||
|
||||
export async function testApiProfile(profile: ApiProfile): Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
const url = `${profile.baseUrl.replace(/\/$/, "")}/models`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${profile.apiKey}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "连接成功" };
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
ok: false,
|
||||
message: `HTTP ${response.status}: ${body.slice(0, 200)}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : "连接失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ProfileCapabilities } from "../llm/capabilities.js";
|
||||
|
||||
export type LlmConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/**
|
||||
* API 配置上的默认思考强度;generation 未指定时写入 reasoning_effort。
|
||||
* 空 / auto = 不发送,走供应商默认。
|
||||
*/
|
||||
reasoningEffort?: string;
|
||||
/** 来自 API profile 探测结果;决定 structured 投递选路 */
|
||||
capabilities?: ProfileCapabilities;
|
||||
};
|
||||
|
||||
export function loadLlmConfig(): LlmConfig {
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* API profile 能力探测与 structured 投递选路。
|
||||
* 优先 response json_schema;不可用则 forced tool;再退 json_object。
|
||||
*/
|
||||
|
||||
import {
|
||||
applyRejectedGenerationField,
|
||||
parseRejectedGenerationField,
|
||||
} from "./generation-compat.js";
|
||||
|
||||
export type CapabilityStatus = "ok" | "fail" | "unknown";
|
||||
|
||||
export type StructuredDeliveryMode =
|
||||
| "json_schema"
|
||||
| "forced_tool"
|
||||
| "json_object"
|
||||
| "text";
|
||||
|
||||
export type ProfileCapabilities = {
|
||||
/** 普通聊天 /models 或极简 completion */
|
||||
chat: CapabilityStatus;
|
||||
/** response_format: json_object */
|
||||
jsonObject: CapabilityStatus;
|
||||
/** response_format: json_schema(真 schema 约束) */
|
||||
jsonSchema: CapabilityStatus;
|
||||
/** tools + 返回 tool_calls */
|
||||
tools: CapabilityStatus;
|
||||
/** tool_choice=required(或指名函数)能强制提交 */
|
||||
forcedTool: CapabilityStatus;
|
||||
/** tools[].function.strict(若探测时带了) */
|
||||
strictTool: CapabilityStatus;
|
||||
testedAt?: string;
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
export const UNKNOWN_CAPABILITIES: ProfileCapabilities = {
|
||||
chat: "unknown",
|
||||
jsonObject: "unknown",
|
||||
jsonSchema: "unknown",
|
||||
tools: "unknown",
|
||||
forcedTool: "unknown",
|
||||
strictTool: "unknown",
|
||||
};
|
||||
|
||||
/** 探测固定 low,避免 always-on 思考把 max_tokens 吃光导致误判 */
|
||||
export const CAPABILITY_PROBE_REASONING_EFFORT = "low" as const;
|
||||
|
||||
const PROBE_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["ping", "n"],
|
||||
properties: {
|
||||
ping: { type: "string" },
|
||||
n: { type: "number" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROBE_TOOL = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "submit_probe",
|
||||
description: "Submit the probe result. Always call this tool.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["ping", "n"],
|
||||
properties: {
|
||||
ping: { type: "string" },
|
||||
n: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PROBE_TOOL_STRICT = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
...PROBE_TOOL.function,
|
||||
strict: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function normalizeCapabilities(
|
||||
raw: unknown,
|
||||
): ProfileCapabilities | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const read = (k: string): CapabilityStatus => {
|
||||
const v = row[k];
|
||||
if (v === "ok" || v === "fail" || v === "unknown") return v;
|
||||
return "unknown";
|
||||
};
|
||||
return {
|
||||
chat: read("chat"),
|
||||
jsonObject: read("jsonObject"),
|
||||
jsonSchema: read("jsonSchema"),
|
||||
tools: read("tools"),
|
||||
forcedTool: read("forcedTool"),
|
||||
strictTool: read("strictTool"),
|
||||
testedAt: typeof row.testedAt === "string" ? row.testedAt : undefined,
|
||||
notes: Array.isArray(row.notes)
|
||||
? row.notes.filter((x): x is string => typeof x === "string")
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 选路:schema → forced tool → json_object → text */
|
||||
export function resolveStructuredDeliveryMode(
|
||||
caps: ProfileCapabilities | null | undefined,
|
||||
): StructuredDeliveryMode {
|
||||
if (!caps) return "json_object";
|
||||
if (caps.jsonSchema === "ok") return "json_schema";
|
||||
if (caps.forcedTool === "ok") return "forced_tool";
|
||||
if (caps.jsonObject === "ok") return "json_object";
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function deliveryModeLabel(mode: StructuredDeliveryMode): string {
|
||||
switch (mode) {
|
||||
case "json_schema":
|
||||
return "JSON Schema";
|
||||
case "forced_tool":
|
||||
return "强制 Tool";
|
||||
case "json_object":
|
||||
return "JSON Object";
|
||||
default:
|
||||
return "纯文本";
|
||||
}
|
||||
}
|
||||
|
||||
type ProbeConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
async function postOnce(
|
||||
config: ProbeConfig,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
|
||||
const url = `${config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ model: config.model, ...body }),
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, json, text };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
json: null,
|
||||
text: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测请求始终带 reasoning_effort=low。
|
||||
* 若供应商拒收该字段,剥掉后再试一次。
|
||||
*/
|
||||
async function postChat(
|
||||
config: ProbeConfig,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
|
||||
const withEffort: Record<string, unknown> = {
|
||||
...body,
|
||||
reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT,
|
||||
};
|
||||
const first = await postOnce(config, withEffort);
|
||||
if (first.ok || first.status !== 400) return first;
|
||||
|
||||
const rejected = parseRejectedGenerationField(first.text);
|
||||
if (rejected !== "reasoning_effort") return first;
|
||||
|
||||
const retryBody = { ...withEffort };
|
||||
const tried = new Set<string>();
|
||||
if (!applyRejectedGenerationField(retryBody, "reasoning_effort", tried)) {
|
||||
return first;
|
||||
}
|
||||
return postOnce(config, retryBody);
|
||||
}
|
||||
|
||||
function assistantMessage(json: unknown): Record<string, unknown> | null {
|
||||
if (!json || typeof json !== "object") return null;
|
||||
const choices = (json as { choices?: unknown }).choices;
|
||||
if (!Array.isArray(choices) || !choices[0] || typeof choices[0] !== "object") {
|
||||
return null;
|
||||
}
|
||||
const msg = (choices[0] as { message?: unknown }).message;
|
||||
if (!msg || typeof msg !== "object") return null;
|
||||
return msg as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hasToolCallNamed(json: unknown, name: string): boolean {
|
||||
const msg = assistantMessage(json);
|
||||
if (!msg) return false;
|
||||
const calls = msg.tool_calls;
|
||||
if (!Array.isArray(calls)) return false;
|
||||
return calls.some((c) => {
|
||||
if (!c || typeof c !== "object") return false;
|
||||
const fn = (c as { function?: unknown }).function;
|
||||
if (!fn || typeof fn !== "object") return false;
|
||||
return (fn as { name?: unknown }).name === name;
|
||||
});
|
||||
}
|
||||
|
||||
function contentLooksLikeJsonObject(json: unknown): boolean {
|
||||
const msg = assistantMessage(json);
|
||||
const content = msg?.content;
|
||||
if (typeof content !== "string" || !content.trim()) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return parsed != null && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个 profile 跑一轮能力探测(会真实打 API,费用很小)。
|
||||
*/
|
||||
export async function probeProfileCapabilities(
|
||||
config: ProbeConfig,
|
||||
): Promise<ProfileCapabilities> {
|
||||
const notes: string[] = [];
|
||||
const caps: ProfileCapabilities = {
|
||||
...UNKNOWN_CAPABILITIES,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const chat = await postChat(config, {
|
||||
messages: [{ role: "user", content: "Reply with exactly: ok" }],
|
||||
max_tokens: 32,
|
||||
});
|
||||
caps.chat = chat.ok ? "ok" : "fail";
|
||||
if (!chat.ok) {
|
||||
notes.push(`chat: HTTP ${chat.status} ${chat.text.slice(0, 160)}`);
|
||||
caps.notes = notes;
|
||||
return caps;
|
||||
}
|
||||
|
||||
const jsonObject = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
'Return a JSON object with keys ping (string "ok") and n (number 1).',
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
max_tokens: 128,
|
||||
});
|
||||
caps.jsonObject =
|
||||
jsonObject.ok && contentLooksLikeJsonObject(jsonObject.json) ? "ok" : "fail";
|
||||
if (!jsonObject.ok) {
|
||||
notes.push(`json_object: HTTP ${jsonObject.status} ${jsonObject.text.slice(0, 120)}`);
|
||||
} else if (caps.jsonObject === "fail") {
|
||||
notes.push("json_object: 返回了但不是可解析对象");
|
||||
}
|
||||
|
||||
const jsonSchema = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Fill ping=ok and n=1.",
|
||||
},
|
||||
],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "probe_result",
|
||||
strict: true,
|
||||
schema: PROBE_SCHEMA,
|
||||
},
|
||||
},
|
||||
max_tokens: 128,
|
||||
});
|
||||
caps.jsonSchema =
|
||||
jsonSchema.ok && contentLooksLikeJsonObject(jsonSchema.json) ? "ok" : "fail";
|
||||
if (!jsonSchema.ok) {
|
||||
notes.push(`json_schema: HTTP ${jsonSchema.status} ${jsonSchema.text.slice(0, 120)}`);
|
||||
}
|
||||
|
||||
const tools = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call submit_probe with ping=ok and n=1.",
|
||||
},
|
||||
],
|
||||
tools: [PROBE_TOOL],
|
||||
tool_choice: "auto",
|
||||
max_tokens: 256,
|
||||
});
|
||||
caps.tools = tools.ok && hasToolCallNamed(tools.json, "submit_probe") ? "ok" : "fail";
|
||||
if (!tools.ok) {
|
||||
notes.push(`tools: HTTP ${tools.status} ${tools.text.slice(0, 120)}`);
|
||||
} else if (caps.tools === "fail") {
|
||||
notes.push("tools: 请求成功但未返回 submit_probe");
|
||||
}
|
||||
|
||||
const forced = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Submit the probe.",
|
||||
},
|
||||
],
|
||||
tools: [PROBE_TOOL],
|
||||
tool_choice: "required",
|
||||
max_tokens: 256,
|
||||
});
|
||||
let forcedOk =
|
||||
forced.ok && hasToolCallNamed(forced.json, "submit_probe");
|
||||
if (!forcedOk && forced.ok) {
|
||||
const named = await postChat(config, {
|
||||
messages: [{ role: "user", content: "Submit the probe." }],
|
||||
tools: [PROBE_TOOL],
|
||||
tool_choice: {
|
||||
type: "function",
|
||||
function: { name: "submit_probe" },
|
||||
},
|
||||
max_tokens: 256,
|
||||
});
|
||||
forcedOk = named.ok && hasToolCallNamed(named.json, "submit_probe");
|
||||
if (!named.ok) {
|
||||
notes.push(`forced_tool(named): HTTP ${named.status} ${named.text.slice(0, 120)}`);
|
||||
}
|
||||
} else if (!forced.ok) {
|
||||
notes.push(`forced_tool: HTTP ${forced.status} ${forced.text.slice(0, 120)}`);
|
||||
}
|
||||
caps.forcedTool = forcedOk ? "ok" : "fail";
|
||||
|
||||
if (caps.tools === "ok" || caps.forcedTool === "ok") {
|
||||
const strict = await postChat(config, {
|
||||
messages: [{ role: "user", content: "Submit the probe." }],
|
||||
tools: [PROBE_TOOL_STRICT],
|
||||
tool_choice: "required",
|
||||
max_tokens: 256,
|
||||
});
|
||||
caps.strictTool =
|
||||
strict.ok && hasToolCallNamed(strict.json, "submit_probe") ? "ok" : "fail";
|
||||
if (!strict.ok) {
|
||||
notes.push(`strict_tool: HTTP ${strict.status} ${strict.text.slice(0, 120)}`);
|
||||
}
|
||||
} else {
|
||||
caps.strictTool = "fail";
|
||||
}
|
||||
|
||||
caps.notes = notes.length ? notes : undefined;
|
||||
return caps;
|
||||
}
|
||||
+56
-37
@@ -1,11 +1,18 @@
|
||||
import type { LlmConfig } from "../config/env.js";
|
||||
import type { GenerationParameters } from "../types/preset.js";
|
||||
import {
|
||||
applyOpenAiGeneration,
|
||||
fetchWithGenerationCompat,
|
||||
sanitizeReasoningEffort,
|
||||
} from "./generation-compat.js";
|
||||
import { consumeOpenAiToolStream } from "./stream-complete.js";
|
||||
import {
|
||||
currentAbortSignal,
|
||||
throwIfAborted,
|
||||
} from "./run-abort.js";
|
||||
|
||||
export { sanitizeReasoningEffort };
|
||||
|
||||
export type ToolCallPayload = {
|
||||
id: string;
|
||||
type: "function";
|
||||
@@ -30,6 +37,8 @@ export type ToolDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
/** 部分供应商支持的严格参数约束 */
|
||||
strict?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -57,8 +66,16 @@ export type CompleteResult = {
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type JsonSchemaFormat = {
|
||||
name: string;
|
||||
strict?: boolean;
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CompleteOptions = {
|
||||
responseFormat?: "json_object" | "text";
|
||||
responseFormat?: "json_object" | "json_schema" | "text";
|
||||
/** responseFormat=json_schema 时使用 */
|
||||
jsonSchema?: JsonSchemaFormat;
|
||||
generation?: GenerationParameters;
|
||||
/** 统计用途,如 main_agent / worker:write-rules */
|
||||
caller?: string;
|
||||
@@ -66,8 +83,15 @@ export type CompleteOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type ToolChoice =
|
||||
| "auto"
|
||||
| "required"
|
||||
| "none"
|
||||
| { type: "function"; function: { name: string } };
|
||||
|
||||
export type CompleteWithToolsOptions = CompleteOptions & {
|
||||
tools: ToolDefinition[];
|
||||
toolChoice?: ToolChoice;
|
||||
};
|
||||
|
||||
export type CompleteWithToolsResult = {
|
||||
@@ -104,12 +128,11 @@ export type LlmProvider = {
|
||||
): Promise<CompleteWithToolsResult>;
|
||||
};
|
||||
|
||||
function buildRequestBody(
|
||||
export function buildRequestBody(
|
||||
config: LlmConfig,
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const gen = options?.generation ?? {};
|
||||
const body: Record<string, unknown> = {
|
||||
model: config.model,
|
||||
messages,
|
||||
@@ -117,34 +140,28 @@ function buildRequestBody(
|
||||
|
||||
if (options?.tools?.length) {
|
||||
body.tools = options.tools;
|
||||
body.tool_choice = "auto";
|
||||
body.tool_choice =
|
||||
(options as CompleteWithToolsOptions).toolChoice ?? "auto";
|
||||
}
|
||||
|
||||
if (gen.temperature !== undefined) body.temperature = gen.temperature;
|
||||
else body.temperature = 0.2;
|
||||
|
||||
if (gen.topP !== undefined) body.top_p = gen.topP;
|
||||
if (gen.topK !== undefined) body.top_k = gen.topK;
|
||||
if (gen.minP !== undefined) body.min_p = gen.minP;
|
||||
if (gen.frequencyPenalty !== undefined) {
|
||||
body.frequency_penalty = gen.frequencyPenalty;
|
||||
}
|
||||
if (gen.presencePenalty !== undefined) {
|
||||
body.presence_penalty = gen.presencePenalty;
|
||||
}
|
||||
if (gen.repetitionPenalty !== undefined) {
|
||||
body.repetition_penalty = gen.repetitionPenalty;
|
||||
}
|
||||
if (gen.maxOutputTokens !== undefined) {
|
||||
body.max_tokens = gen.maxOutputTokens;
|
||||
}
|
||||
if (gen.seed !== undefined) body.seed = gen.seed;
|
||||
if (gen.reasoningEffort !== undefined) {
|
||||
body.reasoning_effort = gen.reasoningEffort;
|
||||
}
|
||||
applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
});
|
||||
|
||||
if (options?.responseFormat === "json_object") {
|
||||
body.response_format = { type: "json_object" };
|
||||
} else if (
|
||||
options?.responseFormat === "json_schema" &&
|
||||
options.jsonSchema
|
||||
) {
|
||||
body.response_format = {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: options.jsonSchema.name,
|
||||
strict: options.jsonSchema.strict ?? true,
|
||||
schema: options.jsonSchema.schema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (options?.stream) {
|
||||
@@ -246,19 +263,21 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
|
||||
async function llmFetch(
|
||||
url: string,
|
||||
apiKey: string,
|
||||
body: unknown,
|
||||
body: Record<string, unknown>,
|
||||
options?: CompleteOptions,
|
||||
): Promise<Response> {
|
||||
throwIfAborted(options?.signal);
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal ?? currentAbortSignal(),
|
||||
});
|
||||
return fetchWithGenerationCompat(async (payload) => {
|
||||
throwIfAborted(options?.signal);
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal ?? currentAbortSignal(),
|
||||
});
|
||||
}, body);
|
||||
}
|
||||
|
||||
export class OpenAiCompatibleProvider implements LlmProvider {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { GenerationParameters } from "../types/preset.js";
|
||||
|
||||
/**
|
||||
* OpenAI-compatible Chat Completions 的生成参数兼容层,对齐 SillyTavern Custom 源:
|
||||
* - `auto` / 关闭值不发送
|
||||
* - top_k、min_p、repetition_penalty 不是官方 Chat Completions 字段,默认不发
|
||||
* - 推理模型去掉 temperature / top_p / penalty
|
||||
* - o 系列与 gpt-5 用 max_completion_tokens
|
||||
* - 仍 400 时按错误信息剥掉不支持字段再试
|
||||
*/
|
||||
|
||||
const REASONING_EFFORT_VALUES = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
type ReasoningEffort = (typeof REASONING_EFFORT_VALUES)[number];
|
||||
|
||||
const REASONING_EFFORT_ALIASES: Record<string, ReasoningEffort> = {
|
||||
min: "minimal",
|
||||
};
|
||||
|
||||
const VERBOSITY_VALUES = ["low", "medium", "high"] as const;
|
||||
type Verbosity = (typeof VERBOSITY_VALUES)[number];
|
||||
|
||||
export const STRIPPABLE_GENERATION_FIELDS = new Set([
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"min_p",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"repetition_penalty",
|
||||
"seed",
|
||||
"n",
|
||||
"reasoning_effort",
|
||||
"verbosity",
|
||||
"max_tokens",
|
||||
"max_completion_tokens",
|
||||
"logit_bias",
|
||||
"stop",
|
||||
"response_format",
|
||||
"stream_options",
|
||||
]);
|
||||
|
||||
export type ChatModelCompat = {
|
||||
omitSampling: boolean;
|
||||
useMaxCompletionTokens: boolean;
|
||||
supportsVerbosity: boolean;
|
||||
};
|
||||
|
||||
function modelLeaf(model: string): string {
|
||||
const trimmed = model.trim().toLowerCase();
|
||||
const slash = trimmed.lastIndexOf("/");
|
||||
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
|
||||
}
|
||||
|
||||
export function classifyChatModel(model: string): ChatModelCompat {
|
||||
const id = modelLeaf(model);
|
||||
const isOSeries = /^o[134]($|[-.\d])/.test(id);
|
||||
const isGpt5 = id.includes("gpt-5");
|
||||
const isGpt5Chat = isGpt5 && id.includes("chat");
|
||||
const isKimi25 = id.includes("kimi-k2.5");
|
||||
const isDeepseekReasoner = id.includes("deepseek-reasoner");
|
||||
const isGrok3Mini = id.includes("grok-3-mini");
|
||||
|
||||
return {
|
||||
omitSampling:
|
||||
isOSeries ||
|
||||
(isGpt5 && !isGpt5Chat) ||
|
||||
isKimi25 ||
|
||||
isDeepseekReasoner ||
|
||||
isGrok3Mini,
|
||||
useMaxCompletionTokens: isOSeries || isGpt5,
|
||||
supportsVerbosity: isGpt5,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeReasoningEffort(
|
||||
value: unknown,
|
||||
): ReasoningEffort | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const key = value.trim().toLowerCase();
|
||||
if (!key || key === "auto") return undefined;
|
||||
const mapped = REASONING_EFFORT_ALIASES[key] ?? key;
|
||||
return (REASONING_EFFORT_VALUES as readonly string[]).includes(mapped)
|
||||
? (mapped as ReasoningEffort)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function sanitizeVerbosity(value: unknown): Verbosity | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const key = value.trim().toLowerCase();
|
||||
if (!key || key === "auto") return undefined;
|
||||
return (VERBOSITY_VALUES as readonly string[]).includes(key)
|
||||
? (key as Verbosity)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
export type GenerationDefaults = {
|
||||
/** API profile 等来源的默认思考强度;仅当 gen 未指定时生效 */
|
||||
reasoningEffort?: string;
|
||||
};
|
||||
|
||||
export function applyOpenAiGeneration(
|
||||
body: Record<string, unknown>,
|
||||
gen: GenerationParameters,
|
||||
model: string,
|
||||
defaults?: GenerationDefaults,
|
||||
): void {
|
||||
const { omitSampling, useMaxCompletionTokens, supportsVerbosity } =
|
||||
classifyChatModel(model);
|
||||
|
||||
if (!omitSampling) {
|
||||
body.temperature = gen.temperature !== undefined ? gen.temperature : 0.2;
|
||||
const topP = finiteNumber(gen.topP);
|
||||
if (topP !== undefined && topP > 0) body.top_p = topP;
|
||||
const frequencyPenalty = finiteNumber(gen.frequencyPenalty);
|
||||
if (frequencyPenalty !== undefined) {
|
||||
body.frequency_penalty = frequencyPenalty;
|
||||
}
|
||||
const presencePenalty = finiteNumber(gen.presencePenalty);
|
||||
if (presencePenalty !== undefined) {
|
||||
body.presence_penalty = presencePenalty;
|
||||
}
|
||||
}
|
||||
|
||||
const maxOutput = finiteNumber(gen.maxOutputTokens);
|
||||
if (maxOutput !== undefined && maxOutput > 0) {
|
||||
if (useMaxCompletionTokens) body.max_completion_tokens = maxOutput;
|
||||
else body.max_tokens = maxOutput;
|
||||
}
|
||||
|
||||
const seed = finiteNumber(gen.seed);
|
||||
if (seed !== undefined && seed >= 0) body.seed = seed;
|
||||
|
||||
const reasoningEffort =
|
||||
sanitizeReasoningEffort(gen.reasoningEffort) ??
|
||||
sanitizeReasoningEffort(defaults?.reasoningEffort);
|
||||
if (reasoningEffort !== undefined) {
|
||||
body.reasoning_effort = reasoningEffort;
|
||||
}
|
||||
|
||||
const verbosity = sanitizeVerbosity(gen.verbosity);
|
||||
if (verbosity !== undefined && supportsVerbosity) {
|
||||
body.verbosity = verbosity;
|
||||
}
|
||||
}
|
||||
|
||||
function readErrorMessage(raw: unknown): string {
|
||||
if (!raw || typeof raw !== "object") return "";
|
||||
const err = (raw as { error?: unknown }).error;
|
||||
if (typeof err === "string") return err;
|
||||
if (err && typeof err === "object") {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
const message = (raw as { message?: unknown }).message;
|
||||
return typeof message === "string" ? message : "";
|
||||
}
|
||||
|
||||
function readErrorParam(raw: unknown): string | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const err = (raw as { error?: unknown }).error;
|
||||
if (err && typeof err === "object") {
|
||||
const param = (err as { param?: unknown }).param;
|
||||
if (typeof param === "string" && STRIPPABLE_GENERATION_FIELDS.has(param)) {
|
||||
return param;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseRejectedGenerationField(
|
||||
errorText: string,
|
||||
): string | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(errorText);
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
const fromParam = readErrorParam(parsed);
|
||||
if (fromParam) return fromParam;
|
||||
|
||||
const message = `${readErrorMessage(parsed)}\n${errorText}`;
|
||||
if (/temperature and top_p cannot both/i.test(message)) return "top_p";
|
||||
|
||||
const patterns = [
|
||||
/unsupported parameter:?\s*['"`]?([a-z_]+)/i,
|
||||
/unknown field [`'"]([a-z_]+)[`'"]/i,
|
||||
/([a-z_]+):\s*unknown variant/i,
|
||||
/invalid value[^\n]*for[^\n]*['"`]([a-z_]+)['"`]/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = message.match(pattern);
|
||||
const field = match?.[1]?.toLowerCase();
|
||||
if (field && STRIPPABLE_GENERATION_FIELDS.has(field)) return field;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyRejectedGenerationField(
|
||||
body: Record<string, unknown>,
|
||||
field: string,
|
||||
tried: Set<string>,
|
||||
): boolean {
|
||||
if (!STRIPPABLE_GENERATION_FIELDS.has(field)) return false;
|
||||
|
||||
if (
|
||||
field === "max_completion_tokens" &&
|
||||
body.max_completion_tokens != null &&
|
||||
!tried.has("max_tokens")
|
||||
) {
|
||||
body.max_tokens = body.max_completion_tokens;
|
||||
delete body.max_completion_tokens;
|
||||
tried.add("max_completion_tokens");
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
field === "max_tokens" &&
|
||||
body.max_tokens != null &&
|
||||
!tried.has("max_completion_tokens")
|
||||
) {
|
||||
body.max_completion_tokens = body.max_tokens;
|
||||
delete body.max_tokens;
|
||||
tried.add("max_tokens");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(field in body)) return false;
|
||||
delete body[field];
|
||||
tried.add(field);
|
||||
return true;
|
||||
}
|
||||
|
||||
function replayResponse(source: Response, text: string): Response {
|
||||
return new Response(text, {
|
||||
status: source.status,
|
||||
statusText: source.statusText,
|
||||
headers: source.headers,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchWithGenerationCompat(
|
||||
send: (body: Record<string, unknown>) => Promise<Response>,
|
||||
initialBody: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
let body = initialBody;
|
||||
const tried = new Set<string>();
|
||||
let last: Response | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
last = await send(body);
|
||||
if (last.ok || last.status !== 400) return last;
|
||||
const text = await last.text();
|
||||
const field = parseRejectedGenerationField(text);
|
||||
if (!field) return replayResponse(last, text);
|
||||
const next = { ...body };
|
||||
if (!applyRejectedGenerationField(next, field, tried)) {
|
||||
return replayResponse(last, text);
|
||||
}
|
||||
body = next;
|
||||
}
|
||||
|
||||
return last ?? new Response("LLM request failed", { status: 400 });
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 按 profile 能力选路的 structured 完成:json_schema → forced_tool → json_object。
|
||||
*/
|
||||
import {
|
||||
resolveStructuredDeliveryMode,
|
||||
type ProfileCapabilities,
|
||||
type StructuredDeliveryMode,
|
||||
} from "./capabilities.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
CompleteOptions,
|
||||
CompleteResult,
|
||||
LlmProvider,
|
||||
ToolDefinition,
|
||||
} from "./client.js";
|
||||
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
||||
|
||||
export type StructuredCompleteOptions = CompleteOptions & {
|
||||
/** JSON Schema 对象(json_schema / tool parameters 共用) */
|
||||
schema: Record<string, unknown>;
|
||||
/** schema / tool 名称 */
|
||||
name: string;
|
||||
/** 覆盖自动选路 */
|
||||
mode?: StructuredDeliveryMode;
|
||||
capabilities?: ProfileCapabilities | null;
|
||||
/**
|
||||
* schema 含 additionalProperties:true 等、无法走 strict json_schema 时置 true。
|
||||
* 会自动降级:forced_tool → json_object。
|
||||
*/
|
||||
schemaIsLoose?: boolean;
|
||||
/** forced_tool 时是否尝试 strict(默认:capabilities.strictTool===ok 且非 loose) */
|
||||
preferStrictTool?: boolean;
|
||||
stream?: import("./client.js").StreamCallbacks;
|
||||
};
|
||||
|
||||
export type StructuredCompleteResult = CompleteResult & {
|
||||
parsed: unknown;
|
||||
mode: StructuredDeliveryMode;
|
||||
};
|
||||
|
||||
function parseObjectPayload(raw: string): unknown {
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed != null) return parsed;
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
throw new Error("structured 输出无法解析为 JSON");
|
||||
}
|
||||
}
|
||||
|
||||
function buildSubmitTool(
|
||||
name: string,
|
||||
schema: Record<string, unknown>,
|
||||
strict: boolean,
|
||||
): ToolDefinition {
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
description: `Submit the structured result as arguments to ${name}. Always call this tool.`,
|
||||
parameters: schema,
|
||||
...(strict ? { strict: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次 structured 完成:返回解析后的对象。
|
||||
* forced_tool 路径走 completeWithTools;其余走 complete / completeStream。
|
||||
*/
|
||||
export async function completeStructured(
|
||||
llm: LlmProvider,
|
||||
messages: ChatMessage[],
|
||||
options: StructuredCompleteOptions,
|
||||
): Promise<StructuredCompleteResult> {
|
||||
const requested =
|
||||
options.mode ?? resolveStructuredDeliveryMode(options.capabilities);
|
||||
let mode = requested;
|
||||
if (options.schemaIsLoose && mode === "json_schema") {
|
||||
mode =
|
||||
options.capabilities?.forcedTool === "ok"
|
||||
? "forced_tool"
|
||||
: options.capabilities?.jsonObject === "ok"
|
||||
? "json_object"
|
||||
: "text";
|
||||
}
|
||||
const schemaName = options.name || "submit_result";
|
||||
|
||||
if (mode === "forced_tool") {
|
||||
const strict =
|
||||
!options.schemaIsLoose &&
|
||||
(options.preferStrictTool ??
|
||||
options.capabilities?.strictTool === "ok");
|
||||
const tools = [buildSubmitTool(schemaName, options.schema, strict)];
|
||||
const result = await llm.completeWithTools(messages, {
|
||||
...options,
|
||||
tools,
|
||||
toolChoice: "required",
|
||||
responseFormat: undefined,
|
||||
jsonSchema: undefined,
|
||||
});
|
||||
const call =
|
||||
result.toolCalls.find((t) => t.name === schemaName) ??
|
||||
result.toolCalls[0];
|
||||
if (!call) {
|
||||
throw new Error("forced_tool 模式未返回 tool_calls");
|
||||
}
|
||||
const parsed = parseObjectPayload(call.arguments);
|
||||
return {
|
||||
content: call.arguments,
|
||||
reasoning: result.reasoning,
|
||||
usage: result.usage,
|
||||
model: result.model,
|
||||
parsed,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
const completeOpts: CompleteOptions = {
|
||||
...options,
|
||||
responseFormat:
|
||||
mode === "json_schema"
|
||||
? "json_schema"
|
||||
: mode === "json_object"
|
||||
? "json_object"
|
||||
: "text",
|
||||
jsonSchema:
|
||||
mode === "json_schema"
|
||||
? { name: schemaName, strict: true, schema: options.schema }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
let result: CompleteResult;
|
||||
if (options.stream && llm.completeStream) {
|
||||
result = await llm.completeStream(messages, completeOpts, options.stream);
|
||||
} else {
|
||||
result = await llm.complete(messages, completeOpts);
|
||||
}
|
||||
|
||||
const parsed = parseObjectPayload(result.content);
|
||||
return { ...result, parsed, mode };
|
||||
}
|
||||
@@ -46,6 +46,7 @@ const WAIT_LABEL: Record<WaitingReason["kind"], string> = {
|
||||
worker_questions: "执行单元提问",
|
||||
revision: "修订",
|
||||
next_intent: "下一步意向",
|
||||
pick_creation_step: "点选节点",
|
||||
};
|
||||
|
||||
const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
|
||||
@@ -56,9 +57,14 @@ const EVENT_LABEL: Record<RuntimeEvent["type"], string> = {
|
||||
main_agent_decision_created: "总管决策",
|
||||
user_approved_next_step: "确认下一步",
|
||||
user_rejected_next_step: "拒绝下一步",
|
||||
user_requested_flow_replan: "再编排",
|
||||
user_picked_creation_step: "点选节点",
|
||||
creation_step_pick_awaited: "等待点选节点",
|
||||
user_left_creation_step: "返回节点选择",
|
||||
worker_started: "执行单元开始",
|
||||
worker_completed: "执行单元完成",
|
||||
worker_needs_input: "执行单元提问",
|
||||
worker_revision_produced_nothing: "修订无新产物",
|
||||
user_resolved_sidecar_questions: "回答挂载追问",
|
||||
user_accepted_artifact: "验收通过",
|
||||
user_rejected_artifact: "打回产物",
|
||||
@@ -78,6 +84,7 @@ const EFFECT_LABEL: Record<string, string> = {
|
||||
emit_message: "发消息",
|
||||
propose_next_creation_step: "提案下一步",
|
||||
seal_creation_opening: "收口开场白",
|
||||
unseal_creation_opening: "解开开场收口",
|
||||
run_play_turn: "开游玩回合",
|
||||
continue_play_turn: "续游玩管线",
|
||||
};
|
||||
|
||||
@@ -24,25 +24,26 @@ function buildMainAgentSystemPrompt(
|
||||
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||
: "- (当前 skill 未加载 worker 列表)";
|
||||
|
||||
return `你是创作节点路由器。只读取「设计.创作流程」DAG,并选择下一个要执行的创作节点。
|
||||
|
||||
- DAG 不存在或需要追加节点:调用 design-flow。
|
||||
- DAG 中已有下一个可执行节点:调用 design-step。
|
||||
- 只调度 worker,不创作正文,不规划 DAG 之外的流程。
|
||||
return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
|
||||
|
||||
当前 skill 可用 worker(workerId 必须与下列 id 完全一致):
|
||||
${workerLines}
|
||||
|
||||
输出必须是 JSON 对象,字段:
|
||||
{
|
||||
"action": "ask_user" | "run_worker" | "create_temp_worker" | "review_blackboard" | "finish",
|
||||
"reason": "string",
|
||||
"assessment": "string | null",
|
||||
"questions": [{ "id": "q1", "prompt": "…", "options": [{ "label": "建议示范…" }] }] | null,
|
||||
"workerId": "string | null",
|
||||
"requiresApproval": boolean,
|
||||
"workerContext": { "roleId": "string" } | null
|
||||
}`;
|
||||
结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
|
||||
}
|
||||
|
||||
/** 总管把推理写成正文、未给出可用工具/JSON 时,运行时改走点选或 design-flow。 */
|
||||
export function isUnusableMainAgentOutput(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return (
|
||||
/invalid JSON/i.test(msg) ||
|
||||
/no tool calls and no content/i.test(msg) ||
|
||||
/Invalid action/i.test(msg) ||
|
||||
/must be an object/i.test(msg) ||
|
||||
/requires reason/i.test(msg) ||
|
||||
/multiple terminal tools/i.test(msg) ||
|
||||
/Not a terminal tool/i.test(msg)
|
||||
);
|
||||
}
|
||||
|
||||
export class MainAgent {
|
||||
|
||||
@@ -42,17 +42,12 @@ function buildToolLoopSystemPrompt(
|
||||
? workers.map((w) => `- ${w.id}:${w.description}`).join("\n")
|
||||
: "- (当前 skill 未加载 worker 列表)";
|
||||
|
||||
return `你是创作节点路由器。只根据「设计.创作流程」DAG 选择下一个创作节点。
|
||||
|
||||
1. 用 read_blackboard 读取「设计.创作流程」及其进度 tag。
|
||||
2. 没有 DAG 或 DAG 需要追加节点时,run_worker(design-flow)。
|
||||
3. DAG 已给出下一个可执行节点时,run_worker(design-step)。
|
||||
4. 不创作正文,不改写 DAG,不处理运行状态或游玩流程。
|
||||
return `你是创作节点路由器。当前没有「设计.创作流程」。本轮调用 run_worker:workerId 为 design-flow,排出近期起点。
|
||||
|
||||
当前 skill 可用 worker:
|
||||
${workerLines}
|
||||
|
||||
选定节点后立即调用 run_worker。`;
|
||||
结束本轮必须调用 run_worker(design-flow)。reason 用一句话说明根据用户需求开始编排;requiresApproval 为 false。`;
|
||||
}
|
||||
|
||||
function executeLoopTool(
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/** 从模型正文里抽出 JSON:先按原文 parse,再宽松修,避免弯引号/围栏/夹叙把合法稿撕开。 */
|
||||
|
||||
export function stripCodeFences(text: string): string {
|
||||
let t = String(text || "").trim();
|
||||
t = t.replace(/^```(?:json|yaml|yml)?\s*\r?\n?/i, "");
|
||||
t = t.replace(/\r?\n?```\s*$/i, "");
|
||||
const fenced = t.match(/```(?:json|yaml|yml)?\s*\r?\n([\s\S]*?)\r?\n```/i);
|
||||
if (fenced) t = fenced[1]!.trim();
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
export function escapeRawControlsInJsonStrings(s: string): string {
|
||||
let out = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i]!;
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
out += c;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c === "\\") {
|
||||
out += c;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
out += c;
|
||||
inString = false;
|
||||
continue;
|
||||
}
|
||||
if (c === "\n") {
|
||||
out += "\\n";
|
||||
continue;
|
||||
}
|
||||
if (c === "\r") {
|
||||
out += "\\r";
|
||||
continue;
|
||||
}
|
||||
if (c === "\t") {
|
||||
out += "\\t";
|
||||
continue;
|
||||
}
|
||||
out += c;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
inString = true;
|
||||
out += c;
|
||||
continue;
|
||||
}
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function convertStructuralSmartQuotes(s: string): string {
|
||||
const text = String(s || "");
|
||||
let out = "";
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
const isOpen = c === "\u201C" || c === "\u201E" || c === "\u201F";
|
||||
const isClose = c === "\u201D" || c === "\u2033" || c === "\u2036";
|
||||
if (!isOpen && !isClose) {
|
||||
out += c;
|
||||
continue;
|
||||
}
|
||||
let prev = i - 1;
|
||||
while (prev >= 0 && /\s/.test(text[prev]!)) prev -= 1;
|
||||
let next = i + 1;
|
||||
while (next < text.length && /\s/.test(text[next]!)) next += 1;
|
||||
const prevCh = prev >= 0 ? text[prev]! : "";
|
||||
const nextCh = next < text.length ? text[next]! : "";
|
||||
const afterOpen = !prevCh || "{[:,[".includes(prevCh);
|
||||
const beforeClose = !nextCh || ":,}]".includes(nextCh);
|
||||
if (isOpen && afterOpen) out += '"';
|
||||
else if (isClose && beforeClose) out += '"';
|
||||
else out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function softenJsonText(s: string): string {
|
||||
let t = String(s || "")
|
||||
.replace(/^\uFEFF/, "")
|
||||
.replace(/\u2026/g, "")
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
|
||||
t = t.replace(/^\s*\/\/.*$/gm, "");
|
||||
t = t.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
t = escapeRawControlsInJsonStrings(t);
|
||||
t = t.replace(/,\s*([\]}])/g, "$1");
|
||||
return t;
|
||||
}
|
||||
|
||||
export function repairTruncatedJsonObject(slice: string): string {
|
||||
let s = String(slice || "").trim();
|
||||
if (!s.startsWith("{")) return s;
|
||||
s = s.replace(/,\s*"[^"]*$/u, "");
|
||||
s = s.replace(/,\s*$/u, "");
|
||||
const opens = (s.match(/\{/g) || []).length;
|
||||
const closes = (s.match(/\}/g) || []).length;
|
||||
const openBrackets = (s.match(/\[/g) || []).length;
|
||||
const closeBrackets = (s.match(/\]/g) || []).length;
|
||||
const quoteCount = (s.match(/(?<!\\)"/g) || []).length;
|
||||
if (quoteCount % 2 === 1) s += '"';
|
||||
if (openBrackets > closeBrackets) s += "]".repeat(openBrackets - closeBrackets);
|
||||
if (opens > closes) s += "}".repeat(opens - closes);
|
||||
return s;
|
||||
}
|
||||
|
||||
function tryParse(s: string): unknown {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PRODUCT_HINT_KEYS = [
|
||||
"outputs",
|
||||
"schema",
|
||||
"正文",
|
||||
"body",
|
||||
"技能",
|
||||
"workers",
|
||||
"steps",
|
||||
"brief",
|
||||
"追问",
|
||||
] as const;
|
||||
|
||||
export function looksLikeProductJson(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
return PRODUCT_HINT_KEYS.some((k) => k in row);
|
||||
}
|
||||
|
||||
function parseSlice(slice: string): unknown {
|
||||
return (
|
||||
tryParse(slice) ??
|
||||
tryParse(softenJsonText(slice)) ??
|
||||
tryParse(softenJsonText(convertStructuralSmartQuotes(slice))) ??
|
||||
tryParse(repairTruncatedJsonObject(slice)) ??
|
||||
tryParse(softenJsonText(repairTruncatedJsonObject(slice)))
|
||||
);
|
||||
}
|
||||
|
||||
/** 思维链末尾常整段 JSON;从后往前找像产物的对象,避免吃进前面的举例 `{ }` */
|
||||
export function extractLastProductJson(text: string): unknown {
|
||||
const s = String(text || "");
|
||||
const starts: number[] = [];
|
||||
for (let i = s.length - 1; i >= 0 && starts.length < 8; i--) {
|
||||
if (s[i] === "{") starts.push(i);
|
||||
}
|
||||
let fallback: unknown = null;
|
||||
for (const start of starts) {
|
||||
const slice = s.slice(start);
|
||||
const end = slice.lastIndexOf("}");
|
||||
if (end < 1) continue;
|
||||
const parsed = parseSlice(slice.slice(0, end + 1));
|
||||
if (parsed == null) continue;
|
||||
if (looksLikeProductJson(parsed)) return parsed;
|
||||
if (fallback == null) fallback = parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 模型常把弯引号写进字符串;必须先按原文 parse,再做宽松修复 */
|
||||
export function tryParseJsonDoc(text: string): unknown {
|
||||
const stripped = stripCodeFences(text);
|
||||
const attempts = [
|
||||
stripped,
|
||||
softenJsonText(stripped),
|
||||
softenJsonText(convertStructuralSmartQuotes(stripped)),
|
||||
];
|
||||
for (const candidate of attempts) {
|
||||
const parsed = tryParse(candidate);
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
|
||||
const cleaned = softenJsonText(convertStructuralSmartQuotes(stripped));
|
||||
if (
|
||||
(cleaned.startsWith('"') && cleaned.endsWith('"')) ||
|
||||
(cleaned.startsWith("'") && cleaned.endsWith("'"))
|
||||
) {
|
||||
const inner = tryParse(cleaned);
|
||||
if (typeof inner === "string") {
|
||||
const parsed = tryParse(softenJsonText(inner)) ?? tryParse(inner);
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const fromTail = extractLastProductJson(stripped) ?? extractLastProductJson(cleaned);
|
||||
if (fromTail != null) return fromTail;
|
||||
|
||||
const arrStart = cleaned.indexOf("[");
|
||||
const arrEnd = cleaned.lastIndexOf("]");
|
||||
if (arrStart >= 0 && arrEnd > arrStart) {
|
||||
const parsed = tryParse(cleaned.slice(arrStart, arrEnd + 1));
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文可能是闲聊/英文收束,JSON 在思维链里。
|
||||
* 能 parse 的一边优先;两边都能 parse 时用更像产物的那边。
|
||||
*/
|
||||
export function selectJsonPayload(content: string, reasoning?: string): string {
|
||||
const body = String(content ?? "").trim();
|
||||
const think = String(reasoning ?? "").trim();
|
||||
const fromBody = body ? tryParseJsonDoc(body) : null;
|
||||
const fromThink = think ? tryParseJsonDoc(think) : null;
|
||||
const bodyOk = fromBody != null && typeof fromBody === "object";
|
||||
const thinkOk = fromThink != null && typeof fromThink === "object";
|
||||
if (bodyOk && thinkOk) {
|
||||
if (looksLikeProductJson(fromThink) && !looksLikeProductJson(fromBody)) {
|
||||
return think;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
if (bodyOk) return body;
|
||||
if (thinkOk) return think;
|
||||
return body || think;
|
||||
}
|
||||
@@ -160,6 +160,9 @@ export class RuntimeOrchestrator {
|
||||
case "invoke_main_agent":
|
||||
case "propose_next_creation_step":
|
||||
case "seal_creation_opening":
|
||||
case "unseal_creation_opening":
|
||||
case "run_play_turn":
|
||||
case "continue_play_turn":
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+167
-10
@@ -35,6 +35,11 @@ import {
|
||||
SLOT_CREATION_SEALED_BY_OPENING,
|
||||
isOpeningSealArtifact,
|
||||
} from "../skills/opening-seal.js";
|
||||
import {
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
DESIGN_FLOW_WORKER_ID,
|
||||
DESIGN_STEP_WORKER_ID,
|
||||
} from "../skills/creation-flow.js";
|
||||
import { inferLifecycleStage } from "../skills/worker-declaration.js";
|
||||
import type { QuestionItem } from "../types/questions.js";
|
||||
|
||||
@@ -146,6 +151,54 @@ function running(session: RuntimeSession): RuntimeSession {
|
||||
return touch({ ...session, phase: "running", waitingReason: undefined });
|
||||
}
|
||||
|
||||
/** 回到流程编排:保留已验收步骤,让 design-flow 追加/改排节点 */
|
||||
function applyFlowReplan(
|
||||
session: RuntimeSession,
|
||||
event: RuntimeEvent,
|
||||
reason?: string,
|
||||
): ApplyEventResult {
|
||||
const slots = { ...session.slots };
|
||||
const note = reason?.trim() ?? "";
|
||||
if (note) {
|
||||
slots["用户.下一步意向"] = note;
|
||||
slots["用户.修订说明"] = note;
|
||||
}
|
||||
delete slots[CREATION_PROPOSED_STEP_TAG];
|
||||
delete slots[SLOT_CREATION_SEALED_BY_OPENING];
|
||||
|
||||
let artifacts = session.artifacts;
|
||||
if (session.pendingArtifactId) {
|
||||
artifacts = session.artifacts.map((a) =>
|
||||
a.id === session.pendingArtifactId
|
||||
? { ...a, status: "rejected" as const, updatedAt: nowIso() }
|
||||
: a,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{
|
||||
...session,
|
||||
slots,
|
||||
artifacts,
|
||||
pendingDecision: undefined,
|
||||
pendingArtifactId: undefined,
|
||||
currentWorkerId: undefined,
|
||||
resumeContext: undefined,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [
|
||||
{ type: "unseal_creation_opening" },
|
||||
{ type: "run_worker", workerId: DESIGN_FLOW_WORKER_ID },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 游玩连跑下一执行单元;创作则交总管 */
|
||||
function afterPlayOrMainAgent(
|
||||
session: RuntimeSession,
|
||||
@@ -286,6 +339,7 @@ export function getAllowedEvents(
|
||||
case "running":
|
||||
return [
|
||||
"main_agent_decision_created",
|
||||
"creation_step_pick_awaited",
|
||||
"worker_started",
|
||||
"worker_completed",
|
||||
"worker_needs_input",
|
||||
@@ -303,6 +357,9 @@ export function getAllowedEvents(
|
||||
"user_confirmed_intake",
|
||||
"user_approved_next_step",
|
||||
"user_rejected_next_step",
|
||||
"user_requested_flow_replan",
|
||||
"user_picked_creation_step",
|
||||
"user_left_creation_step",
|
||||
"user_accepted_artifact",
|
||||
"user_rejected_artifact",
|
||||
"user_requested_revision",
|
||||
@@ -341,13 +398,34 @@ export function canApplyEvent(
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "revision" ||
|
||||
reason?.kind === "next_intent"
|
||||
reason?.kind === "next_intent" ||
|
||||
reason?.kind === "pick_creation_step"
|
||||
);
|
||||
case "user_confirmed_intake":
|
||||
return reason?.kind === "intake";
|
||||
case "user_approved_next_step":
|
||||
case "user_rejected_next_step":
|
||||
return reason?.kind === "approve_step";
|
||||
case "user_requested_flow_replan":
|
||||
return (
|
||||
reason?.kind === "approve_step" ||
|
||||
reason?.kind === "review_artifact" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "revision" ||
|
||||
reason?.kind === "pick_creation_step"
|
||||
);
|
||||
case "user_picked_creation_step":
|
||||
return reason?.kind === "pick_creation_step";
|
||||
case "user_left_creation_step":
|
||||
return (
|
||||
reason?.kind === "approve_step" ||
|
||||
reason?.kind === "worker_questions" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "revision"
|
||||
);
|
||||
case "creation_step_pick_awaited":
|
||||
return session.phase === "running";
|
||||
case "user_accepted_artifact":
|
||||
case "user_rejected_artifact":
|
||||
return reason?.kind === "review_artifact";
|
||||
@@ -361,8 +439,12 @@ export function canApplyEvent(
|
||||
reason?.kind === "review_artifact" || reason?.kind === "approve_step"
|
||||
);
|
||||
case "main_agent_decision_created":
|
||||
// running 时可决策;waiting_user(input) 时允许总管在启动阶段插话
|
||||
return session.phase === "running" || reason?.kind === "input";
|
||||
// running 时可决策;waiting_user(input/pick) 时允许总管在启动阶段插话或点选后补参确认
|
||||
return (
|
||||
session.phase === "running" ||
|
||||
reason?.kind === "input" ||
|
||||
reason?.kind === "pick_creation_step"
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
@@ -420,13 +502,20 @@ function handleWorkerCompleted(
|
||||
|
||||
if (mode === "no_confirmation") {
|
||||
next = updateArtifact(next, artifact.id, { status: "accepted" });
|
||||
return afterPlayOrMainAgent(session, {
|
||||
const cleared = {
|
||||
...next,
|
||||
pendingArtifactId: undefined,
|
||||
currentWorkerId: undefined,
|
||||
pendingDecision: undefined,
|
||||
resumeContext: undefined,
|
||||
});
|
||||
};
|
||||
if (artifact.workerId === DESIGN_FLOW_WORKER_ID) {
|
||||
return {
|
||||
session: touch(running(cleared)),
|
||||
effects: [{ type: "propose_next_creation_step" }],
|
||||
};
|
||||
}
|
||||
return afterPlayOrMainAgent(session, cleared);
|
||||
}
|
||||
|
||||
// programmatic_review
|
||||
@@ -618,6 +707,10 @@ export function applyEvent(
|
||||
};
|
||||
}
|
||||
|
||||
if (session.waitingReason?.kind === "pick_creation_step") {
|
||||
return applyFlowReplan(session, event, text);
|
||||
}
|
||||
|
||||
if (session.waitingReason?.kind === "revision") {
|
||||
const instruction =
|
||||
text.trim() || session.waitingReason.instruction?.trim() || "";
|
||||
@@ -836,16 +929,81 @@ export function applyEvent(
|
||||
}
|
||||
|
||||
case "user_rejected_next_step": {
|
||||
return applyFlowReplan(session, event, event.payload.reason);
|
||||
}
|
||||
|
||||
case "user_requested_flow_replan": {
|
||||
return applyFlowReplan(session, event, event.payload.reason);
|
||||
}
|
||||
|
||||
case "creation_step_pick_awaited": {
|
||||
return {
|
||||
session: waiting(
|
||||
touch(
|
||||
appendHistory(
|
||||
{
|
||||
...session,
|
||||
currentWorkerId: undefined,
|
||||
pendingDecision: undefined,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
{ kind: "pick_creation_step" },
|
||||
),
|
||||
effects: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_left_creation_step": {
|
||||
const slots = { ...session.slots };
|
||||
delete slots[CREATION_PROPOSED_STEP_TAG];
|
||||
return {
|
||||
session: waiting(
|
||||
touch(
|
||||
appendHistory(
|
||||
{
|
||||
...session,
|
||||
slots,
|
||||
pendingDecision: undefined,
|
||||
pendingArtifactId: undefined,
|
||||
currentWorkerId: undefined,
|
||||
currentStepId: undefined,
|
||||
resumeContext: undefined,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
{ kind: "pick_creation_step" },
|
||||
),
|
||||
effects: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "user_picked_creation_step": {
|
||||
const stepId = event.payload.stepId.trim();
|
||||
if (!stepId) {
|
||||
return fail(session, "pick_creation_step requires stepId");
|
||||
}
|
||||
return {
|
||||
session: touch(
|
||||
running(
|
||||
appendHistory(
|
||||
{ ...session, pendingDecision: undefined },
|
||||
{
|
||||
...session,
|
||||
currentWorkerId: DESIGN_STEP_WORKER_ID,
|
||||
pendingDecision: undefined,
|
||||
},
|
||||
event,
|
||||
),
|
||||
),
|
||||
),
|
||||
effects: [{ type: "invoke_main_agent" }],
|
||||
effects: [
|
||||
{
|
||||
type: "run_worker",
|
||||
workerId: DESIGN_STEP_WORKER_ID,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1033,9 +1191,8 @@ export function applyEvent(
|
||||
if (inferLifecycleStage(session) === "play") {
|
||||
return afterPlayOrMainAgent(session, cleared);
|
||||
}
|
||||
// 创作步验收后直接提案下一步(有待执行步则确认开干;否则交总管扩步)。
|
||||
// 不再先停在「下一步想写什么」——与「同意并开始」叠成双重确认。
|
||||
// 细化终稿也走提案,而不是 invoke_main_agent(否则容易把刚收下的终稿再跑一遍)。
|
||||
// 创作步验收后停在分层图上等点选(可反复能力可当场新开一条)。
|
||||
// 仅当没有任何可点节点时,propose 才会把控制权交回总管扩步。
|
||||
const proposeNext =
|
||||
artifact.workerId === "design-step" ||
|
||||
artifact.workerId === "design-flow";
|
||||
|
||||
+653
-154
@@ -17,7 +17,11 @@ import {
|
||||
} from "./phase-machine.js";
|
||||
import { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider } from "../llm/client.js";
|
||||
import { DEFAULT_WORKERS, MainAgent } from "../main-agent/main-agent.js";
|
||||
import {
|
||||
DEFAULT_WORKERS,
|
||||
isUnusableMainAgentOutput,
|
||||
MainAgent,
|
||||
} from "../main-agent/main-agent.js";
|
||||
import { listSkills, listWorkerSkills, loadSkill, resolveSkillId } from "../skills/loader.js";
|
||||
import {
|
||||
buildInstanceWorkerDeclaration,
|
||||
@@ -58,14 +62,32 @@ import {
|
||||
CREATION_FLOW_TAG,
|
||||
CREATION_CURRENT_STEP_TAG,
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
CREATION_INHERIT_EXISTING_TAG,
|
||||
CREATION_MODULE_OPENING_TAG,
|
||||
CREATION_MODULE_OPENING_STATE_TAG,
|
||||
SLOT_CREATION_MODULE_OPENING_STATE,
|
||||
loadModuleCatalog,
|
||||
isReviseStep,
|
||||
isStepAccepted,
|
||||
isInheritExistingFlag,
|
||||
mergeCreationFlowPreservingAccepted,
|
||||
nextPendingStep,
|
||||
listReadySteps,
|
||||
listSelectablePrototypes,
|
||||
listRepeatableSpawns,
|
||||
isStepReady,
|
||||
hasSelectableCreationWork,
|
||||
spawnRepeatableCreationStep,
|
||||
spawnInstanceFromPrototype,
|
||||
removeUnstartedInstance,
|
||||
isPrototypeStep,
|
||||
isPrototypeSelectable,
|
||||
isInstanceStep,
|
||||
findStepByRef,
|
||||
findModuleByName,
|
||||
parseCreationFlow,
|
||||
DESIGN_FLOW_WORKER_ID,
|
||||
DESIGN_STEP_WORKER_ID,
|
||||
parseModuleOpeningState,
|
||||
patchCreationFlowStepParams,
|
||||
pickRecordedStepId,
|
||||
@@ -76,7 +98,10 @@ import {
|
||||
stepUnitId,
|
||||
stringifyCreationFlow,
|
||||
stringifyModuleOpeningState,
|
||||
finalizeCreationFlow,
|
||||
type CreationFlow,
|
||||
type CreationFlowStepParams,
|
||||
type ModuleCatalog,
|
||||
type ModuleCatalogEntry,
|
||||
type ModuleParamSpec,
|
||||
} from "../skills/creation-flow.js";
|
||||
@@ -98,6 +123,7 @@ import {
|
||||
OPENING_OUTPUT_TAG,
|
||||
OPENING_SETUP_ARTIFACT_TAG,
|
||||
parseOpeningSealPayload,
|
||||
reopenCreationFlowRaw,
|
||||
SLOT_CREATION_SEALED_BY_OPENING,
|
||||
SLOT_OPENING_SELECTED_INDEX,
|
||||
} from "../skills/opening-seal.js";
|
||||
@@ -326,6 +352,56 @@ export class PhaseRuntime {
|
||||
throw new Error("当前没有可重试的执行");
|
||||
}
|
||||
|
||||
/** LLM/解析失败后离开 running,避免界面一直转圈。 */
|
||||
async failRun(reason: string): Promise<RuntimeSession> {
|
||||
if (this.session.phase !== "running") return this.session;
|
||||
const result = this.commitEvent({
|
||||
type: "runtime_failed",
|
||||
payload: { reason },
|
||||
});
|
||||
await this.processEffects(result.effects);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程里留下的「执行中、没 worker」:配方已预置 DAG 时改回点选等待。
|
||||
* 读档 / 轮询时调用,避免界面一直转圈。
|
||||
*/
|
||||
recoverOrphanedRun(): boolean {
|
||||
if (this.session.phase !== "running" || this.session.waitingReason) {
|
||||
return false;
|
||||
}
|
||||
if (this.session.currentWorkerId) return false;
|
||||
if (!this.hasCreationFlow()) return false;
|
||||
debugLog("step", "恢复中断的调度,等待点选节点");
|
||||
this.clearProposedStep();
|
||||
this.session = {
|
||||
...this.session,
|
||||
phase: "waiting_user",
|
||||
waitingReason: { kind: "pick_creation_step" },
|
||||
pendingDecision: undefined,
|
||||
currentWorkerId: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配方已预置 DAG:跳过「先描述需求」,直接进入点选图。
|
||||
* 图上节点来自当前剧本 seed,换配方即换开局。
|
||||
*/
|
||||
async enterSeededCreationPick(): Promise<void> {
|
||||
if (!this.hasCreationFlow()) return;
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
startupCompleted: true,
|
||||
},
|
||||
};
|
||||
await this.awaitCreationStepPick();
|
||||
}
|
||||
|
||||
/** 重 roll 指定 worker(刷新 Skill 回复) */
|
||||
async rerunWorker(workerId: string): Promise<RuntimeSession> {
|
||||
const effect: Extract<PhaseEffect, { type: "run_worker" }> = {
|
||||
@@ -595,7 +671,7 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 用户拒绝 pendingDecision,回到总管 */
|
||||
/** 用户拒绝 pendingDecision:回到流程编排,可追加节点 */
|
||||
async rejectStep(reason?: string): Promise<RuntimeSession> {
|
||||
const id = this.session.pendingDecision?.id;
|
||||
if (!id) throw new Error("当前没有待拒绝的决策");
|
||||
@@ -606,6 +682,180 @@ export class PhaseRuntime {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开当前技能步,回到分层图。
|
||||
* 可增殖节点刚增殖出、尚未验收的空实例从图上撤掉。
|
||||
*/
|
||||
async leaveCreationStep(): Promise<RuntimeSession> {
|
||||
const waiting = this.session.waitingReason?.kind;
|
||||
const allowed =
|
||||
waiting === "worker_questions" ||
|
||||
waiting === "input" ||
|
||||
waiting === "revision" ||
|
||||
waiting === "approve_step";
|
||||
if (!allowed) {
|
||||
throw new Error("当前不能返回节点选择");
|
||||
}
|
||||
if (inferLifecycleStage(this.session) === "play") {
|
||||
throw new Error("游玩中不能返回创作节点");
|
||||
}
|
||||
const stepId = this.readPinnedStepId();
|
||||
const removed = this.unspawnCurrentInstanceIfEmpty();
|
||||
this.clearProposedStep();
|
||||
this.clearCurrentStepPointers();
|
||||
const result = await this.dispatch({
|
||||
type: "user_left_creation_step",
|
||||
payload: {},
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
const stepName = removed?.name?.trim();
|
||||
this.onMessage(
|
||||
removed
|
||||
? `已取消刚增殖的「${stepName || stepId}」,回到节点选择。`
|
||||
: "已回到节点选择。",
|
||||
);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private unspawnCurrentInstanceIfEmpty(): { id: string; name: string } | null {
|
||||
const stepId = this.readPinnedStepId();
|
||||
if (!stepId) return null;
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) return null;
|
||||
const step = findStepByRef(flow, stepId);
|
||||
if (!step || !isInstanceStep(step)) return null;
|
||||
const accepted = this.readAcceptedStepIds();
|
||||
if (isStepAccepted(step, accepted)) return null;
|
||||
if (
|
||||
this.session.artifacts.some(
|
||||
(a) => a.stepId === step.id && a.status !== "rejected",
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const next = removeUnstartedInstance(flow, step.id, accepted);
|
||||
if (next.steps.length === flow.steps.length) return null;
|
||||
this.writeCreationFlow(next);
|
||||
debugLog("step", `取消未完成实例 ${step.id}`);
|
||||
return { id: step.id, name: step.name };
|
||||
}
|
||||
|
||||
private clearCurrentStepPointers(): void {
|
||||
this.blackboard.write({
|
||||
tag: CREATION_CURRENT_STEP_TAG,
|
||||
content: "",
|
||||
source: "runtime",
|
||||
});
|
||||
this.blackboard.write({
|
||||
tag: CREATION_INHERIT_EXISTING_TAG,
|
||||
content: "",
|
||||
source: "runtime",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_CURRENT_STEP_TAG]: undefined,
|
||||
[CREATION_INHERIT_EXISTING_TAG]: undefined,
|
||||
[SLOT_CREATION_CURRENT_UNIT]: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 开场白等步骤上发现漏了前序节点:再进流程编排,不整局重开 */
|
||||
async replanCreationFlow(reason?: string): Promise<RuntimeSession> {
|
||||
const waiting = this.session.waitingReason?.kind;
|
||||
if (waiting === "approve_step") {
|
||||
return this.rejectStep(reason);
|
||||
}
|
||||
await this.dispatch({
|
||||
type: "user_requested_flow_replan",
|
||||
payload: { reason },
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 图上点选:普通节点开干;原型槽位先增殖再进去;已完成节点可 reenter 重进 */
|
||||
async pickCreationStep(
|
||||
stepId: string,
|
||||
stepParams?: CreationFlowStepParams,
|
||||
opts?: { reenter?: boolean },
|
||||
): Promise<RuntimeSession> {
|
||||
const id = stepId.trim();
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
const catalog = await this.loadActiveModuleCatalog();
|
||||
const flow = this.readNormalizedFlow(catalog);
|
||||
const step = flow ? findStepByRef(flow, id) : null;
|
||||
if (flow && step && isPrototypeStep(step)) {
|
||||
if (opts?.reenter) {
|
||||
throw new Error("原型槽位不能回头修改,请点已完成的实例");
|
||||
}
|
||||
const spawned = spawnInstanceFromPrototype({
|
||||
flow,
|
||||
catalog,
|
||||
prototypeId: step.id,
|
||||
acceptedStepIds: this.readAcceptedStepIds(),
|
||||
});
|
||||
if ("error" in spawned) throw new Error(spawned.error);
|
||||
this.writeCreationFlow(spawned.flow);
|
||||
debugLog("step", `点原型 ${step.id} → ${spawned.step.id}`);
|
||||
await this.startCreationStep(spawned.step.id, { stepParams });
|
||||
return this.session;
|
||||
}
|
||||
await this.startCreationStep(id, {
|
||||
stepParams,
|
||||
reenter: opts?.reenter === true,
|
||||
});
|
||||
return this.session;
|
||||
}
|
||||
|
||||
/** 可反复能力:新编一条再进去 */
|
||||
async spawnCreationStep(moduleName: string): Promise<RuntimeSession> {
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
const catalog = await this.loadActiveModuleCatalog();
|
||||
const flow = this.readNormalizedFlow(catalog);
|
||||
if (!flow) throw new Error("还没有工作流计划,无法追加节点");
|
||||
const spawned = spawnRepeatableCreationStep({
|
||||
flow,
|
||||
catalog,
|
||||
moduleName,
|
||||
acceptedStepIds: this.readAcceptedStepIds(),
|
||||
});
|
||||
if ("error" in spawned) throw new Error(spawned.error);
|
||||
this.writeCreationFlow(spawned.flow);
|
||||
debugLog("step", `新开 ${spawned.step.id}(${spawned.step.name})`);
|
||||
await this.startCreationStep(spawned.step.id);
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private writeCreationFlow(flow: CreationFlow): void {
|
||||
const content = stringifyCreationFlow(flow);
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content,
|
||||
source: "user",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
|
||||
};
|
||||
}
|
||||
|
||||
private readNormalizedFlow(catalog: ModuleCatalog | null): CreationFlow | null {
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) return null;
|
||||
const next = finalizeCreationFlow(flow, catalog);
|
||||
const changed = JSON.stringify(next.steps) !== JSON.stringify(flow.steps);
|
||||
if (changed) this.writeCreationFlow(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 将实例 Worker 声明写入 slots,便于 session 持久化与调试 */
|
||||
private persistInstanceWorkerDeclaration(): void {
|
||||
const active = this.getActiveSkill();
|
||||
@@ -857,6 +1107,13 @@ export class PhaseRuntime {
|
||||
}
|
||||
break;
|
||||
case "run_worker":
|
||||
if (
|
||||
effect.workerId === DESIGN_STEP_WORKER_ID &&
|
||||
!this.hasPinnedReadyStep()
|
||||
) {
|
||||
await this.awaitCreationStepPick();
|
||||
break;
|
||||
}
|
||||
if (this.autoStubWorker) {
|
||||
await this.runStubWorker(effect);
|
||||
} else if (this.llm) {
|
||||
@@ -894,6 +1151,9 @@ export class PhaseRuntime {
|
||||
case "seal_creation_opening":
|
||||
this.sealCreationOpening();
|
||||
break;
|
||||
case "unseal_creation_opening":
|
||||
this.unsealCreationOpening();
|
||||
break;
|
||||
case "run_play_turn":
|
||||
await this.startPlayTurn();
|
||||
break;
|
||||
@@ -905,9 +1165,243 @@ export class PhaseRuntime {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户填完「下一步想写什么」后:有待执行步则提案并进入确认;否则交总管扩步/收口。
|
||||
* 用户填完「下一步想写什么」后:有可点节点则停在分层图;程序步自动开干;否则交总管扩步。
|
||||
*/
|
||||
private async proposeNextCreationStep(): Promise<void> {
|
||||
await this.awaitCreationStepPick();
|
||||
}
|
||||
|
||||
private readAcceptedStepIds(): string[] {
|
||||
return parseAcceptedUnits(
|
||||
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
||||
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadActiveModuleCatalog(): Promise<ModuleCatalog | null> {
|
||||
const active = this.getActiveSkill();
|
||||
if (!active?.name) return null;
|
||||
try {
|
||||
const skill = await loadSkill(active.name);
|
||||
if (!skill.skillPackRoot) return null;
|
||||
return await loadModuleCatalog(skill.skillPackRoot);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private readPinnedStepId(): string {
|
||||
const fromCurrent = this.blackboard
|
||||
.getContentByTag(CREATION_CURRENT_STEP_TAG)
|
||||
?.trim();
|
||||
if (fromCurrent) return fromCurrent;
|
||||
const raw =
|
||||
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
|
||||
? String(this.session.slots[CREATION_PROPOSED_STEP_TAG])
|
||||
: null) || this.blackboard.getContentByTag(CREATION_PROPOSED_STEP_TAG);
|
||||
if (!raw?.trim()) return "";
|
||||
try {
|
||||
const doc = JSON.parse(raw) as { stepId?: unknown };
|
||||
return typeof doc.stepId === "string" ? doc.stepId.trim() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private hasPinnedReadyStep(): boolean {
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) return false;
|
||||
const step = findStepByRef(flow, this.readPinnedStepId());
|
||||
if (!step) return false;
|
||||
return isStepReady(flow, step, this.readAcceptedStepIds());
|
||||
}
|
||||
|
||||
private pinCreationStepSnapshot(params: {
|
||||
stepId: string;
|
||||
name: string;
|
||||
declaration: string;
|
||||
params: Record<string, unknown>;
|
||||
paramsMissing: string[];
|
||||
paramSpecs: ModuleParamSpec[];
|
||||
kind?: ModuleCatalogEntry["kind"];
|
||||
closer?: boolean;
|
||||
revise: boolean;
|
||||
revises?: string;
|
||||
}): void {
|
||||
const intent =
|
||||
typeof this.session.slots["用户.下一步意向"] === "string"
|
||||
? String(this.session.slots["用户.下一步意向"]).trim()
|
||||
: "";
|
||||
const snapshot = {
|
||||
stepId: params.stepId,
|
||||
name: params.name,
|
||||
declaration: params.declaration || undefined,
|
||||
params: params.params,
|
||||
paramsMissing: params.paramsMissing,
|
||||
paramSpecs: params.paramSpecs.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label,
|
||||
required: Boolean(p.required),
|
||||
hint: p.hint,
|
||||
})),
|
||||
intent: intent || undefined,
|
||||
...(params.kind ? { kind: params.kind } : {}),
|
||||
...(params.closer ? { closer: true } : {}),
|
||||
...(params.revise ? { mode: "revise" as const } : {}),
|
||||
...(params.revises ? { revises: params.revises } : {}),
|
||||
};
|
||||
this.blackboard.write({
|
||||
tag: CREATION_PROPOSED_STEP_TAG,
|
||||
content: JSON.stringify(snapshot),
|
||||
source: "runtime",
|
||||
});
|
||||
this.blackboard.write({
|
||||
tag: CREATION_CURRENT_STEP_TAG,
|
||||
content: params.stepId,
|
||||
source: "runtime",
|
||||
});
|
||||
this.blackboard.write({
|
||||
tag: CREATION_INHERIT_EXISTING_TAG,
|
||||
content: params.revise ? "1" : "",
|
||||
source: "runtime",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
|
||||
[CREATION_CURRENT_STEP_TAG]: params.stepId,
|
||||
[CREATION_INHERIT_EXISTING_TAG]: params.revise ? "1" : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async startCreationStep(
|
||||
stepId: string,
|
||||
opts?: { stepParams?: CreationFlowStepParams; reenter?: boolean },
|
||||
): Promise<void> {
|
||||
this.syncSlotsToBlackboard(this.session);
|
||||
let flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) throw new Error("还没有工作流计划");
|
||||
let step = findStepByRef(flow, stepId);
|
||||
if (!step) throw new Error(`找不到节点:${stepId}`);
|
||||
const accepted = this.readAcceptedStepIds();
|
||||
const catalog = await this.loadActiveModuleCatalog();
|
||||
const reenter = opts?.reenter === true;
|
||||
|
||||
if (isPrototypeStep(step)) {
|
||||
if (!isPrototypeSelectable(flow, step, accepted)) {
|
||||
throw new Error(`「${step.name}」的前置还没齐,不能增殖`);
|
||||
}
|
||||
const spawned = spawnInstanceFromPrototype({
|
||||
flow,
|
||||
catalog,
|
||||
prototypeId: step.id,
|
||||
acceptedStepIds: accepted,
|
||||
});
|
||||
if ("error" in spawned) throw new Error(spawned.error);
|
||||
const content = stringifyCreationFlow(spawned.flow);
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content,
|
||||
source: "user",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
|
||||
};
|
||||
flow = spawned.flow;
|
||||
step = spawned.step;
|
||||
} else if (reenter) {
|
||||
if (!isStepAccepted(step, accepted)) {
|
||||
throw new Error(`「${step.name}」还没完成,请直接进入而不是回头修改`);
|
||||
}
|
||||
} else if (!isStepReady(flow, step, accepted)) {
|
||||
throw new Error(`「${step.name}」的前置节点还没完成,不能开始`);
|
||||
}
|
||||
|
||||
const pendingModule = findModuleByName(catalog, step.name);
|
||||
const paramSpecs = pendingModule?.params ?? [];
|
||||
const auto = pendingModule?.auto === true;
|
||||
if (opts?.stepParams && Object.keys(opts.stepParams).length > 0) {
|
||||
const next = patchCreationFlowStepParams(flow, step.id, opts.stepParams);
|
||||
const content = stringifyCreationFlow(next);
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content,
|
||||
source: "user",
|
||||
});
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: { ...this.session.slots, [CREATION_FLOW_TAG]: content },
|
||||
};
|
||||
}
|
||||
const latest = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
const pinned = latest ? findStepByRef(latest, step.id) ?? step : step;
|
||||
const params = pinned.params ? { ...pinned.params } : {};
|
||||
const paramsMissing = missingRequiredStepParams(pinned, pendingModule);
|
||||
this.pinCreationStepSnapshot({
|
||||
stepId: pinned.id,
|
||||
name: pinned.name,
|
||||
declaration: pendingModule?.declaration?.trim() || "",
|
||||
params,
|
||||
paramsMissing,
|
||||
paramSpecs,
|
||||
kind: pendingModule?.kind,
|
||||
closer: pendingModule?.closer === true,
|
||||
revise: reenter || isReviseStep(pinned),
|
||||
revises: pinned.revises,
|
||||
});
|
||||
|
||||
const waitingPick =
|
||||
this.session.waitingReason?.kind === "pick_creation_step";
|
||||
const needsParamForm = paramsMissing.length > 0 && !auto;
|
||||
if (needsParamForm) {
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
workerId: DESIGN_STEP_WORKER_ID,
|
||||
reason: `「${pinned.name}」还缺参数:${paramsMissing.join("、")}`,
|
||||
requiresApproval: true,
|
||||
});
|
||||
await this.dispatch({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (waitingPick) {
|
||||
await this.dispatch({
|
||||
type: "user_picked_creation_step",
|
||||
payload: { stepId: pinned.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
workerId: DESIGN_STEP_WORKER_ID,
|
||||
reason: reenter || isReviseStep(pinned)
|
||||
? `回头修改 · ${pinned.name}`
|
||||
: `开始:${pinned.name}`,
|
||||
requiresApproval: false,
|
||||
});
|
||||
await this.dispatch({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验收后回到分层图:点节点即确认并进入,底栏写意见再发即改编排。
|
||||
*/
|
||||
private async awaitCreationStepPick(): Promise<void> {
|
||||
if (this.session.slots[SLOT_CREATION_SEALED_BY_OPENING]) {
|
||||
this.onMessage(CREATION_SEALED_WAITING_MESSAGE);
|
||||
return;
|
||||
@@ -916,121 +1410,67 @@ export class PhaseRuntime {
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
const accepted = parseAcceptedUnits(
|
||||
this.session.slots[SLOT_CREATION_ACCEPTED_UNITS] ??
|
||||
this.blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
const pending = nextPendingStep(flow, accepted);
|
||||
if (!pending || !flow) {
|
||||
debugLog("step", "暂无下一步,交给总管");
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_PROPOSED_STEP_TAG]: undefined,
|
||||
},
|
||||
};
|
||||
if (this.mainAgent) {
|
||||
await this.runMainAgent();
|
||||
} else {
|
||||
this.onMessage(
|
||||
"[阶段机] 暂无下一节点,等待总管编排或补充意向。",
|
||||
);
|
||||
}
|
||||
const accepted = this.readAcceptedStepIds();
|
||||
if (!flow) {
|
||||
debugLog("step", "暂无可用流程,等待点选或改编排");
|
||||
await this.enterCreationStepPickWait();
|
||||
return;
|
||||
}
|
||||
|
||||
const active = this.getActiveSkill();
|
||||
let paramSpecs: ModuleParamSpec[] = [];
|
||||
let declaration = "";
|
||||
let auto = false;
|
||||
let kind: ModuleCatalogEntry["kind"];
|
||||
let pendingModule: ModuleCatalogEntry | null = null;
|
||||
if (active?.name) {
|
||||
try {
|
||||
const skill = await loadSkill(active.name);
|
||||
if (skill.skillPackRoot) {
|
||||
const catalog = await loadModuleCatalog(skill.skillPackRoot);
|
||||
const mod = catalog?.modules.find((m) => m.name === pending.name);
|
||||
paramSpecs = mod?.params ?? [];
|
||||
declaration = mod?.declaration?.trim() || "";
|
||||
auto = mod?.auto === true;
|
||||
kind = mod?.kind;
|
||||
pendingModule = mod ?? null;
|
||||
}
|
||||
} catch {
|
||||
/* catalog optional */
|
||||
const catalog = await this.loadActiveModuleCatalog();
|
||||
const ready = listReadySteps(flow, accepted);
|
||||
const autoReady = ready.filter((step) => {
|
||||
const mod = findModuleByName(catalog, step.name);
|
||||
return (
|
||||
mod?.auto === true &&
|
||||
missingRequiredStepParams(step, mod).length === 0
|
||||
);
|
||||
});
|
||||
if (autoReady.length > 0 && autoReady.length === ready.length) {
|
||||
const protoReady = listSelectablePrototypes(flow, accepted).length > 0;
|
||||
const spawnReady = catalog
|
||||
? listRepeatableSpawns(flow, catalog, accepted).some((s) => s.ready)
|
||||
: false;
|
||||
if (!protoReady && !spawnReady) {
|
||||
debugLog("step", `程序步直接开始 ${autoReady[0]!.id}`);
|
||||
await this.startCreationStep(autoReady[0]!.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
debugLog(
|
||||
"step",
|
||||
hasSelectableCreationWork(flow, catalog, accepted)
|
||||
? "等待点选创作节点"
|
||||
: "图上暂无已就绪节点,点节点进入或在底栏写意见改编排",
|
||||
);
|
||||
await this.enterCreationStepPickWait();
|
||||
}
|
||||
|
||||
debugLog("step", `提案下一步 ${pending.id}(${pending.name})`);
|
||||
|
||||
const params = pending.params ? { ...pending.params } : {};
|
||||
const paramsMissing = missingRequiredStepParams(pending, pendingModule);
|
||||
|
||||
const intent =
|
||||
typeof this.session.slots["用户.下一步意向"] === "string"
|
||||
? String(this.session.slots["用户.下一步意向"]).trim()
|
||||
: "";
|
||||
const paramHint =
|
||||
Object.keys(params).length > 0
|
||||
? `;参数 ${Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(" · ")}`
|
||||
: paramsMissing.length
|
||||
? `;待钉参数:${paramsMissing.join("、")}`
|
||||
: "";
|
||||
const revise = isReviseStep(pending);
|
||||
const reason = [
|
||||
revise ? `下一步:回头修改 · ${pending.name}` : `下一步:${pending.name}`,
|
||||
declaration ? `—— ${declaration}` : "",
|
||||
paramHint,
|
||||
intent ? `(你的意向:${intent})` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
|
||||
const snapshot = {
|
||||
stepId: pending.id,
|
||||
name: pending.name,
|
||||
declaration: declaration || undefined,
|
||||
params,
|
||||
paramsMissing,
|
||||
paramSpecs: paramSpecs.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label,
|
||||
required: Boolean(p.required),
|
||||
hint: p.hint,
|
||||
})),
|
||||
intent: intent || undefined,
|
||||
...(kind ? { kind } : {}),
|
||||
...(revise ? { mode: "revise" as const } : {}),
|
||||
...(pending.revises ? { revises: pending.revises } : {}),
|
||||
private async enterCreationStepPickWait(): Promise<void> {
|
||||
this.clearProposedStep();
|
||||
if (this.session.waitingReason?.kind === "pick_creation_step") return;
|
||||
if (this.session.phase === "running") {
|
||||
await this.dispatch({ type: "creation_step_pick_awaited", payload: {} });
|
||||
return;
|
||||
}
|
||||
this.session = {
|
||||
...this.session,
|
||||
phase: "waiting_user",
|
||||
waitingReason: { kind: "pick_creation_step" },
|
||||
pendingDecision: undefined,
|
||||
currentWorkerId: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.blackboard.write({
|
||||
tag: CREATION_PROPOSED_STEP_TAG,
|
||||
content: JSON.stringify(snapshot),
|
||||
source: "runtime",
|
||||
});
|
||||
}
|
||||
|
||||
private clearProposedStep(): void {
|
||||
this.session = {
|
||||
...this.session,
|
||||
slots: {
|
||||
...this.session.slots,
|
||||
[CREATION_PROPOSED_STEP_TAG]: JSON.stringify(snapshot),
|
||||
[CREATION_PROPOSED_STEP_TAG]: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const decision = createDecision({
|
||||
action: "run_worker",
|
||||
workerId: "design-step",
|
||||
reason,
|
||||
// 程序步(如投影排序)且参数已齐:跳过「同意并开始」,直接执行。
|
||||
requiresApproval: !(auto && paramsMissing.length === 0),
|
||||
});
|
||||
await this.dispatch({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1108,6 +1548,24 @@ export class PhaseRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
/** 再编排:解开开场收口,让 design-flow 能在收口前插入节点 */
|
||||
private unsealCreationOpening(): void {
|
||||
const reopened = reopenCreationFlowRaw(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (reopened) {
|
||||
this.blackboard.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: reopened,
|
||||
source: "runtime",
|
||||
});
|
||||
}
|
||||
const slots = { ...this.session.slots };
|
||||
delete slots[SLOT_CREATION_SEALED_BY_OPENING];
|
||||
if (reopened) slots[CREATION_FLOW_TAG] = reopened;
|
||||
this.session = { ...this.session, slots };
|
||||
}
|
||||
|
||||
private applyProposedStepParams(params: CreationFlowStepParams): void {
|
||||
const raw =
|
||||
(typeof this.session.slots[CREATION_PROPOSED_STEP_TAG] === "string"
|
||||
@@ -1807,6 +2265,10 @@ export class PhaseRuntime {
|
||||
flowRaw,
|
||||
currentStepName,
|
||||
acceptedStepNames: accepted,
|
||||
inheritExisting: isInheritExistingFlag(
|
||||
this.blackboard.getContentByTag(CREATION_INHERIT_EXISTING_TAG) ??
|
||||
this.session.slots[CREATION_INHERIT_EXISTING_TAG],
|
||||
),
|
||||
});
|
||||
if (!binding?.opening?.trim()) return false;
|
||||
|
||||
@@ -1952,7 +2414,9 @@ export class PhaseRuntime {
|
||||
const flow = parseCreationFlow(
|
||||
this.blackboard.getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
const pending = nextPendingStep(flow, accepted);
|
||||
const pinnedId = this.readPinnedStepId();
|
||||
const pinned = flow && pinnedId ? findStepByRef(flow, pinnedId) : null;
|
||||
const pending = pinned ?? nextPendingStep(flow, accepted);
|
||||
current = pending ? stepUnitId(pending) : current || "(无待执行步骤)";
|
||||
this.blackboard.write({
|
||||
tag: CREATION_CURRENT_STEP_TAG,
|
||||
@@ -2209,65 +2673,100 @@ export class PhaseRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private hasCreationFlow(): boolean {
|
||||
return Boolean(
|
||||
parseCreationFlow(this.blackboard.getContentByTag(CREATION_FLOW_TAG)),
|
||||
);
|
||||
}
|
||||
|
||||
private async commitMainAgentDecision(
|
||||
decision: MainAgentDecision,
|
||||
): Promise<void> {
|
||||
debugLog(
|
||||
"step",
|
||||
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
|
||||
);
|
||||
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||
const result = this.commitEvent({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
if (result.error) this.onMessage(result.error);
|
||||
await this.processEffects(result.effects);
|
||||
await this.maybeRunMainAgent(result.effects);
|
||||
}
|
||||
|
||||
private async runMainAgent(): Promise<void> {
|
||||
if (!this.mainAgent || this.session.phase !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasCreationFlow()) {
|
||||
debugLog("step", "已有创作流程,等待点选节点");
|
||||
await this.awaitCreationStepPick();
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog("step", "总管开始");
|
||||
const availableWorkers = await this.resolveAvailableWorkers();
|
||||
|
||||
const decision = await this.mainAgent.runToolLoop(
|
||||
{
|
||||
session: this.session,
|
||||
blackboardIndex: this.blackboard.listTagIndex(),
|
||||
availableWorkers,
|
||||
},
|
||||
{
|
||||
handlers: {
|
||||
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
|
||||
listWorkers: () => availableWorkers,
|
||||
listArtifacts: () =>
|
||||
this.session.artifacts.map((a) => ({
|
||||
id: a.id,
|
||||
workerId: a.workerId,
|
||||
status: a.status,
|
||||
summary: a.summary,
|
||||
outputTags: a.outputTags,
|
||||
})),
|
||||
onToolCall: (name, detail) => {
|
||||
const preview =
|
||||
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
|
||||
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
|
||||
},
|
||||
onThinkingDelta: (delta) => {
|
||||
this.onAgentThinkingDelta?.(delta);
|
||||
},
|
||||
onThinkingDone: (text) => {
|
||||
this.onAgentThinkingDone?.(text);
|
||||
this.onMessage(`[总管 思考]\n\n${text}`);
|
||||
try {
|
||||
const decision = await this.mainAgent.runToolLoop(
|
||||
{
|
||||
session: this.session,
|
||||
blackboardIndex: this.blackboard.listTagIndex(),
|
||||
availableWorkers,
|
||||
},
|
||||
{
|
||||
handlers: {
|
||||
readBlackboard: (tags) => this.readBlackboardForAgent(tags),
|
||||
listWorkers: () => availableWorkers,
|
||||
listArtifacts: () =>
|
||||
this.session.artifacts.map((a) => ({
|
||||
id: a.id,
|
||||
workerId: a.workerId,
|
||||
status: a.status,
|
||||
summary: a.summary,
|
||||
outputTags: a.outputTags,
|
||||
})),
|
||||
onToolCall: (name, detail) => {
|
||||
const preview =
|
||||
detail.length > 120 ? `${detail.slice(0, 120)}…` : detail;
|
||||
this.onMessage(`[总管 tool] ${name}${preview ? `: ${preview}` : ""}`);
|
||||
},
|
||||
onThinkingDelta: (delta) => {
|
||||
this.onAgentThinkingDelta?.(delta);
|
||||
},
|
||||
onThinkingDone: (text) => {
|
||||
this.onAgentThinkingDone?.(text);
|
||||
this.onMessage(`[总管 思考]\n\n${text}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
debugLog(
|
||||
"step",
|
||||
`总管决策 ${labelAction(decision.action)}${decision.workerId ? ` ${decision.workerId}` : ""}`,
|
||||
);
|
||||
this.onMessage(`[总管] ${decision.action}: ${decision.reason}`);
|
||||
|
||||
const result = this.commitEvent({
|
||||
type: "main_agent_decision_created",
|
||||
payload: { decision },
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
this.onMessage(result.error);
|
||||
);
|
||||
await this.commitMainAgentDecision(decision);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
if (this.hasCreationFlow()) {
|
||||
debugLog("step", "总管无可用决策,改为等待点选");
|
||||
await this.awaitCreationStepPick();
|
||||
return;
|
||||
}
|
||||
if (isUnusableMainAgentOutput(err)) {
|
||||
debugLog("step", "总管未给出工具调用,改为启动 design-flow");
|
||||
await this.commitMainAgentDecision(
|
||||
createDecision({
|
||||
action: "run_worker",
|
||||
workerId: DESIGN_FLOW_WORKER_ID,
|
||||
reason: "尚无创作流程,开始编排近期起点",
|
||||
requiresApproval: false,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
debugLog("step", `总管失败 ${reason.slice(0, 120)}`);
|
||||
await this.failRun(reason);
|
||||
}
|
||||
|
||||
await this.processEffects(result.effects);
|
||||
await this.maybeRunMainAgent(result.effects);
|
||||
}
|
||||
|
||||
/** 总管 read_blackboard tool:按 tag 或模式读取正文 */
|
||||
|
||||
@@ -435,6 +435,15 @@ export function buildFocus(
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "pick_creation_step") {
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "点选要做的节点",
|
||||
detail: "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发",
|
||||
};
|
||||
}
|
||||
|
||||
if (reason?.kind === "next_intent") {
|
||||
return {
|
||||
actorType: "user",
|
||||
@@ -471,6 +480,14 @@ export function buildFocus(
|
||||
|
||||
if (reason?.kind === "review_artifact") {
|
||||
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
|
||||
if (art?.workerId === "design-flow") {
|
||||
return {
|
||||
actorType: "user",
|
||||
actorLabel: "你",
|
||||
action: "点选要做的节点",
|
||||
detail: "点可进入的节点即确认并开始;虚线原型点一下增殖再进去。要改排在底栏写意见再发",
|
||||
};
|
||||
}
|
||||
const copy = reviewComposerCopy(art?.workerId, {
|
||||
hasQuestions: Boolean(reason.questions?.length),
|
||||
outputTags: art?.outputTags,
|
||||
|
||||
@@ -123,6 +123,17 @@ export function formatAgentDisplayTitle(detail?: string): string {
|
||||
return "编排器";
|
||||
}
|
||||
|
||||
/** 流程编排产物待确认:图上点节点即确认,不走验收双按钮 */
|
||||
export function isFlowPlanReview(view: {
|
||||
waitingReason?: { kind?: string } | null;
|
||||
reviewArtifact?: { workerId?: string | null } | null;
|
||||
} | null | undefined): boolean {
|
||||
return (
|
||||
view?.waitingReason?.kind === "review_artifact" &&
|
||||
view?.reviewArtifact?.workerId === "design-flow"
|
||||
);
|
||||
}
|
||||
|
||||
/** 底栏双按钮 + 工作面 kicker:随 waiting 产物种类变化 */
|
||||
export type ReviewComposerCopy = {
|
||||
/** 底栏短芯片 */
|
||||
@@ -179,8 +190,8 @@ export function reviewComposerCopy(
|
||||
taskLabel: "开场",
|
||||
taskTitle: "选定开场白",
|
||||
hint: hasQuestions
|
||||
? "同意就选定此开场;要改就写意见。下方追问可选答。"
|
||||
: "同意就选定此开场;要改就写意见。",
|
||||
? "同意就选定此开场;要改开场白就写意见。下方追问可选答。"
|
||||
: "同意就选定此开场;要改开场白就写意见。",
|
||||
submitLabel: "按意见修改",
|
||||
acceptLabel: "选定此开场",
|
||||
placeholder: "修改意见…",
|
||||
|
||||
+324
-32
@@ -81,8 +81,10 @@ import { Blackboard } from "../blackboard/blackboard.js";
|
||||
import { parseWorkerSetYaml } from "../skills/worker-set-parse.js";
|
||||
import { parseShellAdaptationFromReplyFormat } from "../skills/present-packet.js";
|
||||
import {
|
||||
CREATION_ACCEPTED_CONTENT_TAG,
|
||||
CREATION_ACCEPTED_UNITS_TAG,
|
||||
isDesignDiskWorker,
|
||||
parseAcceptedContentStore,
|
||||
parseAcceptedUnits,
|
||||
SLOT_CREATION_ACCEPTED_UNITS,
|
||||
} from "../skills/creation-units.js";
|
||||
@@ -104,18 +106,26 @@ import {
|
||||
CREATION_FLOW_TAG,
|
||||
CREATION_PROPOSED_STEP_TAG,
|
||||
CREATION_SELECTED_RECIPE_TAG,
|
||||
DESIGN_FLOW_WORKER_ID,
|
||||
creationFlowFromRecipeSeed,
|
||||
findRecipeCatalogEntry,
|
||||
formatCreationFlowForUser,
|
||||
findModuleByName,
|
||||
findStepByRef,
|
||||
type FlowStepTitleHint,
|
||||
isProgressPointerTag,
|
||||
loadRecipeCatalog,
|
||||
loadRecipeDetail,
|
||||
loadModuleCatalog,
|
||||
parseCreationFlow,
|
||||
parseSelectedRecipeRef,
|
||||
resolveDesignStepBinding,
|
||||
stepUnitId,
|
||||
stringifyCreationFlow,
|
||||
stripReviseSteps,
|
||||
type CreationFlow,
|
||||
type CreationFlowUserView,
|
||||
type ModuleCatalog,
|
||||
type RecipeCatalogEntry,
|
||||
} from "../skills/creation-flow.js";
|
||||
import { loadSkill } from "../skills/loader.js";
|
||||
@@ -260,6 +270,8 @@ export type SessionAction =
|
||||
| { type: "approve"; label: string }
|
||||
| { type: "accept"; label: string }
|
||||
| { type: "reject"; label: string }
|
||||
| { type: "replan"; label: string }
|
||||
| { type: "leave_step"; label: string }
|
||||
| { type: "run_outline"; label: string }
|
||||
| { type: "finish"; label: string };
|
||||
|
||||
@@ -297,6 +309,8 @@ export type ProposedNextStepView = {
|
||||
/** 步骤怎么跑:revise = 回头修改既有产物 */
|
||||
mode?: "fresh" | "revise";
|
||||
revises?: string;
|
||||
/** 收口节点(开场白):确认开干前仍可改编排追加前序节点 */
|
||||
closer?: boolean;
|
||||
};
|
||||
|
||||
export type ReviewArtifactView = {
|
||||
@@ -325,6 +339,8 @@ type ManagedSession = {
|
||||
branchState: MessageBranchState;
|
||||
/** 当前能力包可选初始配方(用户手动选) */
|
||||
recipeOptions?: RecipeCatalogEntry[];
|
||||
/** 模块目录(分层图 / 可反复增殖) */
|
||||
moduleCatalog?: ModuleCatalog | null;
|
||||
/** 开局模块 opening,缓存给意图页 */
|
||||
openingGuide?: { stepId: string; stepName: string; text: string } | null;
|
||||
/** 当前 LLM 请求的取消器(停止并重试) */
|
||||
@@ -540,6 +556,7 @@ export class SessionManager {
|
||||
if (bookId) {
|
||||
this.activeBookSessions.set(bookId, id);
|
||||
appendBookSession(bookId, id);
|
||||
this.persist(managed);
|
||||
}
|
||||
return this.toView(id);
|
||||
}
|
||||
@@ -557,7 +574,9 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
get(id: string): SessionView | null {
|
||||
if (!this.sessions.has(id)) return null;
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) return null;
|
||||
if (s.runtime.recoverOrphanedRun()) this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
@@ -825,7 +844,8 @@ export class SessionManager {
|
||||
kind !== "review_artifact" &&
|
||||
kind !== "approve_step" &&
|
||||
kind !== "revision" &&
|
||||
kind !== "next_intent"
|
||||
kind !== "next_intent" &&
|
||||
kind !== "pick_creation_step"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -1079,8 +1099,7 @@ export class SessionManager {
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 发送消息失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
await this.recoverFailedRun(s, detail);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
@@ -1315,8 +1334,7 @@ export class SessionManager {
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 停止并重试失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
await this.recoverFailedRun(s, detail);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
@@ -1462,7 +1480,7 @@ export class SessionManager {
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, async () => {
|
||||
if (waiting?.kind === "approve_step") {
|
||||
await s.runtime.rejectStep(reason ?? "用户暂不执行");
|
||||
await s.runtime.leaveCreationStep();
|
||||
} else if (waiting?.kind === "review_artifact") {
|
||||
await s.runtime.rejectArtifact(reason ?? "用户要求重新来");
|
||||
} else {
|
||||
@@ -1476,9 +1494,174 @@ export class SessionManager {
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
/** 离开当前技能步,回到分层图点选 */
|
||||
async leaveStep(id: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, () =>
|
||||
s.runtime.leaveCreationStep(),
|
||||
);
|
||||
if (!owned) return this.toView(id);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 返回节点失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
/** 开场白等步骤上发现漏了前序节点:再进流程编排,不整局重开 */
|
||||
async replan(id: string, reason?: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
const note = reason?.trim() || "回到流程编排,追加或改排节点。";
|
||||
this.clearAgentThinking(id);
|
||||
recordPreMessageCheckpoint(
|
||||
s.branchState,
|
||||
s.messages.length,
|
||||
this.captureCheckpoint(s),
|
||||
);
|
||||
s.messages.push(this.msg("user", note));
|
||||
this.syncCreationDialogue(s);
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, () =>
|
||||
s.runtime.replanCreationFlow(note),
|
||||
);
|
||||
if (!owned) return this.toView(id);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 再编排失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
this.syncCreationDialogue(s);
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async pickStep(
|
||||
id: string,
|
||||
stepId: string,
|
||||
stepParams?: Record<string, unknown>,
|
||||
reenter?: boolean,
|
||||
): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, async () => {
|
||||
await this.confirmPendingFlowPlan(s);
|
||||
const waiting = s.runtime.getSession().waitingReason?.kind;
|
||||
if (waiting !== "pick_creation_step") {
|
||||
throw new Error("当前不是点选节点的时机");
|
||||
}
|
||||
await s.runtime.pickCreationStep(stepId, stepParams, {
|
||||
reenter: reenter === true,
|
||||
});
|
||||
});
|
||||
if (!owned) return this.toView(id);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 点选节点失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
}
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
async getStepArtifact(
|
||||
id: string,
|
||||
stepId: string,
|
||||
): Promise<{
|
||||
stepId: string;
|
||||
name: string;
|
||||
tag: string | null;
|
||||
content: string;
|
||||
}> {
|
||||
const s = this.require(id);
|
||||
const catalog = s.moduleCatalog;
|
||||
const flow = parseCreationFlow(
|
||||
s.runtime.getBlackboard().getContentByTag(CREATION_FLOW_TAG),
|
||||
);
|
||||
if (!flow) throw new Error("还没有工作流计划");
|
||||
const cleaned = stripReviseSteps(flow);
|
||||
const step = findStepByRef(cleaned, stepId.trim());
|
||||
if (!step) throw new Error(`找不到节点:${stepId}`);
|
||||
const mod = findModuleByName(catalog, step.name);
|
||||
const tag = mod?.artifact?.trim() || null;
|
||||
const content = tag
|
||||
? s.runtime.getBlackboard().getContentByTag(tag)?.trim() || ""
|
||||
: "";
|
||||
return { stepId: step.id, name: step.name, tag, content };
|
||||
}
|
||||
|
||||
async spawnStep(id: string, moduleName: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
const owned = await this.runExclusive(id, s, async () => {
|
||||
await this.confirmPendingFlowPlan(s);
|
||||
const waiting = s.runtime.getSession().waitingReason?.kind;
|
||||
if (waiting !== "pick_creation_step") {
|
||||
throw new Error("当前不是追加节点的时机");
|
||||
}
|
||||
await s.runtime.spawnCreationStep(moduleName);
|
||||
});
|
||||
if (!owned) return this.toView(id);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error("[会话] 追加节点失败", detail);
|
||||
s.messages.push(this.msg("system", formatRuntimeError(detail)));
|
||||
}
|
||||
this.persist(s);
|
||||
return this.toView(id);
|
||||
}
|
||||
|
||||
/** 已点进技能步、尚未验收:可退回分层图(误点可增殖节点时用) */
|
||||
private canOfferLeaveCreationStep(
|
||||
s: ManagedSession,
|
||||
reason: WaitingReason | undefined,
|
||||
): boolean {
|
||||
const session = s.runtime.getSession();
|
||||
if (
|
||||
isPlayLayerActive(session.slots) ||
|
||||
inferLifecycleStage(session) === "play"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const kind = reason?.kind;
|
||||
if (
|
||||
kind !== "worker_questions" &&
|
||||
kind !== "input" &&
|
||||
kind !== "revision"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const stepId =
|
||||
s.runtime.getBlackboard().getContentByTag(CREATION_CURRENT_STEP_TAG)?.trim() ||
|
||||
session.currentStepId?.trim() ||
|
||||
"";
|
||||
return Boolean(stepId);
|
||||
}
|
||||
|
||||
/** 流程编排还停在核对态时,点节点先收下计划再进入 */
|
||||
private async confirmPendingFlowPlan(s: ManagedSession): Promise<void> {
|
||||
const session = s.runtime.getSession();
|
||||
if (session.waitingReason?.kind !== "review_artifact") return;
|
||||
const art = session.pendingArtifactId
|
||||
? session.artifacts.find((a) => a.id === session.pendingArtifactId)
|
||||
: undefined;
|
||||
if (art?.workerId !== DESIGN_FLOW_WORKER_ID) {
|
||||
throw new Error("当前不是点选节点的时机");
|
||||
}
|
||||
await s.runtime.acceptArtifact();
|
||||
}
|
||||
|
||||
async runOutline(id: string): Promise<SessionView> {
|
||||
const s = this.require(id);
|
||||
try {
|
||||
@@ -1586,6 +1769,7 @@ export class SessionManager {
|
||||
this.activeBookSessions.set(params.bookId, params.sessionId);
|
||||
appendBookSession(params.bookId, params.sessionId);
|
||||
await this.refreshRecipeOptions(managedRef);
|
||||
runtime.recoverOrphanedRun();
|
||||
this.persist(managedRef);
|
||||
await this.maybeKickAgentAfterDemand(runtime);
|
||||
|
||||
@@ -1891,6 +2075,20 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行失败后离开 running,避免界面一直转圈。 */
|
||||
private async recoverFailedRun(
|
||||
s: ManagedSession,
|
||||
detail: string,
|
||||
): Promise<void> {
|
||||
const formatted = formatRuntimeError(detail);
|
||||
if (s.runtime.getSession().phase === "running") {
|
||||
await s.runtime.failRun(formatted);
|
||||
} else {
|
||||
s.messages.push(this.msg("system", formatted));
|
||||
}
|
||||
if (s.bookId) this.syncBookPreview(s.bookId, s.messages);
|
||||
}
|
||||
|
||||
private enrichDisplayMessages(
|
||||
session: RuntimeSession,
|
||||
messages: ChatMessage[],
|
||||
@@ -2202,11 +2400,21 @@ export class SessionManager {
|
||||
} else {
|
||||
actions.push({ type: "approve", label: "确认执行" });
|
||||
}
|
||||
actions.push({ type: "reject", label: "暂不执行" });
|
||||
actions.push({ type: "reject", label: "返回节点" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "说明意见",
|
||||
placeholder: "改排、回头改某步,或说明不要这一步…",
|
||||
placeholder: "改排、补节点(如 NPC)、回头改某步,或说明不要这一步…",
|
||||
});
|
||||
} else if (reason?.kind === "pick_creation_step") {
|
||||
hints.push(
|
||||
"点可进入的节点即确认并开始。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
|
||||
);
|
||||
actions.push({ type: "replan", label: "发送" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
|
||||
});
|
||||
} else if (reason?.kind === "next_intent") {
|
||||
hints.push("下一步想写什么?可留空;也可说回头改某步。发送后会展示下一节点供你确认。");
|
||||
@@ -2220,15 +2428,30 @@ export class SessionManager {
|
||||
const copy = reviewComposerCopy(art?.workerId, {
|
||||
hasQuestions: Boolean(reason.questions?.length),
|
||||
});
|
||||
actions.push({ type: "accept", label: copy.acceptLabel });
|
||||
actions.push({ type: "reject", label: "不接受" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: copy.submitLabel,
|
||||
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
|
||||
});
|
||||
if (art?.workerId === DESIGN_FLOW_WORKER_ID) {
|
||||
hints.push(
|
||||
"点可进入的节点即确认并开始。虚线原型点一下会增殖一条实例再进去。要改排在底栏写意见再发。",
|
||||
);
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: "发送",
|
||||
placeholder: "例如:补舞台骨架、把某步改到开场白之前…",
|
||||
});
|
||||
} else {
|
||||
actions.push({ type: "accept", label: copy.acceptLabel });
|
||||
actions.push({ type: "reject", label: "不接受" });
|
||||
actions.push({
|
||||
type: "send_message",
|
||||
label: copy.submitLabel,
|
||||
placeholder: `${copy.placeholder};在现有产物上改,不整份重做…`,
|
||||
});
|
||||
}
|
||||
if (reason.questions?.length) {
|
||||
hints.push(`下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`);
|
||||
hints.push(
|
||||
art?.workerId === DESIGN_FLOW_WORKER_ID
|
||||
? "下方追问可选;点节点进入即收起"
|
||||
: `下方追问可选;${copy.acceptLabel}即收起(表示无需再完善)`,
|
||||
);
|
||||
actions.push({ type: "answer_questions", label: "提交作答" });
|
||||
actions.push({ type: "skip_questions", label: "跳过追问" });
|
||||
}
|
||||
@@ -2281,6 +2504,10 @@ export class SessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.canOfferLeaveCreationStep(s, reason)) {
|
||||
actions.push({ type: "leave_step", label: "返回节点" });
|
||||
}
|
||||
|
||||
if (s.bookId) {
|
||||
this.persist(s);
|
||||
}
|
||||
@@ -2330,7 +2557,12 @@ export class SessionManager {
|
||||
effectiveStartupMode(activeSnap) === "agent-first"
|
||||
? activeSnap.uiPrompt?.trim() || DEFAULT_UI_PROMPT
|
||||
: undefined,
|
||||
reviewArtifact: buildReviewArtifactView(session, messages, s.runtime.getBlackboard()),
|
||||
reviewArtifact: buildReviewArtifactView(
|
||||
session,
|
||||
messages,
|
||||
s.runtime.getBlackboard(),
|
||||
s.moduleCatalog,
|
||||
),
|
||||
workerSetView: buildWorkerSetUserView(
|
||||
s.runtime.getBlackboard(),
|
||||
s.runtime.getSession(),
|
||||
@@ -2338,6 +2570,7 @@ export class SessionManager {
|
||||
creationFlowView: buildCreationFlowUserView(
|
||||
s.runtime.getBlackboard(),
|
||||
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
|
||||
s.moduleCatalog,
|
||||
),
|
||||
proposedNextStep:
|
||||
reason?.kind === "approve_step"
|
||||
@@ -2448,6 +2681,7 @@ export class SessionManager {
|
||||
const skillName = s.runtime.getActiveSkill()?.name;
|
||||
if (!skillName) {
|
||||
s.recipeOptions = [];
|
||||
s.moduleCatalog = null;
|
||||
s.openingGuide = null;
|
||||
return;
|
||||
}
|
||||
@@ -2455,13 +2689,19 @@ export class SessionManager {
|
||||
const skill = await loadSkill(skillName);
|
||||
if (!skill.skillPackRoot) {
|
||||
s.recipeOptions = [];
|
||||
s.moduleCatalog = null;
|
||||
s.openingGuide = null;
|
||||
return;
|
||||
}
|
||||
const catalog = await loadRecipeCatalog(skill.skillPackRoot);
|
||||
const [catalog, modules] = await Promise.all([
|
||||
loadRecipeCatalog(skill.skillPackRoot),
|
||||
loadModuleCatalog(skill.skillPackRoot),
|
||||
]);
|
||||
s.recipeOptions = catalog?.recipes ?? [];
|
||||
s.moduleCatalog = modules;
|
||||
} catch {
|
||||
s.recipeOptions = [];
|
||||
s.moduleCatalog = null;
|
||||
}
|
||||
await this.ensureRecipeSeededFlow(s);
|
||||
await this.refreshOpeningGuide(s);
|
||||
@@ -2489,11 +2729,7 @@ export class SessionManager {
|
||||
const detail = await loadRecipeDetail(packRoot, entry);
|
||||
const seeded = creationFlowFromRecipeSeed(detail);
|
||||
if (!seeded) return;
|
||||
board.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: stringifyCreationFlow(seeded),
|
||||
source: "runtime",
|
||||
});
|
||||
await this.commitSeededCreationFlow(s, seeded);
|
||||
} catch (err) {
|
||||
console.error("[会话] 配方预置失败", err);
|
||||
}
|
||||
@@ -2553,6 +2789,7 @@ export class SessionManager {
|
||||
s: ManagedSession,
|
||||
reason: WaitingReason | undefined,
|
||||
): { stepId: string; stepName: string; text: string } | null {
|
||||
if (reason?.kind === "pick_creation_step") return null;
|
||||
if (reason?.kind === "worker_questions") {
|
||||
const qs = normalizeQuestions(reason.questions);
|
||||
if (isModuleOpeningQuestions(qs)) {
|
||||
@@ -2584,6 +2821,44 @@ export class SessionManager {
|
||||
return s.openingGuide ?? null;
|
||||
}
|
||||
|
||||
/** 写入剧本 seed,并立刻进入点选图(开局节点跟配方走)。 */
|
||||
private async commitSeededCreationFlow(
|
||||
s: ManagedSession,
|
||||
seeded: CreationFlow,
|
||||
): Promise<void> {
|
||||
this.writeSeededCreationFlow(s, seeded);
|
||||
await s.runtime.enterSeededCreationPick();
|
||||
}
|
||||
|
||||
/** 配方预置 DAG:写入黑板并留下可回退的编排检查点(不必先跑 LLM) */
|
||||
private writeSeededCreationFlow(s: ManagedSession, seeded: CreationFlow): void {
|
||||
s.runtime.getBlackboard().write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: stringifyCreationFlow(seeded),
|
||||
source: "runtime",
|
||||
});
|
||||
const alreadyRecorded = s.messages.some(
|
||||
(m) =>
|
||||
m.kind === "worker_output" &&
|
||||
m.actor === "design-flow" &&
|
||||
(m.text ?? "").includes("配方预置近期起点"),
|
||||
);
|
||||
if (alreadyRecorded) return;
|
||||
const names = seeded.steps.map((step) => step.name).join(" → ");
|
||||
recordPreMessageCheckpoint(
|
||||
s.branchState,
|
||||
s.messages.length,
|
||||
this.captureCheckpoint(s),
|
||||
);
|
||||
s.messages.push(
|
||||
this.msg(
|
||||
"system",
|
||||
`[Worker] design-flow 已完成\n\n配方预置近期起点${names ? `:${names}` : ""}。\n这是可回退的编排检查点;之后可再编排追加节点(如生成规则 / 具体实例)。`,
|
||||
),
|
||||
);
|
||||
this.syncCreationDialogue(s);
|
||||
}
|
||||
|
||||
private async applySelectedRecipe(
|
||||
s: ManagedSession,
|
||||
recipeId: string,
|
||||
@@ -2606,7 +2881,7 @@ export class SessionManager {
|
||||
source: "user",
|
||||
});
|
||||
|
||||
// 剧本已钉死开局模块 → 默认写入近期起点,开局即坐在 DAG 第一步(不必先跑 design-flow)
|
||||
// 开局节点跟剧本 seed:写入近期起点并进入点选图(不必先跑 design-flow / 先描述需求)
|
||||
const existingFlow = board.getContentByTag(CREATION_FLOW_TAG)?.trim();
|
||||
if (!existingFlow) {
|
||||
const skillName = s.runtime.getActiveSkill()?.name;
|
||||
@@ -2618,11 +2893,7 @@ export class SessionManager {
|
||||
const detail = await loadRecipeDetail(packRoot, entry);
|
||||
const seeded = creationFlowFromRecipeSeed(detail);
|
||||
if (seeded) {
|
||||
board.write({
|
||||
tag: CREATION_FLOW_TAG,
|
||||
content: stringifyCreationFlow(seeded),
|
||||
source: "runtime",
|
||||
});
|
||||
await this.commitSeededCreationFlow(s, seeded);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -2652,6 +2923,7 @@ function collectFilledTags(blackboard: Blackboard): string[] {
|
||||
function buildCreationFlowUserView(
|
||||
blackboard: Blackboard,
|
||||
acceptedRaw?: unknown,
|
||||
catalog?: ModuleCatalog | null,
|
||||
): CreationFlowUserView | undefined {
|
||||
const raw = blackboard.getContentByTag(CREATION_FLOW_TAG)?.trim();
|
||||
if (!raw) return undefined;
|
||||
@@ -2662,7 +2934,24 @@ function buildCreationFlowUserView(
|
||||
const accepted = parseAcceptedUnits(
|
||||
acceptedRaw ?? blackboard.getContentByTag(CREATION_ACCEPTED_UNITS_TAG),
|
||||
);
|
||||
return formatCreationFlowForUser(flow, undefined, accepted);
|
||||
const titleHints: Record<string, FlowStepTitleHint> = {};
|
||||
const store = parseAcceptedContentStore(
|
||||
blackboard.getContentByTag(CREATION_ACCEPTED_CONTENT_TAG),
|
||||
);
|
||||
for (const [id, entry] of Object.entries(store.units)) {
|
||||
if (!entry.summary && entry.content == null) continue;
|
||||
titleHints[id] = {
|
||||
...(entry.summary ? { summary: entry.summary } : {}),
|
||||
...(entry.content != null ? { content: entry.content } : {}),
|
||||
};
|
||||
}
|
||||
return formatCreationFlowForUser(
|
||||
flow,
|
||||
catalog,
|
||||
accepted,
|
||||
collectFilledTags(blackboard),
|
||||
titleHints,
|
||||
);
|
||||
}
|
||||
|
||||
function readProposedNextStep(
|
||||
@@ -2708,6 +2997,7 @@ function readProposedNextStep(
|
||||
intent: typeof doc.intent === "string" ? doc.intent : undefined,
|
||||
mode: doc.mode === "revise" ? "revise" : undefined,
|
||||
revises: typeof doc.revises === "string" ? doc.revises : undefined,
|
||||
closer: doc.closer === true ? true : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -2767,6 +3057,7 @@ function buildReviewArtifactView(
|
||||
session: RuntimeSession,
|
||||
messages: ChatMessage[],
|
||||
blackboard: Blackboard,
|
||||
catalog?: ModuleCatalog | null,
|
||||
): ReviewArtifactView | undefined {
|
||||
if (session.waitingReason?.kind !== "review_artifact") return undefined;
|
||||
const art = session.artifacts.find((a) => a.id === session.pendingArtifactId);
|
||||
@@ -2869,6 +3160,7 @@ function buildReviewArtifactView(
|
||||
? buildCreationFlowUserView(
|
||||
blackboard,
|
||||
session.slots[SLOT_CREATION_ACCEPTED_UNITS],
|
||||
catalog,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
@@ -2896,7 +3188,7 @@ function formatRuntimeError(detail: string): string {
|
||||
if (detail.includes("401") || detail.toLowerCase().includes("authentication")) {
|
||||
return (
|
||||
"[请求失败] API Key 无效或未授权。请到「设置 → API 配置」检查 Key 与 Base URL," +
|
||||
"或点击「测试连接」验证。"
|
||||
"或点击「探测」验证。"
|
||||
);
|
||||
}
|
||||
if (detail.startsWith("LLM request failed")) {
|
||||
|
||||
@@ -4,9 +4,13 @@ import {
|
||||
deleteApiProfile,
|
||||
getApiProfile,
|
||||
listApiProfiles,
|
||||
testApiProfile,
|
||||
probeAndSaveProfileCapabilities,
|
||||
updateApiProfile,
|
||||
} from "../config/api-profiles.js";
|
||||
import {
|
||||
deliveryModeLabel,
|
||||
resolveStructuredDeliveryMode,
|
||||
} from "../llm/capabilities.js";
|
||||
import {
|
||||
ensureActiveProfileDefault,
|
||||
loadAppSettings,
|
||||
@@ -120,12 +124,14 @@ export async function handleSettingsApi(
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
reasoningEffort?: string;
|
||||
};
|
||||
const profile = createApiProfile({
|
||||
name: body.name ?? "新配置",
|
||||
baseUrl: body.baseUrl ?? "https://api.deepseek.com",
|
||||
apiKey: body.apiKey ?? "",
|
||||
model: body.model ?? "deepseek-v4-pro",
|
||||
reasoningEffort: body.reasoningEffort,
|
||||
});
|
||||
const activate = (body as { activate?: boolean }).activate === true;
|
||||
if (activate) {
|
||||
@@ -136,19 +142,34 @@ export async function handleSettingsApi(
|
||||
return true;
|
||||
}
|
||||
|
||||
const profileMatch = pathname.match(/^\/api\/profiles\/([^/]+)(\/test)?$/);
|
||||
const profileMatch = pathname.match(
|
||||
/^\/api\/profiles\/([^/]+)(\/probe-capabilities)?$/,
|
||||
);
|
||||
if (profileMatch) {
|
||||
const id = decodeURIComponent(profileMatch[1]);
|
||||
const isTest = profileMatch[2] === "/test";
|
||||
const suffix = profileMatch[2];
|
||||
|
||||
if (isTest && req.method === "POST") {
|
||||
const profile = getApiProfile(id);
|
||||
if (!profile) {
|
||||
json(res, 404, { error: "配置不存在" });
|
||||
return true;
|
||||
if (suffix === "/probe-capabilities" && req.method === "POST") {
|
||||
try {
|
||||
const profile = await probeAndSaveProfileCapabilities(id);
|
||||
const mode = resolveStructuredDeliveryMode(profile.capabilities);
|
||||
const settings = loadAppSettings();
|
||||
const reloadedSessions =
|
||||
settings.activeProfileId === id
|
||||
? sessionManager.reloadAllLlms()
|
||||
: 0;
|
||||
json(res, 200, {
|
||||
ok: profile.capabilities?.chat === "ok",
|
||||
profile,
|
||||
deliveryMode: mode,
|
||||
deliveryModeLabel: deliveryModeLabel(mode),
|
||||
reloadedSessions,
|
||||
});
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : "能力探测失败",
|
||||
});
|
||||
}
|
||||
const result = await testApiProfile(profile);
|
||||
json(res, 200, result);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -168,10 +189,16 @@ export async function handleSettingsApi(
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
reasoningEffort?: string;
|
||||
};
|
||||
try {
|
||||
const profile = updateApiProfile(id, body);
|
||||
json(res, 200, { profile });
|
||||
const settings = loadAppSettings();
|
||||
const reloadedSessions =
|
||||
settings.activeProfileId === id
|
||||
? sessionManager.reloadAllLlms()
|
||||
: 0;
|
||||
json(res, 200, { profile, reloadedSessions });
|
||||
} catch (err) {
|
||||
json(res, 404, {
|
||||
error: err instanceof Error ? err.message : "更新失败",
|
||||
|
||||
@@ -123,6 +123,18 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && sub === "/step-artifact") {
|
||||
try {
|
||||
const stepId = url.searchParams.get("stepId") ?? "";
|
||||
const data = await sessionManager.getStepArtifact(sessionId, stepId);
|
||||
json(res, 200, data);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "读取失败";
|
||||
json(res, msg.includes("不存在") ? 404 : 400, { error: msg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/messages") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
text?: string;
|
||||
@@ -445,6 +457,10 @@ const server = createServer(async (req, res) => {
|
||||
action?: string;
|
||||
stepParams?: Record<string, unknown>;
|
||||
openingIndex?: number;
|
||||
reason?: string;
|
||||
stepId?: string;
|
||||
moduleName?: string;
|
||||
reenter?: boolean;
|
||||
};
|
||||
let view;
|
||||
try {
|
||||
@@ -474,6 +490,31 @@ const server = createServer(async (req, res) => {
|
||||
case "reject":
|
||||
view = await sessionManager.reject(sessionId);
|
||||
break;
|
||||
case "replan":
|
||||
view = await sessionManager.replan(
|
||||
sessionId,
|
||||
typeof body.reason === "string" ? body.reason : undefined,
|
||||
);
|
||||
break;
|
||||
case "leave_step":
|
||||
view = await sessionManager.leaveStep(sessionId);
|
||||
break;
|
||||
case "pick_step":
|
||||
view = await sessionManager.pickStep(
|
||||
sessionId,
|
||||
typeof body.stepId === "string" ? body.stepId : "",
|
||||
body.stepParams && typeof body.stepParams === "object"
|
||||
? body.stepParams
|
||||
: undefined,
|
||||
body.reenter === true,
|
||||
);
|
||||
break;
|
||||
case "spawn_step":
|
||||
view = await sessionManager.spawnStep(
|
||||
sessionId,
|
||||
typeof body.moduleName === "string" ? body.moduleName : "",
|
||||
);
|
||||
break;
|
||||
case "run_outline":
|
||||
view = await sessionManager.runOutline(sessionId);
|
||||
break;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 上下文片段产物(context-fragment.v1)解析与展示辅助。
|
||||
* 规范:docs/context-fragment-design.md
|
||||
*/
|
||||
import { extractJsonObjectText } from "./worker-set-parse.js";
|
||||
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
||||
import {
|
||||
normalizeQuestions,
|
||||
type QuestionItem,
|
||||
@@ -75,14 +75,9 @@ function normalizeStability(v: unknown): ContextFragmentStability | undefined {
|
||||
export function parseContextFragment(raw: unknown): ContextFragment | undefined {
|
||||
let row: Record<string, unknown> | null = null;
|
||||
if (typeof raw === "string") {
|
||||
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
row = raw as Record<string, unknown>;
|
||||
@@ -278,14 +273,9 @@ export function extractFragmentAskSidecar(raw: unknown): {
|
||||
} {
|
||||
let row: Record<string, unknown> | null = null;
|
||||
if (typeof raw === "string") {
|
||||
const text = extractJsonObjectText(raw) ?? raw.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return { questions: [], assessment: "" };
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row = parsed as Record<string, unknown>;
|
||||
}
|
||||
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
row = raw as Record<string, unknown>;
|
||||
|
||||
+1029
-76
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,8 @@ type WorkerTemplateDoc = {
|
||||
|
||||
/**
|
||||
* 解析本次 worker 的验收模式(创作 / run 共用入口)。
|
||||
* - design-* / opening-generator 磁盘创作 worker → 始终 user_confirmed
|
||||
* - design-flow → no_confirmation(图上点节点即确认;底栏输入即改编排)
|
||||
* - design-step / opening-generator 磁盘创作 worker → 始终 user_confirmed
|
||||
* - 游玩回合:一律 no_confirmation(一句输入连跑,不穿插验收)
|
||||
* - Worker 集 acceptance: review → user_confirmed;continue → no_confirmation
|
||||
* - 未声明 acceptance:创作默认确认
|
||||
@@ -55,6 +56,7 @@ export function resolveAcceptanceModeForWorker(params: {
|
||||
workerId: string;
|
||||
}): AcceptanceMode {
|
||||
const workerId = params.workerId.trim();
|
||||
if (workerId === "design-flow") return "no_confirmation";
|
||||
if (isDesignDiskWorker(workerId) || workerId === "opening-generator") {
|
||||
return "user_confirmed";
|
||||
}
|
||||
@@ -95,7 +97,7 @@ export async function resolveRunnableWorker(params: {
|
||||
const acceptedRaw =
|
||||
params.session.slots?.creationAcceptedUnits ??
|
||||
params.blackboard.getContentByTag("创作.已验收单位");
|
||||
const { parseAcceptedSteps } = await import("./creation-flow.js");
|
||||
const { parseAcceptedSteps, isInheritExistingFlag } = await import("./creation-flow.js");
|
||||
const withCtx = await loadWorkerSkillWithContext(
|
||||
params.skillPackName,
|
||||
params.workerId,
|
||||
@@ -106,6 +108,9 @@ export async function resolveRunnableWorker(params: {
|
||||
acceptedStepNames: parseAcceptedSteps(acceptedRaw),
|
||||
selectedRecipeRef,
|
||||
filledArtifactTags: params.blackboard.listTagIndex().map((item) => item.tag),
|
||||
inheritExisting: isInheritExistingFlag(
|
||||
params.blackboard.getContentByTag("创作.继承修改"),
|
||||
),
|
||||
},
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 扁平产物契约(wire format):机器字段薄,正文用 sections[] 字符串块。
|
||||
* 渲染与旧 context-fragment.v1 深树通过 normalize 互转。
|
||||
*/
|
||||
|
||||
export const FLAT_ARTIFACT_SCHEMA_ID = "artifact.flat.v1" as const;
|
||||
|
||||
export type FlatArtifactQuestion = {
|
||||
id: string;
|
||||
prompt: string;
|
||||
options: string[];
|
||||
};
|
||||
|
||||
export type FlatArtifactSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** 所有需程序校验的创作产物尽量收成此形 */
|
||||
export type FlatArtifact = {
|
||||
schema: typeof FLAT_ARTIFACT_SCHEMA_ID | string;
|
||||
skill: string;
|
||||
brief: string;
|
||||
mount: string[];
|
||||
stability: "stable" | "semi" | "volatile" | "";
|
||||
sections: FlatArtifactSection[];
|
||||
open_questions: string[];
|
||||
questions: FlatArtifactQuestion[];
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/** OpenAI/DeepSeek strict 友好的 JSON Schema(全 required + additionalProperties:false) */
|
||||
export const FLAT_ARTIFACT_JSON_SCHEMA: Record<string, unknown> = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
"schema",
|
||||
"skill",
|
||||
"brief",
|
||||
"mount",
|
||||
"stability",
|
||||
"sections",
|
||||
"open_questions",
|
||||
"questions",
|
||||
"summary",
|
||||
],
|
||||
properties: {
|
||||
schema: { type: "string" },
|
||||
skill: { type: "string" },
|
||||
brief: { type: "string" },
|
||||
mount: { type: "array", items: { type: "string" } },
|
||||
stability: {
|
||||
type: "string",
|
||||
enum: ["stable", "semi", "volatile", ""],
|
||||
},
|
||||
sections: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "title", "text"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
title: { type: "string" },
|
||||
text: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
open_questions: { type: "array", items: { type: "string" } },
|
||||
questions: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "prompt", "options"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
options: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
summary: { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
export function isFlatArtifact(raw: unknown): raw is FlatArtifact {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
||||
const row = raw as Record<string, unknown>;
|
||||
return Array.isArray(row.sections);
|
||||
}
|
||||
|
||||
/** 扁形 → 旧 fragment 外壳,便于现有渲染;正文按 section id 堆对象。
|
||||
* section.text 若是 JSON 对象/数组字符串,解析回结构,避免专用卡吃到整段 stringify。
|
||||
*/
|
||||
export function flatArtifactToFragmentDoc(flat: FlatArtifact): Record<string, unknown> {
|
||||
const 正文: Record<string, unknown> = {};
|
||||
for (const s of flat.sections) {
|
||||
const key = s.id || s.title;
|
||||
if (!key) continue;
|
||||
正文[key] = coerceSectionText(s.text);
|
||||
}
|
||||
return {
|
||||
schema: "context-fragment.v1",
|
||||
技能: flat.skill,
|
||||
brief: flat.brief,
|
||||
mount: flat.mount,
|
||||
...(flat.stability ? { 稳变: flat.stability } : {}),
|
||||
正文,
|
||||
开放问题: flat.open_questions,
|
||||
追问: {
|
||||
导语: "",
|
||||
题目: flat.questions.map((q) => ({
|
||||
问: q.prompt,
|
||||
建议选项: q.options,
|
||||
})),
|
||||
},
|
||||
...(flat.summary ? { summary: flat.summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** section 文本:纯散文保留;JSON 对象/数组还原为结构 */
|
||||
export function coerceSectionText(text: string): unknown {
|
||||
const raw = String(text ?? "").trim();
|
||||
if (!raw) return "";
|
||||
if (
|
||||
!(
|
||||
(raw.startsWith("{") && raw.endsWith("}")) ||
|
||||
(raw.startsWith("[") && raw.endsWith("]"))
|
||||
)
|
||||
) {
|
||||
return String(text ?? "");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return String(text ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧深树 / 任意对象 → 尽量收成扁形(保存与投递归一) */
|
||||
export function coerceToFlatArtifact(raw: unknown): FlatArtifact | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
|
||||
// DAG / worker 集 / 上下文排序:不是扁形正文稿,不能靠 brief 误收
|
||||
if (
|
||||
Array.isArray(row.steps) ||
|
||||
Array.isArray(row.workers) ||
|
||||
Array.isArray(row.agents) ||
|
||||
Array.isArray(row.slots)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isFlatArtifact(raw)) {
|
||||
return {
|
||||
schema:
|
||||
typeof row.schema === "string" ? row.schema : FLAT_ARTIFACT_SCHEMA_ID,
|
||||
skill: String(row.skill ?? row.技能 ?? ""),
|
||||
brief: String(row.brief ?? ""),
|
||||
mount: Array.isArray(row.mount)
|
||||
? row.mount.filter((x): x is string => typeof x === "string")
|
||||
: [],
|
||||
stability:
|
||||
row.stability === "stable" ||
|
||||
row.stability === "semi" ||
|
||||
row.stability === "volatile"
|
||||
? row.stability
|
||||
: row.稳变 === "stable" ||
|
||||
row.稳变 === "semi" ||
|
||||
row.稳变 === "volatile"
|
||||
? row.稳变
|
||||
: "",
|
||||
sections: (row.sections as FlatArtifactSection[]).map((s) => ({
|
||||
id: String(s?.id ?? ""),
|
||||
title: String(s?.title ?? ""),
|
||||
text: String(s?.text ?? ""),
|
||||
})),
|
||||
open_questions: Array.isArray(row.open_questions)
|
||||
? row.open_questions.filter((x): x is string => typeof x === "string")
|
||||
: Array.isArray(row.开放问题)
|
||||
? row.开放问题.filter((x): x is string => typeof x === "string")
|
||||
: [],
|
||||
questions: Array.isArray(row.questions)
|
||||
? (row.questions as FlatArtifactQuestion[]).map((q, i) => ({
|
||||
id: String(q?.id ?? `q${i + 1}`),
|
||||
prompt: String(q?.prompt ?? ""),
|
||||
options: Array.isArray(q?.options)
|
||||
? q.options.filter((x): x is string => typeof x === "string")
|
||||
: [],
|
||||
}))
|
||||
: [],
|
||||
summary: String(row.summary ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
const 正文 = row.正文 ?? row.body;
|
||||
const sections: FlatArtifactSection[] = [];
|
||||
if (正文 && typeof 正文 === "object" && !Array.isArray(正文)) {
|
||||
for (const [key, val] of Object.entries(正文 as Record<string, unknown>)) {
|
||||
sections.push({
|
||||
id: key,
|
||||
title: key,
|
||||
text:
|
||||
typeof val === "string"
|
||||
? val
|
||||
: JSON.stringify(val, null, 2),
|
||||
});
|
||||
}
|
||||
} else if (typeof 正文 === "string" && 正文.trim()) {
|
||||
sections.push({ id: "body", title: "正文", text: 正文 });
|
||||
}
|
||||
|
||||
const probe = row.追问;
|
||||
const questions: FlatArtifactQuestion[] = [];
|
||||
if (probe && typeof probe === "object" && !Array.isArray(probe)) {
|
||||
const topics = (probe as { 题目?: unknown }).题目;
|
||||
if (Array.isArray(topics)) {
|
||||
topics.forEach((t, i) => {
|
||||
if (!t || typeof t !== "object") return;
|
||||
const item = t as Record<string, unknown>;
|
||||
questions.push({
|
||||
id: `q${i + 1}`,
|
||||
prompt: String(item.问 ?? item.prompt ?? ""),
|
||||
options: Array.isArray(item.建议选项)
|
||||
? item.建议选项.filter((x): x is string => typeof x === "string")
|
||||
: Array.isArray(item.options)
|
||||
? item.options.filter((x): x is string => typeof x === "string")
|
||||
: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!sections.length) return null;
|
||||
|
||||
const stab = row.稳变 ?? row.stability;
|
||||
return {
|
||||
schema: FLAT_ARTIFACT_SCHEMA_ID,
|
||||
skill: String(row.技能 ?? row.skill ?? ""),
|
||||
brief: String(row.brief ?? ""),
|
||||
mount: Array.isArray(row.mount)
|
||||
? row.mount.filter((x): x is string => typeof x === "string")
|
||||
: [],
|
||||
stability:
|
||||
stab === "stable" || stab === "semi" || stab === "volatile" ? stab : "",
|
||||
sections,
|
||||
open_questions: Array.isArray(row.开放问题)
|
||||
? row.开放问题.filter((x): x is string => typeof x === "string")
|
||||
: Array.isArray(row.open_questions)
|
||||
? row.open_questions.filter((x): x is string => typeof x === "string")
|
||||
: [],
|
||||
questions,
|
||||
summary: String(row.summary ?? ""),
|
||||
};
|
||||
}
|
||||
@@ -475,6 +475,8 @@ export async function loadWorkerSkillWithContext(
|
||||
selectedRecipeRef?: string | null;
|
||||
/** 黑板已有产物 tag,供编排器判断哪些技能已经做过 */
|
||||
filledArtifactTags?: readonly string[];
|
||||
/** 用户点已完成节点重进 */
|
||||
inheritExisting?: boolean;
|
||||
},
|
||||
): Promise<{ worker: ParsedWorkerSkill; sharedContext: string | null; promptBody: string }> {
|
||||
const worker = await loadWorkerSkill(skillIdOrName, workerId, skillsRoot);
|
||||
@@ -526,6 +528,7 @@ export async function loadWorkerSkillWithContext(
|
||||
currentStepName: opts?.currentStepName,
|
||||
acceptedStepNames: opts?.acceptedStepNames,
|
||||
skillsRoot,
|
||||
inheritExisting: opts?.inheritExisting,
|
||||
});
|
||||
if (binding) {
|
||||
const inheritTag = binding.inheritTag?.trim() || "";
|
||||
@@ -536,8 +539,11 @@ export async function loadWorkerSkillWithContext(
|
||||
? `\n\n【程序开场】若黑板有「${CREATION_MODULE_OPENING_TAG}」,该默认问题已由程序发给用户(不经 LLM);用户首答在「用户.worker答复」。勿重复同一开场白,在其答复与提示词基础上继续追问或产出。`
|
||||
: "";
|
||||
const paramsBlock = isPriorArtifactModule(binding.module)
|
||||
? formatPriorArtifactContext(binding.step.params)
|
||||
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问「生成什么 / 调用哪个规则」)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
|
||||
? formatPriorArtifactContext(
|
||||
binding.step.params,
|
||||
binding.prototypeSuggestion,
|
||||
)
|
||||
: `## 【本步参数】(编排期已钉;直接按此执行,勿再问编排层已定的参数)\n\n${formatStepParamsForPrompt(binding.step.params)}`;
|
||||
let rosterBlock = "";
|
||||
const depProjection =
|
||||
binding.module.id === "context-order" ? "index" : undefined;
|
||||
|
||||
@@ -198,3 +198,11 @@ export function closeCreationFlowRaw(raw: string | null | undefined): string | n
|
||||
if (flow.status === "closed") return stringifyCreationFlow(flow);
|
||||
return stringifyCreationFlow({ ...flow, status: "closed" });
|
||||
}
|
||||
|
||||
/** 再编排:收口前补节点时把 DAG 改回 open。 */
|
||||
export function reopenCreationFlowRaw(raw: string | null | undefined): string | null {
|
||||
const flow = parseCreationFlow(raw);
|
||||
if (!flow) return raw?.trim() ? raw : null;
|
||||
if (flow.status !== "closed") return stringifyCreationFlow(flow);
|
||||
return stringifyCreationFlow({ ...flow, status: "open" });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
||||
import {
|
||||
inferPlaySlotsFromWorkers,
|
||||
mergeWorkersWithPlaySlots,
|
||||
@@ -400,6 +401,10 @@ export function parseWorkerSetYaml(raw: string | undefined): ParsedWorkerSet | n
|
||||
|
||||
/** 从纯 JSON、```json 围栏或夹杂说明的文本中抽出对象字面量 */
|
||||
export function extractJsonObjectText(raw: string): string | null {
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed != null && typeof parsed === "object") {
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
if (text.startsWith("{")) {
|
||||
|
||||
@@ -72,9 +72,16 @@ export function buildContextTrace(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** 编排检查点:不随「只留最新 N 条」丢掉,否则开场白阶段无法回看/回退到流程编排 */
|
||||
export function isPinnedContextTraceCaller(caller: string | undefined): boolean {
|
||||
const id = (caller ?? "").trim();
|
||||
return id === "worker:design-flow" || id.endsWith(":design-flow");
|
||||
}
|
||||
|
||||
/**
|
||||
* 只保留「带 contextTrace 的消息」中最新 keepLatest 条的全文;
|
||||
* 更早的消息删除 contextTrace 字段(消息本身保留)。
|
||||
* design-flow 痕迹钉住不删(keep=0 仍全部清除)。
|
||||
*/
|
||||
export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>(
|
||||
messages: T[],
|
||||
@@ -88,12 +95,15 @@ export function pruneContextTraces<T extends { contextTrace?: LlmContextTrace }>
|
||||
return rest as T;
|
||||
});
|
||||
}
|
||||
const withTraceIdx: number[] = [];
|
||||
const droppableIdx: number[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].contextTrace) withTraceIdx.push(i);
|
||||
const trace = messages[i].contextTrace;
|
||||
if (!trace) continue;
|
||||
if (isPinnedContextTraceCaller(trace.caller)) continue;
|
||||
droppableIdx.push(i);
|
||||
}
|
||||
if (withTraceIdx.length <= keep) return messages;
|
||||
const drop = new Set(withTraceIdx.slice(0, withTraceIdx.length - keep));
|
||||
if (droppableIdx.length <= keep) return messages;
|
||||
const drop = new Set(droppableIdx.slice(0, droppableIdx.length - keep));
|
||||
return messages.map((m, i) => {
|
||||
if (!drop.has(i) || !m.contextTrace) return m;
|
||||
const { contextTrace: _drop, ...rest } = m as T & { contextTrace?: LlmContextTrace };
|
||||
|
||||
+30
-1
@@ -60,6 +60,13 @@ export type WaitingReason =
|
||||
kind: "next_intent";
|
||||
afterWorkerId?: string;
|
||||
afterUnitId?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* DAG 已在:按依赖层级点选节点。依赖未齐的不能点;
|
||||
* 可反复能力以 role=prototype 槽位呈现;点原型增殖 instance 再进去。
|
||||
*/
|
||||
kind: "pick_creation_step";
|
||||
};
|
||||
|
||||
/** 与 src/skills/types 对齐的最小 skill 索引字段,避免 runtime 强依赖 skills 模块 */
|
||||
@@ -232,6 +239,26 @@ export type RuntimeEvent =
|
||||
type: "user_rejected_next_step";
|
||||
payload: { decisionId: string; reason?: string };
|
||||
}
|
||||
| {
|
||||
/** 回到流程编排:在已验收步骤上追加/改排节点(不整局重开) */
|
||||
type: "user_requested_flow_replan";
|
||||
payload: { reason?: string };
|
||||
}
|
||||
| {
|
||||
/** DAG 图上点选一条已就绪的步骤,进入 design-step */
|
||||
type: "user_picked_creation_step";
|
||||
payload: { stepId: string };
|
||||
}
|
||||
| {
|
||||
/** 验收后 / 总管误调 design-step:停在分层图上等用户点选 */
|
||||
type: "creation_step_pick_awaited";
|
||||
payload?: Record<string, never>;
|
||||
}
|
||||
| {
|
||||
/** 离开当前技能步,回到分层图点选(误点增殖节点时用) */
|
||||
type: "user_left_creation_step";
|
||||
payload?: Record<string, never>;
|
||||
}
|
||||
| {
|
||||
type: "worker_started";
|
||||
payload: {
|
||||
@@ -331,10 +358,12 @@ export type PhaseEffect =
|
||||
| { type: "resume_worker" }
|
||||
| { type: "run_programmatic_review"; artifactId: string }
|
||||
| { type: "emit_message"; message: string }
|
||||
/** 根据流程与用户下一步意向,提案下一节点并进入确认 */
|
||||
/** 根据流程与用户下一步意向,提案下一节点或停在分层图上等点选 */
|
||||
| { type: "propose_next_creation_step" }
|
||||
/** 选定开场白:落库、关 DAG、标记创作收口 */
|
||||
| { type: "seal_creation_opening" }
|
||||
/** 再编排:解开开场收口、把 DAG 改回 open */
|
||||
| { type: "unseal_creation_opening" }
|
||||
/** 游玩:按运行规格开一轮(旁观 → 主世界层 → 转述) */
|
||||
| { type: "run_play_turn" }
|
||||
/** 游玩:跑管线里的下一个执行单元;队空则等下一条用户输入 */
|
||||
|
||||
+126
-63
@@ -1,6 +1,7 @@
|
||||
import type { Blackboard } from "../blackboard/blackboard.js";
|
||||
import type { LlmProvider, StreamCallbacks, ChatMessage } from "../llm/client.js";
|
||||
import { supportsContentStream } from "../llm/stream-complete.js";
|
||||
import { completeStructured } from "../llm/structured-complete.js";
|
||||
import { resolveActiveProfile } from "../config/settings.js";
|
||||
import { loadWorkerSkillWithContext } from "../skills/loader.js";
|
||||
import { filterInputsForRolePerspective } from "../skills/worker-llm.js";
|
||||
import { collectUserInputTranscript } from "../intake/intake.js";
|
||||
@@ -14,6 +15,10 @@ import {
|
||||
looksLikeProseNotSpec,
|
||||
parseWorkerSetYaml,
|
||||
} from "../skills/worker-set-parse.js";
|
||||
import {
|
||||
selectJsonPayload,
|
||||
tryParseJsonDoc,
|
||||
} from "../parse/json-doc.js";
|
||||
import { assembleWorkerContext } from "../skills/context-segments.js";
|
||||
import { isPlayLayerActive } from "../skills/play-turn.js";
|
||||
import { loadAppSettings } from "../config/settings.js";
|
||||
@@ -21,6 +26,10 @@ import { resolveActivePreset } from "../preset/store.js";
|
||||
import { assemblePlayWorkerMessages } from "../preset/play-frame.js";
|
||||
import { worldInfoPackFromSegments } from "../preset/world-info-pack.js";
|
||||
import { PRESENT_TAG } from "../skills/present-packet.js";
|
||||
import {
|
||||
coerceToFlatArtifact,
|
||||
flatArtifactToFragmentDoc,
|
||||
} from "../skills/flat-artifact.js";
|
||||
import {
|
||||
gmChanceToolsEnabled,
|
||||
GM_CHANCE_HARNESS_INSTRUCTION,
|
||||
@@ -36,7 +45,10 @@ import {
|
||||
isUsableContextFragment,
|
||||
looksLikeFragmentDoc,
|
||||
} from "../skills/context-fragment.js";
|
||||
import { isProgressPointerTag } from "../skills/creation-flow.js";
|
||||
import {
|
||||
isProgressPointerTag,
|
||||
looksLikeCreationFlowDoc,
|
||||
} from "../skills/creation-flow.js";
|
||||
import {
|
||||
CONTEXT_ORDER_TAG,
|
||||
parseContextOrder,
|
||||
@@ -93,28 +105,19 @@ const WORKER_OUTPUT_INSTRUCTION = `
|
||||
|
||||
---
|
||||
|
||||
## 运行时输出协议(必须遵守)
|
||||
## 运行时输出协议
|
||||
|
||||
请输出 **单个 JSON 对象**(不要 markdown 代码块),字段:
|
||||
{
|
||||
"outputs": { "<outputTag>": "<内容字符串>" },
|
||||
"summary": "50字以内产物摘要",
|
||||
"askUser": null 或 问题数组
|
||||
}
|
||||
输出 **一个 JSON 对象**(由程序按模型能力用 schema / tool / json_object 约束投递)。
|
||||
|
||||
askUser 每项可为:
|
||||
- 字符串:"需要用户补充的问题"
|
||||
- 或结构化:{ "id": "q1", "prompt": "问题", "options": [{ "id": "A", "label": "可编辑完整句选项" }], "allowOther": true }
|
||||
按本步 SKILL 规定的产物形状写。有正文的技能稿按该步 \`正文\` 对象写(柱下到条目/诊断三元组),不要把业务字段改成一段 \`sections[].text\`。
|
||||
「设计.创作流程」必须是带 \`steps\` 数组的 DAG,worker 集必须是带 \`workers\` 的规格。
|
||||
|
||||
- outputs 的 key 必须是要求的 outputTags
|
||||
- **设计.worker集 / 设计.worker集.草稿**:value 必须是 JSON 对象文本(以 { 开头),禁止中文说明、元叙述、提问长文;禁止 YAML
|
||||
- **context-fragment.v1**:必须放在 outputs["<本步 artifact tag>"],禁止把片段当根对象。题目只写在产物「追问」(建议选项 + 示例);顶层 askUser 必须为 **null**。程序会把「追问」挂到询问卡。禁止同一问再抄一份 askUser。禁止写入「创作.当前步骤」等进度指针
|
||||
- **其它产物**:优先同时给 outputs + askUser;有产物时追问挂在产物下(用户可直接接受而不作答)
|
||||
- **残稿也要交**:JSON 不完美、字段不全、截断,只要有可展示正文就必须写入 outputs,交给用户验收(可打回重生成,也可接着改)。不要因为渲染不出就把稿扔掉改成提问
|
||||
- 仅当完全没有可展示正文、只有提问时,才留空 outputs、只填 askUser
|
||||
- 能推断选项时 **必须**给 options(完整句、可改写);不要只丢裸问题逼用户写长段
|
||||
- 若 inputs 中 \`用户.需求\` / \`用户.博弈需求\`(或 book.brief)已有实质内容,禁止 askUser 要求用户重复提供其中已写明的情境、角色、规则等;仅对 genuinely 缺失且无法推断的要点提问
|
||||
- summary 用于界面展示`;
|
||||
也兼容:
|
||||
1. 直接输出本步产物对象(创作流程 / context-fragment.v1 / worker 集等)
|
||||
2. \`{ "outputs": { "<本步 tag>": { … } }, "summary":"…", "askUser": null }\`(values 用对象)
|
||||
|
||||
不要写入进度指针。追问用 \`questions\`(或旧 \`追问.题目\`);\`questions: []\` 表示可验收。
|
||||
「开放问题」留给后续步。残稿也要交。summary 用于界面展示。`;
|
||||
|
||||
function slotValueForTag(
|
||||
tag: string,
|
||||
@@ -193,6 +196,13 @@ function assignOutput(
|
||||
}
|
||||
|
||||
/** 模型常把片段当根对象,或写进「创作.当前步骤」 */
|
||||
function existingOutputIsProduct(text: string | undefined): boolean {
|
||||
if (!text?.trim()) return false;
|
||||
if (isUsableContextFragment(text)) return true;
|
||||
const parsed = tryParseJsonDoc(text);
|
||||
return looksLikeCreationFlowDoc(parsed) || looksLikeFragmentDoc(parsed);
|
||||
}
|
||||
|
||||
function recoverFragmentOutputs(
|
||||
obj: Record<string, unknown>,
|
||||
outputs: Record<string, string>,
|
||||
@@ -200,7 +210,7 @@ function recoverFragmentOutputs(
|
||||
): void {
|
||||
const target = productOutputTags(outputTags)[0];
|
||||
if (!target) return;
|
||||
if (outputs[target] && isUsableContextFragment(outputs[target])) return;
|
||||
if (existingOutputIsProduct(outputs[target])) return;
|
||||
|
||||
const outputsRaw =
|
||||
obj.outputs && typeof obj.outputs === "object" && !Array.isArray(obj.outputs)
|
||||
@@ -212,7 +222,11 @@ function recoverFragmentOutputs(
|
||||
if (key === target) continue;
|
||||
const text = stringifyOutputValue(val);
|
||||
if (!text) continue;
|
||||
if (isUsableContextFragment(text) || looksLikeFragmentDoc(val)) {
|
||||
if (
|
||||
isUsableContextFragment(text) ||
|
||||
looksLikeFragmentDoc(val) ||
|
||||
looksLikeCreationFlowDoc(val)
|
||||
) {
|
||||
outputs[target] = text;
|
||||
return;
|
||||
}
|
||||
@@ -227,6 +241,13 @@ function recoverFragmentOutputs(
|
||||
if (looksLikeFragmentDoc(obj) || looksLikeFragmentDoc(rest)) {
|
||||
const source = looksLikeFragmentDoc(obj) ? obj : rest;
|
||||
outputs[target] = JSON.stringify(source);
|
||||
return;
|
||||
}
|
||||
if (looksLikeCreationFlowDoc(obj) || looksLikeCreationFlowDoc(rest)) {
|
||||
const source = looksLikeCreationFlowDoc(obj) ? obj : rest;
|
||||
if (!outputs[target]?.trim()) {
|
||||
outputs[target] = JSON.stringify(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,14 +304,26 @@ const INCOMPLETE_FRAGMENT_ASK = normalizeQuestions([
|
||||
},
|
||||
]);
|
||||
|
||||
function lookupOutputValue(
|
||||
raw: Record<string, unknown>,
|
||||
tag: string,
|
||||
): unknown {
|
||||
if (raw[tag] != null) return raw[tag];
|
||||
const leaf = tag.includes(".") ? tag.slice(tag.lastIndexOf(".") + 1) : tag;
|
||||
if (!leaf) return undefined;
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (val == null) continue;
|
||||
if (key === leaf || key.endsWith(`.${leaf}`)) return val;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseWorkerResponse(
|
||||
raw: string,
|
||||
outputTags: string[],
|
||||
): WorkerRunResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
|
||||
if (target && looksLikeStructuredDraft(raw)) {
|
||||
return finalizeParsedOutputs(
|
||||
@@ -322,17 +355,43 @@ function parseWorkerResponse(
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Worker 返回了无效 JSON");
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// 创作流程 DAG:根对象就是产物,不能先收成扁形(brief 会被当成稿、steps 丢掉)
|
||||
if (looksLikeCreationFlowDoc(obj)) {
|
||||
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
|
||||
if (target) {
|
||||
return finalizeParsedOutputs(
|
||||
{ [target]: JSON.stringify(obj) },
|
||||
obj,
|
||||
outputTags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 扁形产物 → 旧 fragment 外壳。已是 context-fragment 深树则原样收下(保留自评与嵌套正文)。
|
||||
if (!looksLikeFragmentDoc(obj)) {
|
||||
const asFlat = coerceToFlatArtifact(obj);
|
||||
if (asFlat) {
|
||||
const frag = flatArtifactToFragmentDoc(asFlat);
|
||||
const target = productOutputTags(outputTags)[0] ?? outputTags[0];
|
||||
if (target) {
|
||||
return finalizeParsedOutputs(
|
||||
{ [target]: JSON.stringify(frag) },
|
||||
{ ...frag, summary: asFlat.summary || obj.summary },
|
||||
outputTags,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const outputsRaw = obj.outputs;
|
||||
const outputs: Record<string, string> = {};
|
||||
|
||||
if (outputsRaw && typeof outputsRaw === "object") {
|
||||
if (outputsRaw && typeof outputsRaw === "object" && !Array.isArray(outputsRaw)) {
|
||||
const bag = outputsRaw as Record<string, unknown>;
|
||||
for (const tag of productOutputTags(outputTags)) {
|
||||
assignOutput(outputs, tag, (outputsRaw as Record<string, unknown>)[tag]);
|
||||
assignOutput(outputs, tag, lookupOutputValue(bag, tag));
|
||||
}
|
||||
}
|
||||
recoverFragmentOutputs(obj, outputs, outputTags);
|
||||
@@ -484,25 +543,6 @@ function extractQuestionsFromText(text: string): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async function completeWorkerPreferStream(
|
||||
llm: LlmProvider,
|
||||
messages: Parameters<LlmProvider["complete"]>[0],
|
||||
options: Parameters<LlmProvider["complete"]>[1],
|
||||
stream?: WorkerStreamCallbacks,
|
||||
): Promise<Awaited<ReturnType<LlmProvider["complete"]>>> {
|
||||
const callbacks: StreamCallbacks = {
|
||||
onReasoningDelta: (delta) => stream?.onThinkingDelta?.(delta),
|
||||
onContentDelta: (delta) => stream?.onOutputDelta?.(delta),
|
||||
};
|
||||
if (supportsContentStream(llm)) {
|
||||
return llm.completeStream!(messages, options, callbacks);
|
||||
}
|
||||
const result = await llm.complete(messages, options);
|
||||
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
|
||||
if (result.content) callbacks.onContentDelta?.(result.content);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRunResult> {
|
||||
const workerId = resolveWorkerId(params.workerId);
|
||||
let worker: ParsedWorkerSkill;
|
||||
@@ -519,9 +559,15 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
params.slots.creationAcceptedUnits ??
|
||||
params.blackboard.getContentByTag("创作.已验收单位");
|
||||
let acceptedStepNames: string[] = [];
|
||||
let inheritExisting = false;
|
||||
try {
|
||||
const { parseAcceptedSteps } = await import("../skills/creation-flow.js");
|
||||
const { parseAcceptedSteps, isInheritExistingFlag } = await import(
|
||||
"../skills/creation-flow.js"
|
||||
);
|
||||
acceptedStepNames = parseAcceptedSteps(acceptedRaw);
|
||||
inheritExisting = isInheritExistingFlag(
|
||||
params.blackboard.getContentByTag("创作.继承修改"),
|
||||
);
|
||||
} catch {
|
||||
acceptedStepNames = [];
|
||||
}
|
||||
@@ -538,6 +584,7 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
acceptedStepNames,
|
||||
selectedRecipeRef,
|
||||
filledArtifactTags,
|
||||
inheritExisting,
|
||||
},
|
||||
);
|
||||
worker = loaded.worker;
|
||||
@@ -616,20 +663,36 @@ export async function runWorkerSkill(params: WorkerRunParams): Promise<WorkerRun
|
||||
stream: params.stream,
|
||||
caller: `worker:${worker.id}`,
|
||||
});
|
||||
return parseWorkerResponse(harness.content, worker.outputTags);
|
||||
return parseWorkerResponse(selectJsonPayload(harness.content), worker.outputTags);
|
||||
}
|
||||
|
||||
const result = await completeWorkerPreferStream(
|
||||
params.llm,
|
||||
messages,
|
||||
{
|
||||
responseFormat: "json_object",
|
||||
caller: `worker:${worker.id}`,
|
||||
},
|
||||
params.stream,
|
||||
);
|
||||
const streamCallbacks: StreamCallbacks | undefined = params.stream
|
||||
? {
|
||||
onReasoningDelta: (delta) => params.stream?.onThinkingDelta?.(delta),
|
||||
onContentDelta: (delta) => params.stream?.onOutputDelta?.(delta),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return parseWorkerResponse(result.content, worker.outputTags);
|
||||
const result = await completeStructured(params.llm, messages, {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
},
|
||||
name: "submit_worker_result",
|
||||
schemaIsLoose: true,
|
||||
capabilities: resolveActiveProfile()?.capabilities,
|
||||
caller: `worker:${worker.id}`,
|
||||
stream: streamCallbacks,
|
||||
});
|
||||
|
||||
const raw =
|
||||
typeof result.parsed === "string"
|
||||
? result.parsed
|
||||
: JSON.stringify(result.parsed);
|
||||
return parseWorkerResponse(
|
||||
selectJsonPayload(raw, result.reasoning) || raw,
|
||||
worker.outputTags,
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal 供单测 */
|
||||
|
||||
Reference in New Issue
Block a user