mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 23:02:03 +08:00
feat(studio): server responses follow project language
服务端用户可见错误/提示与管线阶段、agent/工具标签按项目语言返回 (每次请求读取配置,切换语言即时生效);产线端点透传 language
This commit is contained in:
@@ -1556,6 +1556,68 @@ describe("createStudioServer daemon lifecycle", () => {
|
||||
expect(chatCompletionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns English probe errors when the project language is en", async () => {
|
||||
await writeFile(join(root, "inkos.json"), JSON.stringify({
|
||||
...projectConfig,
|
||||
language: "en",
|
||||
llm: {
|
||||
configSource: "env",
|
||||
services: [
|
||||
{ service: "custom", name: "MiniMax", baseUrl: "https://api.minimax.com/v1" },
|
||||
],
|
||||
},
|
||||
}, null, 2), "utf-8");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "404 page not found",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as typeof fetch);
|
||||
|
||||
const { createStudioServer } = await import("./server.js");
|
||||
const app = createStudioServer(cloneProjectConfig() as never, root);
|
||||
|
||||
const response = await app.request("http://localhost/api/v1/services/custom%3AMiniMax/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
apiKey: "sk-minimax",
|
||||
baseUrl: "https://api.minimax.com/v1",
|
||||
apiFormat: "chat",
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining("Could not determine a model automatically"),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an English empty-API-key error when the project language is en", async () => {
|
||||
await writeFile(join(root, "inkos.json"), JSON.stringify({
|
||||
...projectConfig,
|
||||
language: "en",
|
||||
}, null, 2), "utf-8");
|
||||
|
||||
const { createStudioServer } = await import("./server.js");
|
||||
const app = createStudioServer(cloneProjectConfig() as never, root);
|
||||
|
||||
const response = await app.request("http://localhost/api/v1/services/openai/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ apiKey: "" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: "API Key must not be empty",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the detected/default model when custom /models is unavailable", async () => {
|
||||
await writeFile(join(root, "inkos.json"), JSON.stringify({
|
||||
...projectConfig,
|
||||
|
||||
+302
-103
@@ -117,51 +117,91 @@ import { isSafeBookId } from "./safety.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { buildStudioBookConfig } from "./book-create.js";
|
||||
|
||||
// -- Studio server language (read per request from the project config's `language`) --
|
||||
|
||||
type StudioLanguage = "zh" | "en";
|
||||
|
||||
function normalizeStudioLanguage(value: unknown): StudioLanguage {
|
||||
return value === "en" ? "en" : "zh";
|
||||
}
|
||||
|
||||
function pick(lang: StudioLanguage, zh: string, en: string): string {
|
||||
return lang === "en" ? en : zh;
|
||||
}
|
||||
|
||||
// -- Pipeline stage definitions per agent type --
|
||||
|
||||
const PIPELINE_STAGES: Record<string, string[]> = {
|
||||
interface BilingualLabel {
|
||||
readonly zh: string;
|
||||
readonly en: string;
|
||||
}
|
||||
|
||||
const PIPELINE_STAGES: Record<string, ReadonlyArray<BilingualLabel>> = {
|
||||
writer: [
|
||||
"准备章节输入", "撰写章节草稿", "落盘最终章节",
|
||||
"生成最终真相文件", "校验真相文件变更", "同步记忆索引",
|
||||
"更新章节索引与快照",
|
||||
{ zh: "准备章节输入", en: "Prepare chapter input" },
|
||||
{ zh: "撰写章节草稿", en: "Write chapter draft" },
|
||||
{ zh: "落盘最终章节", en: "Save final chapter" },
|
||||
{ zh: "生成最终真相文件", en: "Generate final truth files" },
|
||||
{ zh: "校验真相文件变更", en: "Validate truth file changes" },
|
||||
{ zh: "同步记忆索引", en: "Sync memory index" },
|
||||
{ zh: "更新章节索引与快照", en: "Update chapter index and snapshot" },
|
||||
],
|
||||
architect: [
|
||||
"生成基础设定", "保存书籍配置", "写入基础设定文件",
|
||||
"初始化控制文档", "创建初始快照",
|
||||
{ zh: "生成基础设定", en: "Generate foundation" },
|
||||
{ zh: "保存书籍配置", en: "Save book config" },
|
||||
{ zh: "写入基础设定文件", en: "Write foundation files" },
|
||||
{ zh: "初始化控制文档", en: "Initialize control documents" },
|
||||
{ zh: "创建初始快照", en: "Create initial snapshot" },
|
||||
],
|
||||
reviser: [
|
||||
"加载修订上下文", "修订章节", "落盘修订结果",
|
||||
"更新索引与快照",
|
||||
{ zh: "加载修订上下文", en: "Load revision context" },
|
||||
{ zh: "修订章节", en: "Revise chapter" },
|
||||
{ zh: "落盘修订结果", en: "Save revision result" },
|
||||
{ zh: "更新索引与快照", en: "Update index and snapshot" },
|
||||
],
|
||||
auditor: ["审计章节"],
|
||||
auditor: [{ zh: "审计章节", en: "Audit chapter" }],
|
||||
};
|
||||
|
||||
function pipelineStages(agent: string, lang: StudioLanguage = "zh"): string[] | undefined {
|
||||
return PIPELINE_STAGES[agent]?.map((stage) => pick(lang, stage.zh, stage.en));
|
||||
}
|
||||
|
||||
function attachmentDisposition(fileName: string): string {
|
||||
const safeAscii = fileName.replace(/[^A-Za-z0-9._-]+/g, "_") || "download";
|
||||
return `attachment; filename="${safeAscii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
architect: "建书", writer: "写作", auditor: "审计",
|
||||
reviser: "修订", exporter: "导出",
|
||||
const AGENT_LABELS: Record<string, BilingualLabel> = {
|
||||
architect: { zh: "建书", en: "Book setup" },
|
||||
writer: { zh: "写作", en: "Writing" },
|
||||
auditor: { zh: "审计", en: "Audit" },
|
||||
reviser: { zh: "修订", en: "Revision" },
|
||||
exporter: { zh: "导出", en: "Export" },
|
||||
};
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
|
||||
propose_action: "确认动作",
|
||||
short_fiction_run: "短篇生产",
|
||||
script_create: "剧本创作",
|
||||
storyboard_create: "分镜创作",
|
||||
interactive_film_create: "互动影游",
|
||||
generate_cover: "生成封面",
|
||||
play_edit: "编辑互动世界",
|
||||
play_start: "启动互动世界",
|
||||
play_revise: "重做互动回合",
|
||||
play_step: "推进互动世界",
|
||||
const TOOL_LABELS: Record<string, BilingualLabel> = {
|
||||
read: { zh: "读取文件", en: "Read file" },
|
||||
edit: { zh: "编辑文件", en: "Edit file" },
|
||||
grep: { zh: "搜索", en: "Search" },
|
||||
ls: { zh: "列目录", en: "List directory" },
|
||||
propose_action: { zh: "确认动作", en: "Confirm action" },
|
||||
short_fiction_run: { zh: "短篇生产", en: "Short fiction" },
|
||||
script_create: { zh: "剧本创作", en: "Script creation" },
|
||||
storyboard_create: { zh: "分镜创作", en: "Storyboard creation" },
|
||||
interactive_film_create: { zh: "互动影游", en: "Interactive film" },
|
||||
generate_cover: { zh: "生成封面", en: "Cover generation" },
|
||||
play_edit: { zh: "编辑互动世界", en: "Edit interactive world" },
|
||||
play_start: { zh: "启动互动世界", en: "Start interactive world" },
|
||||
play_revise: { zh: "重做互动回合", en: "Redo interactive turn" },
|
||||
play_step: { zh: "推进互动世界", en: "Advance interactive world" },
|
||||
};
|
||||
|
||||
function resolveToolLabel(tool: string, agent?: string): string {
|
||||
if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
|
||||
return TOOL_LABELS[tool] ?? tool;
|
||||
function resolveToolLabel(tool: string, agent?: string, lang: StudioLanguage = "zh"): string {
|
||||
if (tool === "sub_agent" && agent) {
|
||||
const label = AGENT_LABELS[agent];
|
||||
return label ? pick(lang, label.zh, label.en) : agent;
|
||||
}
|
||||
const label = TOOL_LABELS[tool];
|
||||
return label ? pick(lang, label.zh, label.en) : tool;
|
||||
}
|
||||
|
||||
function summarizeResult(result: unknown): string {
|
||||
@@ -306,8 +346,12 @@ function normalizeApiBookId(value: unknown, fieldName: string): string | null {
|
||||
return bookId;
|
||||
}
|
||||
|
||||
function nonTextModelMessage(modelId: string): string {
|
||||
return `模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`;
|
||||
function nonTextModelMessage(modelId: string, lang: StudioLanguage = "zh"): string {
|
||||
return pick(
|
||||
lang,
|
||||
`模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`,
|
||||
`Model ${modelId} is not suitable for text chat/writing. Pick a text model in the model selector, e.g. gemini-2.5-flash, gemini-2.5-pro, or the service's chat model.`,
|
||||
);
|
||||
}
|
||||
|
||||
function extractToolError(result: unknown): string {
|
||||
@@ -996,10 +1040,17 @@ function validateAgentActionExecution(args: {
|
||||
readonly agentBookId: string | null | undefined;
|
||||
readonly requestedIntent?: RequestedIntent;
|
||||
readonly collectedToolExecs: ReadonlyArray<CollectedToolExec>;
|
||||
readonly language?: StudioLanguage;
|
||||
}): string | undefined {
|
||||
const lang = args.language ?? "zh";
|
||||
const failedExec = args.collectedToolExecs.find(isLikelyFailedToolResult);
|
||||
if (failedExec) {
|
||||
return `${failedExec.label} 执行失败:${failedExec.error ?? failedExec.result ?? "未知错误"}`;
|
||||
const detail = failedExec.error ?? failedExec.result ?? pick(lang, "未知错误", "unknown error");
|
||||
return pick(
|
||||
lang,
|
||||
`${failedExec.label} 执行失败:${detail}`,
|
||||
`${failedExec.label} failed: ${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1007,7 +1058,11 @@ function validateAgentActionExecution(args: {
|
||||
&& args.requestedIntent === "write_next"
|
||||
&& !hasSuccessfulSubAgentExec(args.collectedToolExecs, "writer")
|
||||
) {
|
||||
return "模型声称已完成下一章,但没有实际调用写作工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
|
||||
return pick(
|
||||
lang,
|
||||
"模型声称已完成下一章,但没有实际调用写作工具。请重试;如果仍失败,请检查模型是否支持工具调用。",
|
||||
"The model claimed the next chapter is done, but it never called the writing tool. Retry; if it keeps failing, check whether the model supports tool calls.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1015,19 +1070,35 @@ function validateAgentActionExecution(args: {
|
||||
&& args.requestedIntent === "create_book"
|
||||
&& !hasSuccessfulSubAgentExec(args.collectedToolExecs, "architect")
|
||||
) {
|
||||
return "已确认建书,但模型没有实际调用建书工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
|
||||
return pick(
|
||||
lang,
|
||||
"已确认建书,但模型没有实际调用建书工具。请重试;如果仍失败,请检查模型是否支持工具调用。",
|
||||
"Book creation was confirmed, but the model never called the book setup tool. Retry; if it keeps failing, check whether the model supports tool calls.",
|
||||
);
|
||||
}
|
||||
|
||||
if (args.requestedIntent === "short_run" && !hasSuccessfulToolExec(args.collectedToolExecs, "short_fiction_run")) {
|
||||
return "已确认生成短篇,但模型没有实际调用短篇生产工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
|
||||
return pick(
|
||||
lang,
|
||||
"已确认生成短篇,但模型没有实际调用短篇生产工具。请重试;如果仍失败,请检查模型是否支持工具调用。",
|
||||
"Short fiction was confirmed, but the model never called the short fiction tool. Retry; if it keeps failing, check whether the model supports tool calls.",
|
||||
);
|
||||
}
|
||||
|
||||
if (args.requestedIntent === "play_start" && !hasSuccessfulToolExec(args.collectedToolExecs, "play_start")) {
|
||||
return "已确认启动互动世界,但模型没有实际调用互动世界工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
|
||||
return pick(
|
||||
lang,
|
||||
"已确认启动互动世界,但模型没有实际调用互动世界工具。请重试;如果仍失败,请检查模型是否支持工具调用。",
|
||||
"Starting the interactive world was confirmed, but the model never called the interactive world tool. Retry; if it keeps failing, check whether the model supports tool calls.",
|
||||
);
|
||||
}
|
||||
|
||||
if (args.requestedIntent === "generate_cover" && !hasSuccessfulToolExec(args.collectedToolExecs, "generate_cover")) {
|
||||
return "已确认生成封面,但模型没有实际调用封面工具。请重试;如果仍失败,请检查模型是否支持工具调用。";
|
||||
return pick(
|
||||
lang,
|
||||
"已确认生成封面,但模型没有实际调用封面工具。请重试;如果仍失败,请检查模型是否支持工具调用。",
|
||||
"Cover generation was confirmed, but the model never called the cover tool. Retry; if it keeps failing, check whether the model supports tool calls.",
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -1051,13 +1122,20 @@ function classifyAgentFailure(message: string): AgentFailureKind {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatAgentFailure(message: string): { readonly code: string; readonly message: string; readonly status: 500 | 502 } {
|
||||
function formatAgentFailure(
|
||||
message: string,
|
||||
lang: StudioLanguage = "zh",
|
||||
): { readonly code: string; readonly message: string; readonly status: 500 | 502 } {
|
||||
const kind = classifyAgentFailure(message);
|
||||
if (kind === "llm") {
|
||||
return { code: "AGENT_LLM_ERROR", message, status: 502 };
|
||||
}
|
||||
if (kind === "internal") {
|
||||
return { code: "AGENT_INTERNAL_ERROR", message: `InkOS 内部流程错误:${message}`, status: 500 };
|
||||
return {
|
||||
code: "AGENT_INTERNAL_ERROR",
|
||||
message: pick(lang, `InkOS 内部流程错误:${message}`, `InkOS internal pipeline error: ${message}`),
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
return { code: "AGENT_ERROR", message, status: 500 };
|
||||
}
|
||||
@@ -1161,9 +1239,9 @@ function requirePayloadText(value: string | undefined, message: string): string
|
||||
return text;
|
||||
}
|
||||
|
||||
function toolResultText(result: unknown): string {
|
||||
function toolResultText(result: unknown, lang: StudioLanguage = "zh"): string {
|
||||
const text = extractToolError(result).trim();
|
||||
return text || "已完成。";
|
||||
return text || pick(lang, "已完成。", "Done.");
|
||||
}
|
||||
|
||||
async function executeConfirmedProductionAction(args: {
|
||||
@@ -1176,7 +1254,9 @@ async function executeConfirmedProductionAction(args: {
|
||||
readonly requestedIntent: RequestedIntent;
|
||||
readonly actionPayload?: ActionPayload;
|
||||
readonly playMode?: PlayMode;
|
||||
readonly language?: StudioLanguage;
|
||||
}): Promise<CollectedToolExec> {
|
||||
const lang = args.language ?? "zh";
|
||||
const id = `direct-${args.requestedIntent}-${Date.now().toString(36)}`;
|
||||
const actionPayload = args.actionPayload;
|
||||
let tool: ReturnType<typeof createSubAgentTool>
|
||||
@@ -1194,7 +1274,7 @@ async function executeConfirmedProductionAction(args: {
|
||||
|
||||
if (args.requestedIntent === "create_book") {
|
||||
const payload = actionPayload?.createBook;
|
||||
const title = requirePayloadText(payload?.title, "确认建书缺少书名,请重新生成确认卡。");
|
||||
const title = requirePayloadText(payload?.title, pick(lang, "确认建书缺少书名,请重新生成确认卡。", "The book creation confirmation is missing a title. Regenerate the confirmation card."));
|
||||
tool = createSubAgentTool(args.pipeline, null, args.root, { actionPayload });
|
||||
agent = "architect";
|
||||
params = {
|
||||
@@ -1210,7 +1290,7 @@ async function executeConfirmedProductionAction(args: {
|
||||
} else if (args.requestedIntent === "short_run") {
|
||||
const payload = actionPayload?.shortRun;
|
||||
const direction = payload?.direction?.trim() || args.instruction.trim();
|
||||
if (!direction) throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", "确认短篇缺少方向,请重新生成确认卡。");
|
||||
if (!direction) throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", pick(lang, "确认短篇缺少方向,请重新生成确认卡。", "The short fiction confirmation is missing a direction. Regenerate the confirmation card."));
|
||||
tool = createShortFictionRunTool(args.pipeline, args.root, { actionPayload });
|
||||
params = {
|
||||
direction,
|
||||
@@ -1222,7 +1302,7 @@ async function executeConfirmedProductionAction(args: {
|
||||
};
|
||||
} else if (args.requestedIntent === "generate_cover") {
|
||||
const payload = actionPayload?.generateCover;
|
||||
const title = requirePayloadText(payload?.title, "确认生成封面缺少标题,请重新生成确认卡。");
|
||||
const title = requirePayloadText(payload?.title, pick(lang, "确认生成封面缺少标题,请重新生成确认卡。", "The cover generation confirmation is missing a title. Regenerate the confirmation card."));
|
||||
tool = createGenerateCoverTool(args.root, { actionPayload });
|
||||
params = {
|
||||
title,
|
||||
@@ -1233,7 +1313,7 @@ async function executeConfirmedProductionAction(args: {
|
||||
};
|
||||
} else if (args.requestedIntent === "script_create") {
|
||||
const payload = actionPayload?.scriptCreate;
|
||||
const title = requirePayloadText(payload?.title, "确认创建剧本缺少标题,请重新生成确认卡。");
|
||||
const title = requirePayloadText(payload?.title, pick(lang, "确认创建剧本缺少标题,请重新生成确认卡。", "The script creation confirmation is missing a title. Regenerate the confirmation card."));
|
||||
tool = createScriptCreationTool(args.pipeline, args.root, { actionPayload });
|
||||
params = {
|
||||
title,
|
||||
@@ -1250,7 +1330,7 @@ async function executeConfirmedProductionAction(args: {
|
||||
};
|
||||
} else if (args.requestedIntent === "storyboard_create") {
|
||||
const payload = actionPayload?.storyboardCreate;
|
||||
const title = requirePayloadText(payload?.title, "确认创建分镜缺少标题,请重新生成确认卡。");
|
||||
const title = requirePayloadText(payload?.title, pick(lang, "确认创建分镜缺少标题,请重新生成确认卡。", "The storyboard creation confirmation is missing a title. Regenerate the confirmation card."));
|
||||
tool = createStoryboardCreationTool(args.pipeline, args.root, { actionPayload });
|
||||
params = {
|
||||
title,
|
||||
@@ -1268,7 +1348,7 @@ async function executeConfirmedProductionAction(args: {
|
||||
};
|
||||
} else if (args.requestedIntent === "interactive_film_create") {
|
||||
const payload = actionPayload?.interactiveFilmCreate;
|
||||
const title = requirePayloadText(payload?.title, "确认创建互动影游缺少标题,请重新生成确认卡。");
|
||||
const title = requirePayloadText(payload?.title, pick(lang, "确认创建互动影游缺少标题,请重新生成确认卡。", "The interactive film confirmation is missing a title. Regenerate the confirmation card."));
|
||||
tool = createInteractiveFilmCreationTool(args.pipeline, args.root, { actionPayload });
|
||||
params = {
|
||||
title,
|
||||
@@ -1287,7 +1367,7 @@ async function executeConfirmedProductionAction(args: {
|
||||
};
|
||||
} else if (args.requestedIntent === "play_start") {
|
||||
const payload = actionPayload?.playStart;
|
||||
const title = requirePayloadText(payload?.title, "确认启动互动世界缺少标题,请重新生成确认卡。");
|
||||
const title = requirePayloadText(payload?.title, pick(lang, "确认启动互动世界缺少标题,请重新生成确认卡。", "The interactive world start confirmation is missing a title. Regenerate the confirmation card."));
|
||||
const fallbackScene = [payload?.premise, args.instruction].filter((part): part is string => typeof part === "string" && part.trim().length > 0).join("\n\n");
|
||||
const initialScene = isUsablePlayInitialScene(payload?.initialScene)
|
||||
? payload?.initialScene?.trim()
|
||||
@@ -1318,14 +1398,14 @@ async function executeConfirmedProductionAction(args: {
|
||||
if (!projectId) throw new ApiError(400, "INVALID_ID", "interactive-film action requires a project id (bookId)");
|
||||
const agentCtx = args.pipeline.createAgentContext("film-authoring", projectId);
|
||||
const deps = filmLLMDepsFromClient(agentCtx.client, agentCtx.model);
|
||||
tool = createDraftStructureTool(args.root, projectId, deps);
|
||||
tool = createDraftStructureTool(args.root, projectId, deps, lang);
|
||||
params = {
|
||||
instruction: payload?.instruction?.trim() || args.instruction,
|
||||
};
|
||||
} else if (args.requestedIntent === "connect_choice") {
|
||||
const payload = actionPayload?.connectChoice;
|
||||
if (!payload?.node) {
|
||||
throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", "确认连接选择缺少节点数据,请重新生成确认卡。");
|
||||
throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", pick(lang, "确认连接选择缺少节点数据,请重新生成确认卡。", "The connect-choice confirmation is missing node data. Regenerate the confirmation card."));
|
||||
}
|
||||
const projectId = payload?.projectId ?? args.bookId;
|
||||
if (!projectId) throw new ApiError(400, "INVALID_ID", "interactive-film action requires a project id (bookId)");
|
||||
@@ -1336,7 +1416,7 @@ async function executeConfirmedProductionAction(args: {
|
||||
} else if (args.requestedIntent === "remove_node") {
|
||||
const payload = actionPayload?.removeNode;
|
||||
if (!payload?.nodeId) {
|
||||
throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", "确认删除节点缺少 nodeId,请重新生成确认卡。");
|
||||
throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", pick(lang, "确认删除节点缺少 nodeId,请重新生成确认卡。", "The remove-node confirmation is missing a nodeId. Regenerate the confirmation card."));
|
||||
}
|
||||
const projectId = payload?.projectId ?? args.bookId;
|
||||
if (!projectId) throw new ApiError(400, "INVALID_ID", "interactive-film action requires a project id (bookId)");
|
||||
@@ -1352,10 +1432,10 @@ async function executeConfirmedProductionAction(args: {
|
||||
id,
|
||||
tool: tool.name,
|
||||
agent,
|
||||
label: resolveToolLabel(tool.name, agent),
|
||||
label: resolveToolLabel(tool.name, agent, lang),
|
||||
status: "running",
|
||||
args: params,
|
||||
stages: agent ? PIPELINE_STAGES[agent]?.map(label => ({ label, status: "pending" as const })) : undefined,
|
||||
stages: agent ? pipelineStages(agent, lang)?.map(label => ({ label, status: "pending" as const })) : undefined,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -1382,7 +1462,7 @@ async function executeConfirmedProductionAction(args: {
|
||||
);
|
||||
exec.status = "completed";
|
||||
exec.completedAt = Date.now();
|
||||
exec.result = toolResultText(result);
|
||||
exec.result = toolResultText(result, lang);
|
||||
exec.details = (result as { details?: unknown } | undefined)?.details;
|
||||
exec.stages = exec.stages?.map(stage => ({ ...stage, status: "completed" as const }));
|
||||
broadcast("tool:end", {
|
||||
@@ -1968,13 +2048,16 @@ function shouldTrustStaticModelsWhenLiveListUnavailable(endpoint: ReturnType<typ
|
||||
return endpoint?.group === "aggregator";
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string, lang: StudioLanguage = "zh"): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(`${label} 超时(${timeoutMs}ms)`)), timeoutMs);
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(pick(lang, `${label} 超时(${timeoutMs}ms)`, `${label} timed out (${timeoutMs}ms)`))),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
@@ -1990,50 +2073,82 @@ function formatServiceProbeError(args: {
|
||||
readonly apiFormat?: "chat" | "responses";
|
||||
readonly stream?: boolean;
|
||||
readonly error: string;
|
||||
readonly language?: StudioLanguage;
|
||||
}): string {
|
||||
const lang = args.language ?? "zh";
|
||||
const rawDetail = args.error
|
||||
.replace(/\n\s*\(baseUrl:[\s\S]*?\)$/m, "")
|
||||
.trim();
|
||||
const upstreamDetail = rawDetail.includes("上游详情:")
|
||||
? rawDetail
|
||||
: "";
|
||||
const protocol = args.apiFormat === "responses" ? "Responses" : "Chat / Completions";
|
||||
const streamSuffix = typeof args.stream === "boolean"
|
||||
? pick(lang, `,${args.stream ? "流式" : "非流式"}`, `, ${args.stream ? "streaming" : "non-streaming"}`)
|
||||
: "";
|
||||
const context = [
|
||||
`服务商:${args.label ?? args.service}`,
|
||||
`测试模型:${args.model ?? "未确定"}`,
|
||||
`协议:${args.apiFormat === "responses" ? "Responses" : "Chat / Completions"}${typeof args.stream === "boolean" ? `,${args.stream ? "流式" : "非流式"}` : ""}`,
|
||||
`Base URL:${args.baseUrl}`,
|
||||
pick(lang, `服务商:${args.label ?? args.service}`, `Service: ${args.label ?? args.service}`),
|
||||
pick(lang, `测试模型:${args.model ?? "未确定"}`, `Test model: ${args.model ?? "undetermined"}`),
|
||||
pick(lang, `协议:${protocol}${streamSuffix}`, `Protocol: ${protocol}${streamSuffix}`),
|
||||
pick(lang, `Base URL:${args.baseUrl}`, `Base URL: ${args.baseUrl}`),
|
||||
].join("\n");
|
||||
const upstreamPrefix = (detail: string): string =>
|
||||
pick(lang, `\n上游返回:${detail}`, `\nUpstream response: ${detail}`);
|
||||
|
||||
if (args.service === "google") {
|
||||
return [
|
||||
"Google Gemini 测试连接失败。",
|
||||
pick(lang, "Google Gemini 测试连接失败。", "Google Gemini connection test failed."),
|
||||
context,
|
||||
"",
|
||||
"请优先检查:",
|
||||
"1. API Key 是否来自 Google AI Studio 的 Gemini API key,而不是 OAuth、Vertex AI 或其它 Google 服务凭据。",
|
||||
"2. 该 key 所属项目是否已启用 Gemini API,并且没有被限制到其它 API、来源或服务。",
|
||||
"3. 当前地区/账号是否允许访问 Gemini API。",
|
||||
"4. 如果 key 曾经泄露,请在 AI Studio 重新生成后再保存。",
|
||||
upstreamDetail ? `\n上游返回:${upstreamDetail}` : "",
|
||||
pick(lang, "请优先检查:", "Check these first:"),
|
||||
pick(
|
||||
lang,
|
||||
"1. API Key 是否来自 Google AI Studio 的 Gemini API key,而不是 OAuth、Vertex AI 或其它 Google 服务凭据。",
|
||||
"1. The API Key is a Gemini API key from Google AI Studio, not an OAuth, Vertex AI, or other Google service credential.",
|
||||
),
|
||||
pick(
|
||||
lang,
|
||||
"2. 该 key 所属项目是否已启用 Gemini API,并且没有被限制到其它 API、来源或服务。",
|
||||
"2. The key's project has the Gemini API enabled and is not restricted to other APIs, origins, or services.",
|
||||
),
|
||||
pick(
|
||||
lang,
|
||||
"3. 当前地区/账号是否允许访问 Gemini API。",
|
||||
"3. Your region/account is allowed to access the Gemini API.",
|
||||
),
|
||||
pick(
|
||||
lang,
|
||||
"4. 如果 key 曾经泄露,请在 AI Studio 重新生成后再保存。",
|
||||
"4. If the key was ever leaked, regenerate it in AI Studio before saving.",
|
||||
),
|
||||
upstreamDetail ? upstreamPrefix(upstreamDetail) : "",
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
if (args.service === "moonshot" || args.service === "kimiCodingPlan" || args.service === "kimicode") {
|
||||
return [
|
||||
`${args.label ?? args.service} 测试连接失败。`,
|
||||
pick(lang, `${args.label ?? args.service} 测试连接失败。`, `${args.label ?? args.service} connection test failed.`),
|
||||
context,
|
||||
"",
|
||||
"请优先检查模型是否可用,以及 kimi-k2.x 这类模型是否需要 temperature=1。",
|
||||
rawDetail ? `\n上游返回:${rawDetail}` : "",
|
||||
pick(
|
||||
lang,
|
||||
"请优先检查模型是否可用,以及 kimi-k2.x 这类模型是否需要 temperature=1。",
|
||||
"Check first whether the model is available, and whether models like kimi-k2.x require temperature=1.",
|
||||
),
|
||||
rawDetail ? upstreamPrefix(rawDetail) : "",
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
`${args.label ?? args.service} 测试连接失败。`,
|
||||
pick(lang, `${args.label ?? args.service} 测试连接失败。`, `${args.label ?? args.service} connection test failed.`),
|
||||
context,
|
||||
"",
|
||||
"请检查 API Key、模型可用性、账号额度,以及协议类型是否匹配该服务商。",
|
||||
rawDetail ? `\n上游返回:${rawDetail}` : "",
|
||||
pick(
|
||||
lang,
|
||||
"请检查 API Key、模型可用性、账号额度,以及协议类型是否匹配该服务商。",
|
||||
"Check the API Key, model availability, account quota, and whether the protocol type matches this service.",
|
||||
),
|
||||
rawDetail ? upstreamPrefix(rawDetail) : "",
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
@@ -2042,6 +2157,7 @@ async function fetchModelsFromServiceBaseUrl(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
proxyUrl?: string,
|
||||
lang: StudioLanguage = "zh",
|
||||
): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> {
|
||||
const endpoint = isCustomServiceId(serviceId)
|
||||
? undefined
|
||||
@@ -2052,14 +2168,18 @@ async function fetchModelsFromServiceBaseUrl(
|
||||
const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
|
||||
try {
|
||||
const res = await fetchWithProxy(modelsUrl, {
|
||||
headers: buildBearerAuthHeaders(apiKey),
|
||||
headers: buildBearerAuthHeaders(apiKey, lang),
|
||||
signal: AbortSignal.timeout(SERVICE_MODELS_PROBE_TIMEOUT_MS),
|
||||
}, proxyUrl);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
return {
|
||||
models: [],
|
||||
error: `服务商返回 ${res.status}: ${body.slice(0, 200)}`,
|
||||
error: pick(
|
||||
lang,
|
||||
`服务商返回 ${res.status}: ${body.slice(0, 200)}`,
|
||||
`Service returned ${res.status}: ${body.slice(0, 200)}`,
|
||||
),
|
||||
authFailed: res.status === 401 || res.status === 403,
|
||||
};
|
||||
}
|
||||
@@ -2075,11 +2195,15 @@ async function fetchModelsFromServiceBaseUrl(
|
||||
}
|
||||
}
|
||||
|
||||
function buildBearerAuthHeaders(apiKey: string | undefined): Record<string, string> {
|
||||
function buildBearerAuthHeaders(apiKey: string | undefined, lang: StudioLanguage = "zh"): Record<string, string> {
|
||||
const trimmed = apiKey?.trim() ?? "";
|
||||
if (!trimmed) return {};
|
||||
if (!/^[\x20-\x7e]+$/.test(trimmed)) {
|
||||
throw new Error("API Key 只能包含英文、数字和常见 ASCII 符号,请检查是否误粘贴了中文说明。");
|
||||
throw new Error(pick(
|
||||
lang,
|
||||
"API Key 只能包含英文、数字和常见 ASCII 符号,请检查是否误粘贴了中文说明。",
|
||||
"API Key may only contain ASCII letters, digits, and common symbols. Check whether you pasted explanatory text by mistake.",
|
||||
));
|
||||
}
|
||||
return { Authorization: `Bearer ${trimmed}` };
|
||||
}
|
||||
@@ -2093,7 +2217,9 @@ async function probeServiceCapabilities(args: {
|
||||
preferredStream?: boolean;
|
||||
preferredModel?: string;
|
||||
proxyUrl?: string;
|
||||
language?: StudioLanguage;
|
||||
}): Promise<ServiceProbeResult> {
|
||||
const lang = args.language ?? "zh";
|
||||
const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record<string, unknown>));
|
||||
const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
|
||||
const envConfig = await readEnvConfigStatus(args.root);
|
||||
@@ -2104,12 +2230,16 @@ async function probeServiceCapabilities(args: {
|
||||
: null;
|
||||
|
||||
const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
|
||||
const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey, args.proxyUrl);
|
||||
const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey, args.proxyUrl, lang);
|
||||
if (modelsResponse.authFailed) {
|
||||
return {
|
||||
ok: false,
|
||||
models: [],
|
||||
error: modelsResponse.error ?? "API Key 无效或无权访问模型列表。",
|
||||
error: modelsResponse.error ?? pick(
|
||||
lang,
|
||||
"API Key 无效或无权访问模型列表。",
|
||||
"API Key is invalid or has no access to the model list.",
|
||||
),
|
||||
};
|
||||
}
|
||||
const discoveredModels = modelsResponse.models;
|
||||
@@ -2123,7 +2253,11 @@ async function probeServiceCapabilities(args: {
|
||||
return {
|
||||
ok: false,
|
||||
models: discoveredModels,
|
||||
error: "模型列表可访问,但没有发现可用于文本对话的模型。",
|
||||
error: pick(
|
||||
lang,
|
||||
"模型列表可访问,但没有发现可用于文本对话的模型。",
|
||||
"The model list is reachable, but no model usable for text chat was found.",
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -2185,11 +2319,15 @@ async function probeServiceCapabilities(args: {
|
||||
return {
|
||||
ok: false,
|
||||
models: [],
|
||||
error: "无法自动确定模型,请先填写可用模型或提供支持 /models 的服务端点。",
|
||||
error: pick(
|
||||
lang,
|
||||
"无法自动确定模型,请先填写可用模型或提供支持 /models 的服务端点。",
|
||||
"Could not determine a model automatically. Fill in an available model first, or provide a service endpoint that supports /models.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let lastError = modelsResponse.error ?? "自动探测失败";
|
||||
let lastError = modelsResponse.error ?? pick(lang, "自动探测失败", "Automatic probing failed");
|
||||
|
||||
for (const model of modelCandidates) {
|
||||
for (const plan of buildProbePlans(args.preferredApiFormat, args.preferredStream)) {
|
||||
@@ -2216,6 +2354,7 @@ async function probeServiceCapabilities(args: {
|
||||
chatCompletion(client, model, [{ role: "user", content: "Reply with OK only." }], { maxTokens: 16, retry: false }),
|
||||
SERVICE_CHAT_PROBE_TIMEOUT_MS,
|
||||
"service connection test",
|
||||
lang,
|
||||
);
|
||||
const models = discoveredModels.length > 0
|
||||
? discoveredModels
|
||||
@@ -2238,6 +2377,7 @@ async function probeServiceCapabilities(args: {
|
||||
apiFormat: plan.apiFormat,
|
||||
stream: plan.stream,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
language: lang,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2316,6 +2456,14 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
return freshConfig;
|
||||
}
|
||||
|
||||
// Read the project language fresh from inkos.json on every call, so a language
|
||||
// switch takes effect on the next request instead of being frozen at startup.
|
||||
// A missing/corrupt inkos.json means "no project language configured" -> zh.
|
||||
async function currentProjectLanguage(): Promise<StudioLanguage> {
|
||||
const raw = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
|
||||
return normalizeStudioLanguage(raw.language);
|
||||
}
|
||||
|
||||
async function buildPipelineConfig(
|
||||
overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">> & {
|
||||
readonly currentConfig?: ProjectConfig;
|
||||
@@ -2925,7 +3073,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
const env = await readEffectiveEnvConfigValues(root);
|
||||
if (!env || !env.values.apiKey) {
|
||||
return c.json({
|
||||
error: "未检测到可导入的 LLM 环境变量配置,或缺少 INKOS_LLM_API_KEY。",
|
||||
error: pick(
|
||||
await currentProjectLanguage(),
|
||||
"未检测到可导入的 LLM 环境变量配置,或缺少 INKOS_LLM_API_KEY。",
|
||||
"No importable LLM environment variable configuration was detected, or INKOS_LLM_API_KEY is missing.",
|
||||
),
|
||||
}, 400);
|
||||
}
|
||||
|
||||
@@ -2980,7 +3132,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
}
|
||||
if (body.configSource === "env") {
|
||||
return c.json({
|
||||
error: "Studio 运行时不支持切换到 env;env 只在 CLI/daemon/部署运行时作为覆盖层使用。",
|
||||
error: pick(
|
||||
await currentProjectLanguage(),
|
||||
"Studio 运行时不支持切换到 env;env 只在 CLI/daemon/部署运行时作为覆盖层使用。",
|
||||
"The Studio runtime does not support switching to env; env only acts as an override layer in the CLI/daemon/deployment runtimes.",
|
||||
),
|
||||
}, 400);
|
||||
}
|
||||
if (body.configSource !== undefined) {
|
||||
@@ -3062,7 +3218,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
const body = await c.req.json<{ apiKey?: string }>();
|
||||
const trimmedKey = body.apiKey?.trim() ?? "";
|
||||
if (trimmedKey && !isHeaderSafeApiKey(trimmedKey)) {
|
||||
return c.json({ error: "API Key 包含不能放入 HTTP Authorization header 的字符,请只粘贴原始密钥。" }, 400);
|
||||
return c.json({
|
||||
error: pick(
|
||||
await currentProjectLanguage(),
|
||||
"API Key 包含不能放入 HTTP Authorization header 的字符,请只粘贴原始密钥。",
|
||||
"API Key contains characters that cannot go into an HTTP Authorization header. Paste only the raw key.",
|
||||
),
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const secrets = await loadSecrets(root);
|
||||
@@ -3108,9 +3270,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
stream?: boolean;
|
||||
}>();
|
||||
|
||||
const language = await currentProjectLanguage();
|
||||
const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service, baseUrl);
|
||||
if (!resolvedBaseUrl) {
|
||||
return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: pick(language, `未知服务商: ${service}`, `Unknown service: ${service}`),
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const baseService = isCustomServiceId(service) ? "custom" : service;
|
||||
@@ -3121,7 +3287,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
if (!apiKey?.trim() && !apiKeyOptional) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: "API Key 不能为空",
|
||||
error: pick(language, "API Key 不能为空", "API Key must not be empty"),
|
||||
}, 400);
|
||||
}
|
||||
|
||||
@@ -3135,19 +3301,21 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
preferredApiFormat: apiFormat,
|
||||
preferredStream: stream,
|
||||
proxyUrl: typeof llm.proxyUrl === "string" ? llm.proxyUrl : undefined,
|
||||
language,
|
||||
});
|
||||
|
||||
// B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
|
||||
const connectionFailed = pick(language, "连接失败", "Connection failed");
|
||||
const probeStatus = {
|
||||
ok: probe.ok,
|
||||
models: probe.models?.length ?? 0,
|
||||
...(probe.ok ? {} : { error: probe.error ?? "连接失败" }),
|
||||
...(probe.ok ? {} : { error: probe.error ?? connectionFailed }),
|
||||
};
|
||||
|
||||
if (!probe.ok) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: probe.error ?? "连接失败",
|
||||
error: probe.error ?? connectionFailed,
|
||||
probe: probeStatus,
|
||||
chat: null,
|
||||
}, 400);
|
||||
@@ -3179,7 +3347,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
if (!isHeaderSafeApiKey(trimmedKey)) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: "API Key 只能包含可放进 HTTP Authorization header 的非空白 ASCII 字符;请不要粘贴连接失败提示或诊断文本。",
|
||||
error: pick(
|
||||
await currentProjectLanguage(),
|
||||
"API Key 只能包含可放进 HTTP Authorization header 的非空白 ASCII 字符;请不要粘贴连接失败提示或诊断文本。",
|
||||
"API Key may only contain non-whitespace ASCII characters that fit in an HTTP Authorization header; do not paste connection failure hints or diagnostic text.",
|
||||
),
|
||||
}, 400);
|
||||
}
|
||||
secrets.services[service] = { apiKey: trimmedKey };
|
||||
@@ -3942,8 +4114,9 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
if (!sessionId?.trim()) {
|
||||
throw new ApiError(400, "SESSION_ID_REQUIRED", "sessionId is required");
|
||||
}
|
||||
const language = await currentProjectLanguage();
|
||||
if (reqModel && !isTextChatModelId(reqModel)) {
|
||||
const message = nonTextModelMessage(reqModel);
|
||||
const message = nonTextModelMessage(reqModel, language);
|
||||
return c.json({ error: message, response: message }, 400);
|
||||
}
|
||||
|
||||
@@ -4076,8 +4249,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
const msg = e?.message ?? String(e);
|
||||
if (/API key/i.test(msg)) {
|
||||
return c.json({
|
||||
error: `请先为 ${reqService} 配置 API Key`,
|
||||
response: `请先在模型配置中为 ${reqService} 填写 API Key,然后再试。`,
|
||||
error: pick(language, `请先为 ${reqService} 配置 API Key`, `Configure an API Key for ${reqService} first`),
|
||||
response: pick(
|
||||
language,
|
||||
`请先在模型配置中为 ${reqService} 填写 API Key,然后再试。`,
|
||||
`Fill in an API Key for ${reqService} in the model settings, then try again.`,
|
||||
),
|
||||
}, 400);
|
||||
}
|
||||
throw e;
|
||||
@@ -4188,6 +4365,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
instruction,
|
||||
requestedIntent,
|
||||
actionPayload,
|
||||
language,
|
||||
...(playMode ? { playMode } : {}),
|
||||
});
|
||||
|
||||
@@ -4215,7 +4393,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
}
|
||||
}
|
||||
|
||||
const responseText = exec.result ?? "已完成。";
|
||||
const responseText = exec.result ?? pick(language, "已完成。", "Done.");
|
||||
const responseForUser = suppressManualTextForTool(exec) ? "" : responseText;
|
||||
await appendManualSessionMessages(root, bookSession.sessionId, [
|
||||
manualToolAssistantMessage(
|
||||
@@ -4273,13 +4451,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
id: toolCallId,
|
||||
tool: "sub_agent",
|
||||
args: toolArgs,
|
||||
stages: PIPELINE_STAGES.writer,
|
||||
stages: pipelineStages("writer", language),
|
||||
});
|
||||
|
||||
try {
|
||||
const writeResult = await pipeline.writeNextChapter(directWriteBookId);
|
||||
const writeNeedsReview = Boolean(writeResult.status && writeResult.status !== "ready-for-review");
|
||||
const responseText = writeNeedsReview
|
||||
const zhResponseText = writeNeedsReview
|
||||
? [
|
||||
`已为 ${directWriteBookId} 写出第 ${writeResult.chapterNumber} 章`,
|
||||
writeResult.title ? `《${writeResult.title}》` : "",
|
||||
@@ -4290,6 +4468,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
writeResult.title ? `《${writeResult.title}》` : "",
|
||||
`,字数 ${writeResult.wordCount},状态 ${writeResult.status}。`,
|
||||
].join("");
|
||||
const enChapterRef = writeResult.title
|
||||
? `chapter ${writeResult.chapterNumber} "${writeResult.title}"`
|
||||
: `chapter ${writeResult.chapterNumber}`;
|
||||
const enResponseText = writeNeedsReview
|
||||
? `Wrote ${enChapterRef} for ${directWriteBookId}: ${writeResult.wordCount} words, but the review did not pass (status: ${writeResult.status}). Manual review is required before continuing.`
|
||||
: `Completed ${enChapterRef} for ${directWriteBookId}: ${writeResult.wordCount} words, status ${writeResult.status}.`;
|
||||
const responseText = pick(language, zhResponseText, enResponseText);
|
||||
const toolResult = {
|
||||
content: [{ type: "text", text: responseText }],
|
||||
details: {
|
||||
@@ -4313,7 +4498,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
id: toolCallId,
|
||||
tool: "sub_agent",
|
||||
agent: "writer",
|
||||
label: resolveToolLabel("sub_agent", "writer"),
|
||||
label: resolveToolLabel("sub_agent", "writer", language),
|
||||
status: writeNeedsReview ? "error" : "completed",
|
||||
args: toolArgs,
|
||||
result: responseText,
|
||||
@@ -4346,7 +4531,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
id: toolCallId,
|
||||
tool: "sub_agent",
|
||||
agent: "writer",
|
||||
label: resolveToolLabel("sub_agent", "writer"),
|
||||
label: resolveToolLabel("sub_agent", "writer", language),
|
||||
status: "error",
|
||||
args: toolArgs,
|
||||
error: message,
|
||||
@@ -4427,13 +4612,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
if (event.type === "tool_execution_start") {
|
||||
const args = event.args as Record<string, unknown> | undefined;
|
||||
const agent = event.toolName === "sub_agent" ? (args?.agent as string | undefined) : undefined;
|
||||
const stages = agent ? (PIPELINE_STAGES[agent] ?? []) : [];
|
||||
const stages = agent ? (pipelineStages(agent, language) ?? []) : [];
|
||||
|
||||
collectedToolExecs.push({
|
||||
id: event.toolCallId,
|
||||
tool: event.toolName,
|
||||
agent,
|
||||
label: resolveToolLabel(event.toolName, agent),
|
||||
label: resolveToolLabel(event.toolName, agent, language),
|
||||
status: "running",
|
||||
args,
|
||||
stages: stages.length > 0
|
||||
@@ -4511,6 +4696,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
agentBookId,
|
||||
requestedIntent,
|
||||
collectedToolExecs,
|
||||
language,
|
||||
});
|
||||
if (actionExecutionError) {
|
||||
return c.json({
|
||||
@@ -4574,7 +4760,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
|
||||
await finalizeCreatedBook();
|
||||
}
|
||||
const failure = formatAgentFailure(result.errorMessage);
|
||||
const failure = formatAgentFailure(result.errorMessage, language);
|
||||
return c.json({
|
||||
error: { code: failure.code, message: failure.message },
|
||||
response: failure.message,
|
||||
@@ -4586,6 +4772,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
agentBookId,
|
||||
requestedIntent,
|
||||
collectedToolExecs,
|
||||
language,
|
||||
});
|
||||
if (actionExecutionError) {
|
||||
return c.json({
|
||||
@@ -4609,7 +4796,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
});
|
||||
}
|
||||
|
||||
const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
|
||||
const emptyMessage = pick(
|
||||
language,
|
||||
"模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。",
|
||||
"The model returned no text content. Check the protocol type (chat/responses), the streaming switch, or upstream service compatibility.",
|
||||
);
|
||||
if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
|
||||
await finalizeCreatedBook();
|
||||
}
|
||||
@@ -4646,12 +4837,19 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
// Agent busy — return 429 with user-friendly message
|
||||
if (/already processing|prompt.*queue/i.test(msg)) {
|
||||
return c.json({
|
||||
error: { code: "AGENT_BUSY", message: "正在处理中,请等待当前操作完成" },
|
||||
response: "正在处理中,请等待当前操作完成后再发送。",
|
||||
error: {
|
||||
code: "AGENT_BUSY",
|
||||
message: pick(language, "正在处理中,请等待当前操作完成", "Still processing. Wait for the current operation to finish"),
|
||||
},
|
||||
response: pick(
|
||||
language,
|
||||
"正在处理中,请等待当前操作完成后再发送。",
|
||||
"Still processing. Wait for the current operation to finish before sending again.",
|
||||
),
|
||||
}, 429);
|
||||
}
|
||||
|
||||
const failure = formatAgentFailure(msg);
|
||||
const failure = formatAgentFailure(msg, language);
|
||||
return c.json(
|
||||
{ error: { code: failure.code, message: failure.message } },
|
||||
failure.status,
|
||||
@@ -5595,6 +5793,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
|
||||
preferredStream: currentConfig.llm.stream,
|
||||
preferredModel: currentConfig.llm.model,
|
||||
proxyUrl: currentConfig.llm.proxyUrl,
|
||||
language: normalizeStudioLanguage(currentConfig.language),
|
||||
}),
|
||||
DOCTOR_LLM_PROBE_BUDGET_MS,
|
||||
"doctor llm probe",
|
||||
|
||||
Reference in New Issue
Block a user