Merge branch 'pi-ecosystem-capabilities-20260703'

This commit is contained in:
Ma
2026-07-04 22:23:55 +08:00
21 changed files with 1349 additions and 43 deletions
+1
View File
@@ -62,6 +62,7 @@
"epub-gen-memory": "^1.0.10",
"js-yaml": "^4.1.1",
"undici": "6.21.3",
"unpdf": "^1.6.2",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -185,7 +185,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
};
});
import { runAgentSession, evictAgentCache } from "../agent/agent-session.js";
import { abortAgentSession, runAgentSession, evictAgentCache } from "../agent/agent-session.js";
import {
appendManualSessionMessages,
appendTranscriptEvent,
@@ -233,6 +233,7 @@ describe("runAgentSession cache — bookId switch", () => {
evictAgentCache("play-session");
evictAgentCache("play-active-session");
evictAgentCache("play-confirmed-session");
evictAgentCache("abort-session");
await rm(projectRoot, { recursive: true, force: true });
if (otherProjectRoot) await rm(otherProjectRoot, { recursive: true, force: true });
});
@@ -561,6 +562,8 @@ describe("runAgentSession cache — bookId switch", () => {
expect(agentInstances[0].state.tools.map((tool: any) => tool.name)).toEqual([
"propose_action",
"research_web",
"ingest_material",
"retrieve_material",
]);
});
@@ -576,6 +579,8 @@ describe("runAgentSession cache — bookId switch", () => {
expect(agentInstances[0].state.tools.map((tool: any) => tool.name)).toEqual([
"propose_action",
"research_web",
"ingest_material",
"retrieve_material",
]);
});
@@ -631,6 +636,8 @@ describe("runAgentSession cache — bookId switch", () => {
);
expect(agentInstances[0].state.tools.map((tool: any) => tool.name)).toEqual([
"propose_action",
"ingest_material",
"retrieve_material",
]);
await runAgentSession(
@@ -639,6 +646,8 @@ describe("runAgentSession cache — bookId switch", () => {
);
expect(agentInstances[1].state.tools.map((tool: any) => tool.name)).toEqual([
"propose_action",
"ingest_material",
"retrieve_material",
]);
});
@@ -775,6 +784,8 @@ describe("runAgentSession cache — bookId switch", () => {
"play_edit",
"play_revise",
"play_step",
"ingest_material",
"retrieve_material",
]);
});
@@ -860,6 +871,8 @@ describe("runAgentSession cache — bookId switch", () => {
"patch_chapter_text",
"replace_chapter_text",
"research_web",
"ingest_material",
"retrieve_material",
"grep",
"ls",
]);
@@ -880,6 +893,8 @@ describe("runAgentSession cache — bookId switch", () => {
"rename_entity",
"patch_chapter_text",
"replace_chapter_text",
"ingest_material",
"retrieve_material",
"grep",
"ls",
]);
@@ -1215,6 +1230,30 @@ describe("runAgentSession cache — bookId switch", () => {
expect(JSON.stringify(streamCalls.at(-1)?.context.messages)).not.toContain("model error");
});
it("aborts and evicts an active cached agent session", async () => {
const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
const pipeline = {} as any;
await runAgentSession(
{ sessionId: "abort-session", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
"hello",
);
const abortSpy = vi.spyOn(agentInstances.at(-1), "abort");
const clearSpy = vi.spyOn(agentInstances.at(-1), "clearAllQueues");
expect(abortAgentSession(projectRoot, "abort-session")).toBe(true);
expect(abortSpy).toHaveBeenCalledOnce();
expect(clearSpy).toHaveBeenCalledOnce();
const instancesAfterAbort = agentInstances.length;
await runAgentSession(
{ sessionId: "abort-session", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
"again",
);
expect(agentInstances).toHaveLength(instancesAfterAbort + 1);
});
it("serializes concurrent turns before assigning transcript seq", async () => {
const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
const pipeline = {} as any;
@@ -322,6 +322,11 @@ describe("ComposerAgent", () => {
expect(authorIntent?.excerpt).toContain("Keep the pressure on the mentor conflict.");
expect(compiled?.excerpt).toContain("压缩后的旧章标题历史");
expect(result.trace.notes).toContain("compiled-compressible-context");
expect(result.trace.compression).toMatchObject({
compiledSource: "runtime/compiled-compressible-context",
compressedSources: expect.arrayContaining(["story/chapter_summaries.md#recent_titles"]),
protectedSources: expect.arrayContaining(["story/author_intent.md"]),
});
});
it("emits story context compression lifecycle events when compiling compressible context", async () => {
@@ -0,0 +1,63 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ingestMaterial } from "../materials/ingest.js";
describe("material ingestion", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "inkos-material-ingest-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it("archives a project text file as traceable markdown material", async () => {
await writeFile(join(root, "brief.md"), "# Brief\n\n第一人称,县城冷库旧账。", "utf-8");
const asset = await ingestMaterial(root, {
sourceKind: "file",
filePath: "brief.md",
mimeType: "text/markdown",
purpose: "worldbuilding",
}, {
now: () => new Date("2026-07-03T00:00:00.000Z"),
});
expect(asset.kind).toBe("text");
expect(asset.markdownPath).toMatch(/^\.inkos\/materials\//);
expect(asset.source).toBe("brief.md");
expect(asset.excerpt).toContain("县城冷库旧账");
const markdown = await readFile(join(root, asset.markdownPath), "utf-8");
expect(markdown).toContain("## Metadata");
expect(markdown).toContain("- purpose: worldbuilding");
expect(markdown).toContain("第一人称,县城冷库旧账。");
const manifest = JSON.parse(await readFile(join(root, asset.manifestPath), "utf-8")) as { markdownPath?: string };
expect(manifest.markdownPath).toBe(asset.markdownPath);
});
it("extracts and archives HTML fetched from a URL", async () => {
const fetchImpl = async () => new Response(
"<html><head><title>旧账资料</title><style>x{}</style></head><body><h1>冷库流程</h1><script>bad()</script><p>入库单需要签字。</p></body></html>",
{ status: 200, headers: { "content-type": "text/html; charset=utf-8" } },
);
const asset = await ingestMaterial(root, {
sourceKind: "url",
url: "https://example.com/cold-storage",
purpose: "research",
}, {
fetch: fetchImpl as typeof fetch,
now: () => new Date("2026-07-03T00:00:00.000Z"),
});
expect(asset.kind).toBe("webpage");
expect(asset.title).toBe("旧账资料");
expect(asset.source).toBe("https://example.com/cold-storage");
expect(asset.excerpt).toContain("入库单需要签字");
expect(asset.excerpt).not.toContain("bad()");
});
});
@@ -0,0 +1,80 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ingestMaterial } from "../materials/ingest.js";
import { retrieveMaterials } from "../materials/retrieve.js";
describe("material retrieval", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "inkos-material-retrieve-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it("returns traceable snippets from archived materials", async () => {
await writeFile(join(root, "cold.md"), [
"# 冷库旧账",
"",
"赔偿款在 0607 账页后被拆成三笔转出。",
"冻品走私线索藏在入库单和司机签名里。",
].join("\n"), "utf-8");
await writeFile(join(root, "romance.md"), [
"# 恋爱线",
"",
"女主在海边车站归还钥匙,重点是误会后的情绪修复。",
].join("\n"), "utf-8");
await ingestMaterial(root, {
sourceKind: "file",
filePath: "cold.md",
purpose: "research",
}, { now: () => new Date("2026-07-03T00:00:00.000Z") });
await ingestMaterial(root, {
sourceKind: "file",
filePath: "romance.md",
purpose: "reference",
}, { now: () => new Date("2026-07-03T00:01:00.000Z") });
const results = await retrieveMaterials(root, {
query: "冷库 赔偿款 0607 账页",
limit: 2,
});
expect(results).toHaveLength(1);
expect(results[0]?.title).toBe("cold");
expect(results[0]?.excerpt).toContain("赔偿款");
expect(results[0]?.markdownPath).toMatch(/^\.inkos\/materials\//);
expect(results[0]?.charStart).toBeGreaterThanOrEqual(0);
expect(results[0]?.charEnd).toBeGreaterThan(results[0]?.charStart ?? 0);
});
it("can filter retrieval by material purpose", async () => {
await writeFile(join(root, "research.md"), "现实冷库需要入库单、签收单和温控记录。", "utf-8");
await writeFile(join(root, "script.md"), "分镜阶段需要镜头号、景别和动作。", "utf-8");
await ingestMaterial(root, {
sourceKind: "file",
filePath: "research.md",
purpose: "research",
}, { now: () => new Date("2026-07-03T00:00:00.000Z") });
await ingestMaterial(root, {
sourceKind: "file",
filePath: "script.md",
purpose: "script",
}, { now: () => new Date("2026-07-03T00:01:00.000Z") });
const results = await retrieveMaterials(root, {
query: "镜头 分镜 动作",
purpose: "script",
limit: 3,
});
expect(results.map((result) => result.purpose)).toEqual(["script"]);
expect(results[0]?.excerpt).toContain("分镜");
});
});
+93 -9
View File
@@ -8,6 +8,7 @@ import type {
AssistantMessage,
AssistantMessageEventStream,
Context as PiContext,
ImageContent,
Message,
SimpleStreamOptions,
ToolResultMessage,
@@ -36,6 +37,8 @@ import {
createStoryboardCreationTool,
createInteractiveFilmCreationTool,
createResearchWebTool,
createIngestMaterialTool,
createRetrieveMaterialTool,
} from "./agent-tools.js";
import { createFilmAuthoringTools, filmLLMDepsFromClient } from "./film-authoring-tools.js";
import { createBookContextTransform } from "./context-transform.js";
@@ -97,6 +100,8 @@ export interface AgentSessionConfig {
onEvent?: (event: AgentEvent) => void;
/** Optional listener for context compression lifecycle events. */
onContextCompression?: ContextCompressionCallback;
/** Attachments uploaded with this user turn. Text is injected as protected user context; images use pi-ai ImageContent. */
attachments?: ReadonlyArray<AgentSessionAttachment>;
}
export interface AgentSessionResult {
@@ -108,6 +113,19 @@ export interface AgentSessionResult {
errorMessage?: string;
}
export interface AgentSessionAttachment {
readonly id: string;
readonly filename: string;
readonly mimeType: string;
readonly size: number;
readonly storedPath?: string;
readonly text?: string;
readonly image?: {
readonly data: string;
readonly mimeType: string;
};
}
// ---------------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------------
@@ -243,6 +261,46 @@ function agentCacheKey(projectRoot: string, sessionId: string): string {
return sessionQueueKey(projectRoot, sessionId);
}
function buildAttachmentUserBlock(attachments: ReadonlyArray<AgentSessionAttachment> | undefined, language: string): string {
if (!attachments?.length) return "";
const isEn = language === "en";
const lines = [
isEn
? "\n\n## Uploaded Files (host-provided, user-authorized)"
: "\n\n## 用户上传文件(宿主已接收,用户授权本轮使用)",
];
for (const attachment of attachments) {
lines.push(`\n### ${attachment.filename}`);
lines.push(`- id: ${attachment.id}`);
lines.push(`- mime: ${attachment.mimeType || "application/octet-stream"}`);
lines.push(`- size: ${attachment.size}`);
if (attachment.storedPath) lines.push(`- stored_path: ${attachment.storedPath}`);
if (attachment.text) {
lines.push(isEn ? "\nContent:" : "\n内容:");
lines.push("```");
lines.push(attachment.text);
lines.push("```");
} else if (attachment.image) {
lines.push(isEn ? "- image: attached as multimodal input" : "- 图片:已作为多模态输入附加");
} else {
lines.push(isEn
? "- content: stored only; no extractor is available for this MIME type yet"
: "- 内容:已保存;当前 MIME 类型暂未配置文本抽取器");
}
}
return lines.join("\n");
}
function attachmentImages(attachments: ReadonlyArray<AgentSessionAttachment> | undefined): ImageContent[] {
return (attachments ?? [])
.filter((attachment) => attachment.image)
.map((attachment) => ({
type: "image",
data: attachment.image!.data,
mimeType: attachment.image!.mimeType,
}));
}
function guardedStreamSimple<TApi extends Api>(
model: Model<TApi>,
context: PiContext,
@@ -681,6 +739,8 @@ function createAgentToolsForMode(params: {
sameSession: params.sessionKind !== "chat",
});
const researchTool = createResearchWebTool(params.projectRoot);
const materialTool = createIngestMaterialTool(params.projectRoot);
const materialRetrievalTool = createRetrieveMaterialTool(params.projectRoot);
const isConfirmed = (
intent: NonNullable<AgentSessionConfig["requestedIntent"]>,
): boolean => {
@@ -689,7 +749,7 @@ function createAgentToolsForMode(params: {
};
if (params.sessionKind === "chat") {
return [proposalTool, researchTool];
return [proposalTool, researchTool, materialTool, materialRetrievalTool];
}
if (params.sessionKind === "short") {
@@ -699,28 +759,28 @@ function createAgentToolsForMode(params: {
if (isConfirmed("generate_cover")) {
return [createGenerateCoverTool(params.projectRoot, { actionPayload: params.actionPayload })];
}
return [proposalTool];
return [proposalTool, materialTool, materialRetrievalTool];
}
if (params.sessionKind === "script") {
if (isConfirmed("script_create")) {
return [createScriptCreationTool(params.pipeline, params.projectRoot, { actionPayload: params.actionPayload })];
}
return [proposalTool];
return [proposalTool, materialTool, materialRetrievalTool];
}
if (params.sessionKind === "storyboard") {
if (isConfirmed("storyboard_create")) {
return [createStoryboardCreationTool(params.pipeline, params.projectRoot, { actionPayload: params.actionPayload })];
}
return [proposalTool];
return [proposalTool, materialTool, materialRetrievalTool];
}
if (params.sessionKind === "interactive-film") {
if (isConfirmed("interactive_film_create")) {
return [createInteractiveFilmCreationTool(params.pipeline, params.projectRoot, { actionPayload: params.actionPayload })];
}
return [proposalTool];
return [proposalTool, materialTool, materialRetrievalTool];
}
if (params.sessionKind === "interactive-film-authoring") {
@@ -749,9 +809,11 @@ function createAgentToolsForMode(params: {
createPlayEditTool(params.projectRoot, params.sessionId),
createPlayReviseTool(params.pipeline, params.projectRoot, params.sessionId),
createPlayStepTool(params.pipeline, params.projectRoot, params.sessionId),
materialTool,
materialRetrievalTool,
];
}
return [proposalTool];
return [proposalTool, materialTool, materialRetrievalTool];
}
if (params.sessionKind === "book-create" && !params.bookId) {
@@ -761,7 +823,7 @@ function createAgentToolsForMode(params: {
architectCreateOnly: true,
})];
}
return [proposalTool, researchTool];
return [proposalTool, researchTool, materialTool, materialRetrievalTool];
}
if (!params.bookId) {
@@ -777,6 +839,8 @@ function createAgentToolsForMode(params: {
createPatchChapterTextTool(params.pipeline, params.projectRoot, params.bookId),
createReplaceChapterTextTool(params.pipeline, params.projectRoot, params.bookId),
researchTool,
materialTool,
materialRetrievalTool,
createGrepTool(params.projectRoot),
createLsTool(params.projectRoot),
];
@@ -978,6 +1042,9 @@ async function runAgentSessionUnlocked(
cached.lastActive = Date.now();
const { agent } = cached;
const attachmentBlock = buildAttachmentUserBlock(config.attachments, language);
const promptMessage = attachmentBlock ? `${userMessage}${attachmentBlock}` : userMessage;
const promptImages = attachmentImages(config.attachments);
// ----- Prepare transcript persistence -----
const requestId = randomUUID();
@@ -990,7 +1057,7 @@ async function runAgentSessionUnlocked(
seq,
timestamp: Date.now(),
sessionKind,
input: userMessage,
input: promptMessage,
}));
let parentUuid: string | null = null;
@@ -1059,7 +1126,11 @@ async function runAgentSessionUnlocked(
let errorMessage: string | undefined;
try {
await agent.prompt(userMessage);
if (promptImages.length > 0) {
await agent.prompt(promptMessage, promptImages);
} else {
await agent.prompt(promptMessage);
}
finalAssistant = lastAssistantMessage(agent.state.messages);
errorMessage = assistantErrorMessage(finalAssistant);
@@ -1129,3 +1200,16 @@ export function evictAgentCache(sessionId: string): boolean {
}
return deleted;
}
/** Abort an active cached pi-agent session and evict it from cache. */
export function abortAgentSession(projectRoot: string, sessionId: string): boolean {
let aborted = false;
for (const [key, entry] of agentCache) {
if (entry.projectRoot !== projectRoot || entry.sessionId !== sessionId) continue;
entry.agent.abort();
entry.agent.clearAllQueues?.();
agentCache.delete(key);
aborted = true;
}
return aborted;
}
+20 -16
View File
@@ -37,28 +37,28 @@ function buildChatPrompt(isZh: boolean): string {
这里不是自动生产入口。用户讨论、提问、比较方案时,直接回答。
可用工具:propose_action、research_web。用户明确要创建长篇、生成短篇、启动互动世界、生成封面、创建剧本、创建分镜,或打开同人/续写/番外/仿写辅助入口时调用 propose_action。用户明确要求联网研究、事实核查、年代/职业/世界观资料时调用 research_web;研究报告只是参考材料,不会自动改设定或正文。
可用工具:propose_action、research_web、ingest_material、retrieve_material。用户明确要创建长篇、生成短篇、启动互动世界、生成封面、创建剧本、创建分镜,或打开同人/续写/番外/仿写辅助入口时调用 propose_action。用户明确要求联网研究、事实核查、年代/职业/世界观资料时调用 research_web。用户给出 URL、上传 PDF/Markdown/文本资料,或要求“把这个资料纳入参考库/先读这份资料”时调用 ingest_material。用户要求基于已归档资料回答、整理、对照或继续创作时,先用 retrieve_material 按当前任务召回相关片段;资料卡只是参考材料,不会自动改设定或正文。
生产型动作:create_book、short_run、play_start、generate_cover、script_create、storyboard_create、interactive_film_create。确认后会切换到对应 session 执行。
辅助入口动作:fanfic_init、continuation_import、spinoff_create、style_imitation。确认后只打开现有 Studio 工具,不能声称已经生成成品。
辅助入口是“打开工具并准备材料”,不是立即生成成品。用户明确提到“同人 / 续写 / 番外 / 仿写 / 文风分析 / 参考文风 / 模仿笔法 / 先分析再仿写”时,必须调用 propose_action,不要用普通文字追问书名、原文、父书路径或解释流程。材料缺失时从用户方向临时概括一个短标题,instruction 里写清“待用户在入口补充材料”。映射:同人=fanfic_init,续写=continuation_import,番外/正典资料/不进入主线=spinoff_create,仿写/文风分析/参考文风/模仿笔法=style_imitation。确认卡标题/摘要必须说“打开入口 / 准备材料”,不要说“直接生成成品”。
调用 propose_action 时,instruction 必须自包含:写清目标入口、标题/书名/路径、故事或视觉方向、用户提到的关键上下文;不要让下一条 session 依赖上一轮聊天上下文猜。能确定的执行参数必须同时填进结构化字段:createBook / shortRun / playStart / generateCover / scriptCreate / storyboardCreate / interactiveFilmCreate,不要只写在 instruction 文本里。互动世界如果用户说“开放世界/自由玩/自己行动”,playStart.mode 填 open;如果用户说“分支互动/点着玩/给选项”,playStart.mode 填 guided。互动影游/互动剧/影游交付/盛世天下式多结局剧本,使用 interactive_film_create,不要路由到 play_start。
信息不足时只问一个关键问题。不要在 chat 里创建、写入、编辑或生成故事/图片产物;research_web 保存的参考报告除外。
信息不足时只问一个关键问题。不要在 chat 里创建、写入、编辑或生成故事/图片产物;research_web、ingest_material 和 retrieve_material 只处理参考材料除外。
${commonOutputRules(true)}`
: `You are the InkOS general chat assistant.
This is not an automatic production surface. Answer questions, discussion, comparisons, and issue reports directly.
Available tools: propose_action and research_web. Use propose_action when the user clearly wants to create a book, run short fiction, start a play world, generate a cover, create a script, create a storyboard, or open assisted fanfiction / continuation / side-story / style-imitation workflows. Use research_web when the user explicitly asks for web research, fact checking, era/profession/worldbuilding references, or market research; research reports are reference material only and do not automatically change canon or prose.
Available tools: propose_action, research_web, ingest_material, and retrieve_material. Use propose_action when the user clearly wants to create a book, run short fiction, start a play world, generate a cover, create a script, create a storyboard, or open assisted fanfiction / continuation / side-story / style-imitation workflows. Use research_web when the user explicitly asks for web research, fact checking, era/profession/worldbuilding references, or market research. Use ingest_material when the user provides a URL, uploaded PDF/Markdown/text file, or asks to archive/read provided materials. Use retrieve_material before answering, comparing, or continuing from archived materials. Research reports and material cards are reference material only and do not automatically change canon or prose.
Production actions: create_book, short_run, play_start, generate_cover, script_create, storyboard_create, interactive_film_create. After confirmation, InkOS switches to the matching session and runs them.
Assisted workflow actions: fanfic_init, continuation_import, spinoff_create, style_imitation. After confirmation, InkOS only opens the existing Studio tool; do not claim finished content was generated.
Assisted workflows open a tool and prepare materials; they do not immediately generate finished content. When the user explicitly asks for fanfiction, continuation, side-story/spinoff, style imitation, style analysis, reference-style analysis, prose mimicry, or "analyze first then imitate", you must call propose_action. Do not answer by asking for a title/source text/parent-book path or by explaining the workflow in plain text. If materials are missing, infer a short temporary title from the user's direction, and say in the instruction that the user will fill missing materials in the opened tool. Mapping: fanfiction=fanfic_init, continuation=continuation_import, side-story/spinoff/canon-materials=spinoff_create, style imitation/style analysis/reference-style/prose mimicry=style_imitation. The confirmation card title/summary must say "open workflow / prepare materials"; do not say finished content will be generated.
When calling propose_action, instruction must be self-contained: include target surface, title/book/path, story or visual direction, and concrete context behind references like "that book" or "this cover". Do not make the next session infer missing context from this chat. Put known execution arguments into the structured createBook / shortRun / playStart / generateCover / scriptCreate / storyboardCreate / interactiveFilmCreate fields as well; do not leave them only in instruction text. For interactive worlds, set playStart.mode=open when the user asks for open/free-form play, and playStart.mode=guided when the user asks for branching/choice-led play. For interactive film/drama/game-script deliverables with branch logic, flags, endings, scripts, and storyboards, use interactive_film_create instead of play_start.
If information is missing, ask one key question. Do not create, write, edit, or generate story/image artifacts in chat; research_web reference reports are the only exception.
If information is missing, ask one key question. Do not create, write, edit, or generate story/image artifacts in chat; research_web, ingest_material, and retrieve_material are reference-material-only exceptions.
${commonOutputRules(false)}`;
}
@@ -189,14 +189,14 @@ ${commonOutputRules(false)}`;
return isZh
? `你是 InkOS Short 助手。当前入口只负责把独立短篇或短篇封面需求聊清楚,然后让用户确认。
可用工具:propose_action。短篇成品用 action=short_run;只做封面用 action=generate_cover。核心冲突和主角压力明确时必须调用 propose_action,不要用普通文字手写确认卡。用户说“先确认/确认后再写”时,propose_action 就是确认卡,仍然调用它,不要先用普通文字整理一遍再等用户二次确认。
可用工具:propose_action、ingest_material、retrieve_material。短篇成品用 action=short_run;只做封面用 action=generate_cover。用户上传或提供参考资料时先归档/召回相关资料,但不要直接生成成品。核心冲突和主角压力明确时必须调用 propose_action,不要用普通文字手写确认卡。用户说“先确认/确认后再写”时,propose_action 就是确认卡,仍然调用它,不要先用普通文字整理一遍再等用户二次确认。
instruction 必须自包含:题材方向、标题/暂定名、主角压力、核心冲突、情绪回报、封面视觉方向或目标短篇路径。生成完整短篇时同时填 shortRundirection、chapters、charsPerChapter、covercharsPerChapter 只能是每章 900-1200 字,不是整篇总字数。
标题或封面视觉缺失时可以自行拟一个工作版本写进 instruction;只有题材、主角压力或核心冲突太空时才问一个关键问题。不要创建长篇 books/ 项目,不要启动互动世界,不要把短篇转成长篇建书。
${commonOutputRules(true)}`
: `You are the InkOS Short assistant. This surface clarifies standalone short-fiction or cover requests and asks for confirmation before production.
Available tool: propose_action. Use action=short_run for full short production; action=generate_cover for cover-only work. When the core conflict and protagonist pressure are clear, you must call propose_action; do not hand-write the confirmation card as plain text. If the user says "confirm first" or "write after confirmation", propose_action is that confirmation card; still call it instead of summarizing in plain text and waiting for a second confirmation.
Available tools: propose_action, ingest_material, retrieve_material. Use action=short_run for full short production; action=generate_cover for cover-only work. Archive/retrieve user-provided references when needed, but do not generate finished content directly. When the core conflict and protagonist pressure are clear, you must call propose_action; do not hand-write the confirmation card as plain text. If the user says "confirm first" or "write after confirmation", propose_action is that confirmation card; still call it instead of summarizing in plain text and waiting for a second confirmation.
instruction must be self-contained: genre direction, title/working title, protagonist pressure, core conflict, emotional payoff, cover direction, or target short path. For full short production, also fill shortRun: direction, chapters, charsPerChapter, cover; charsPerChapter is per-chapter 900-1200 Chinese chars, not total story length.
If title or cover direction is missing, invent a working version inside instruction; ask one key question only when genre, protagonist pressure, or core conflict is too vague. Do not create books/ projects, start play worlds, or route short-fiction requests to book creation.
@@ -223,7 +223,7 @@ ${commonOutputRules(false)}`;
return isZh
? `你是 InkOS 剧本创作助手。当前入口负责把小说、创意、大纲或已有文本转成用户可继续修改的剧本。
可用工具:propose_actionaction=script_create。用户已经说明想做“剧本 / 短剧剧本 / 小说改剧本 / 互动剧本 / 广播剧 / 分镜前剧本”时,先确认规格,不要在聊天里直接写完整剧本。
可用工具:propose_action、ingest_material、retrieve_materialaction=script_create。用户已经说明想做“剧本 / 短剧剧本 / 小说改剧本 / 互动剧本 / 广播剧 / 分镜前剧本”时,先归档/召回参考资料并确认规格,不要在聊天里直接写完整剧本。
确认卡要把空间留给用户:标题/暂定名、原素材类型、目标剧本格式、集数或时长、保留什么、可改什么、对白/场景/低成本拍摄等要求。不要替用户擅自决定忠实改编、商业强化或低成本拍摄强度;没有说清时写“待用户后续调整”或问一个关键问题。
instruction 必须自包含;能确定的执行参数同时填 scriptCreatetitle、sourceKind、targetFormat、sourceText/sourcePath、requirements、episodeCount、episodeDuration。sourceText 只放用户当前明确给出的素材;素材太长时要求用户通过入口补充 sourcePath,不要凭空改写、压缩或替用户补素材。
只有标题/素材/目标格式都太空时才问一个关键问题。
@@ -231,7 +231,7 @@ instruction 必须自包含;能确定的执行参数同时填 scriptCreatet
${commonOutputRules(true)}`
: `You are the InkOS script creation assistant. This surface turns a novel, idea, outline, or existing text into an editable script.
Available tool: propose_action with action=script_create. When the user asks for a script, vertical short-drama script, novel-to-script adaptation, interactive script, audio drama, or script-before-storyboard work, confirm the spec first; do not write the full script in chat.
Available tools: propose_action, ingest_material, retrieve_material with action=script_create. When the user asks for a script, vertical short-drama script, novel-to-script adaptation, interactive script, audio drama, or script-before-storyboard work, archive/retrieve references and confirm the spec first; do not write the full script in chat.
The confirmation card should leave creative room for the user: title/working title, source type, target script format, episode count or duration, what to preserve, what may change, dialogue/scene/production constraints. Do not decide fidelity, commercialization, or low-budget adaptation strength for the user; if unclear, say it remains adjustable or ask one key question.
instruction must be self-contained. Also fill scriptCreate when known: title, sourceKind, targetFormat, sourceText/sourcePath, requirements, episodeCount, episodeDuration. sourceText may contain the user's current material or compact summary; if the source is too long, ask the user to provide it through the entry or sourcePath instead of inventing it.
Ask one key question only when title/source/target format are all too vague.
@@ -259,7 +259,7 @@ ${commonOutputRules(false)}`;
return isZh
? `你是 InkOS 分镜创作助手。当前入口负责把剧本、小说片段、创意或场景列表拆成可拍、可画、可继续修改的分镜。
可用工具:propose_actionaction=storyboard_create。用户已经说明想做“分镜 / 镜头表 / 分镜图提示词 / 剧本转分镜 / 小说转分镜”时,先确认规格,不要在聊天里直接写完整分镜。
可用工具:propose_action、ingest_material、retrieve_materialaction=storyboard_create。用户已经说明想做“分镜 / 镜头表 / 分镜图提示词 / 剧本转分镜 / 小说转分镜”时,先归档/召回参考资料并确认规格,不要在聊天里直接写完整分镜。
确认卡要把空间留给用户:标题/暂定名、原素材类型、分镜粒度、画幅、视觉风格、镜头上限、是否需要图像提示词、哪些信息必须保留。不要替用户擅自锁死拍法、风格或镜头数量;没有说清时写“待用户后续调整”或问一个关键问题。
instruction 必须自包含;能确定的执行参数同时填 storyboardCreatetitle、sourceKind、sourceText/sourcePath、requirements、visualStyle、aspectRatio、granularity、maxShots。sourceText 只放用户当前明确给出的素材;素材太长时要求用户通过入口补充 sourcePath,不要凭空改写、压缩或替用户补素材。
只有标题/素材/目标分镜形态都太空时才问一个关键问题。
@@ -267,7 +267,7 @@ instruction 必须自包含;能确定的执行参数同时填 storyboardCreate
${commonOutputRules(true)}`
: `You are the InkOS storyboard creation assistant. This surface turns scripts, novel excerpts, ideas, or scene lists into editable storyboard tables and image prompts.
Available tool: propose_action with action=storyboard_create. When the user asks for storyboard, shot list, storyboard image prompts, script-to-storyboard, or novel-to-storyboard work, confirm the spec first; do not write the full storyboard in chat.
Available tools: propose_action, ingest_material, retrieve_material with action=storyboard_create. When the user asks for storyboard, shot list, storyboard image prompts, script-to-storyboard, or novel-to-storyboard work, archive/retrieve references and confirm the spec first; do not write the full storyboard in chat.
The confirmation card should leave creative room for the user: title/working title, source type, shot granularity, aspect ratio, visual style, max shots, whether image prompts are needed, and what must be preserved. Do not lock shooting style, visual style, or shot count unless the user specified them; if unclear, say it remains adjustable or ask one key question.
instruction must be self-contained. Also fill storyboardCreate when known: title, sourceKind, sourceText/sourcePath, requirements, visualStyle, aspectRatio, granularity, maxShots. sourceText may contain the user's current material or compact summary; if the source is too long, ask the user to provide it through the entry or sourcePath instead of inventing it.
Ask one key question only when title/source/target storyboard form are all too vague.
@@ -295,7 +295,7 @@ ${commonOutputRules(false)}`;
return isZh
? `你是 InkOS 互动影游创作助手。当前入口负责把创意、小说、剧本、大纲或投稿需求整理成可制作的互动影游交付稿。
可用工具:propose_actionaction=interactive_film_create。用户已经说明想做“互动影游 / 互动剧 / 互动叙事类游戏 / 分支剧本 / 多结局影游 / 盛世天下式多走向剧本”时,先确认规格,不要在聊天里直接写完整交付稿。
可用工具:propose_action、ingest_material、retrieve_materialaction=interactive_film_create。用户已经说明想做“互动影游 / 互动剧 / 互动叙事类游戏 / 分支剧本 / 多结局影游 / 盛世天下式多走向剧本”时,先归档/召回参考资料并确认规格,不要在聊天里直接写完整交付稿。
确认卡要把空间留给用户:标题/暂定名、原素材类型、分支结构、多结局目标、变量/旗标系统、目标受众、预算、段落/集数、视觉/分镜要求。不要默认 RPG 数值、战斗公式、装备系统或固定游戏模板;只有用户明确要求才写。
instruction 必须自包含;能确定的执行参数同时填 interactiveFilmCreatetitle、sourceKind、sourceText/sourcePath、requirements、targetAudience、episodeCount、episodeDuration、budget、referenceMode。sourceText 只放用户当前明确给出的素材;素材太长时要求用户通过入口补充 sourcePath,不要凭空改写、压缩或替用户补素材。
只有标题/素材/互动目标都太空时才问一个关键问题。
@@ -303,7 +303,7 @@ instruction 必须自包含;能确定的执行参数同时填 interactiveFilmC
${commonOutputRules(true)}`
: `You are the InkOS interactive-film creation assistant. This surface turns ideas, novels, scripts, outlines, or submission requirements into editable interactive film/game-script deliverables.
Available tool: propose_action with action=interactive_film_create. When the user asks for interactive film, interactive drama, branching narrative game, multi-ending script, or choice-led film/game deliverables, confirm the spec first; do not write the full package in chat.
Available tools: propose_action, ingest_material, retrieve_material with action=interactive_film_create. When the user asks for interactive film, interactive drama, branching narrative game, multi-ending script, or choice-led film/game deliverables, archive/retrieve references and confirm the spec first; do not write the full package in chat.
The confirmation card should leave creative room for the user: title/working title, source type, branching structure, endings, variables/flags, target audience, budget, episode/segment count, visual/storyboard needs. Do not default to RPG stats, combat formulas, equipment systems, or a fixed game template unless the user explicitly asks.
instruction must be self-contained. Also fill interactiveFilmCreate when known: title, sourceKind, sourceText/sourcePath, requirements, targetAudience, episodeCount, episodeDuration, budget, referenceMode. sourceText may contain the user's current material; if the source is too long, ask for sourcePath instead of inventing it.
Ask one key question only when title/source/interactive goal are all too vague.
@@ -336,7 +336,7 @@ ${commonOutputRules(false)}`;
return isZh
? `你是 InkOS Play 助手。当前入口只负责启动新的互动世界,但现在还没有已创建的世界。
现在还没有已创建世界。可用工具:propose_actionaction=play_start。玩家身份、起始地点、压力和核心冲突基本明确时必须调用 propose_action,不要用普通文字手写确认卡。用户说“先确认/确认后开始”时,propose_action 就是确认卡,仍然调用它,不要先用普通文字整理一遍再等用户二次确认。
现在还没有已创建世界。可用工具:propose_action、ingest_material、retrieve_materialaction=play_start。玩家身份、起始地点、压力和核心冲突基本明确时必须调用 propose_action,不要用普通文字手写确认卡。用户上传/归档世界资料时先归档或按需召回,不要自动写入世界。用户说“先确认/确认后开始”时,propose_action 就是确认卡,仍然调用它,不要先用普通文字整理一遍再等用户二次确认。
instruction 必须自包含:世界标题/暂定名、玩家身份、起始地点、压力、核心冲突、开场氛围、交互模式。playStart 必须填 title、premise、mode、initialScene、suggestedActions;开放世界/自由玩填 mode=open,分支互动/点着玩填 mode=guided。
playStart.initialScene 是确认后第一眼展示给玩家的正文场面,必须写成纯叙事,不要写“世界标题/玩家设定/规则摘要/交互模式/你要怎么做/请选择/选项/Suggested actions”。设定摘要放 premise/worldContract,动作跳板放 suggestedActions,不要混进 initialScene。
如果用户明确给了长期规则,把它们原样提炼进 playStart.worldContract:时间尺度如何按动作变化并同步世界、角色是否自主行动、物件/线索/关系/装备/身份有什么语义、哪些事禁止或有代价。用户没说就留空,不要擅自加等级、数值、RPG 面板或固定每回合时间。
@@ -347,7 +347,7 @@ playStart.initialScene 是确认后第一眼展示给玩家的正文场面,必
${commonOutputRules(true)}`
: `You are the InkOS Play assistant. This surface can start a new interactive world, but no world exists yet.
No world exists yet. Available tool: propose_action with action=play_start. When player role, starting location, pressure, and core conflict are basically clear, you must call propose_action; do not hand-write the confirmation card as plain text. If the user says "confirm first" or "start after confirmation", propose_action is that confirmation card; still call it instead of summarizing in plain text and waiting for a second confirmation.
No world exists yet. Available tools: propose_action, ingest_material, retrieve_material with action=play_start. When player role, starting location, pressure, and core conflict are basically clear, you must call propose_action; do not hand-write the confirmation card as plain text. Archive or retrieve uploaded world references when needed, but do not automatically mutate world state. If the user says "confirm first" or "start after confirmation", propose_action is that confirmation card; still call it instead of summarizing in plain text and waiting for a second confirmation.
instruction must be self-contained: title/working title, player role, starting location, pressure, core conflict, opening mood, and interaction mode. Fill playStart: title, premise, mode, initialScene, suggestedActions; use mode=open for open/free-form play and mode=guided for branching/choice-led play.
playStart.initialScene is the first prose shown to the player after confirmation. It must be pure narrative scene text, not "world title", player setup, rule summary, interaction mode, "what do you do?", choices, options, or "Suggested actions". Put setup in premise/worldContract and action springboards in suggestedActions, not in initialScene.
If the user explicitly gave durable rules, distill them into playStart.worldContract: time scale changes by action and synchronizes the world, role autonomy, object/clue/relationship/equipment/identity semantics, taboos, or costs. Leave it empty when unspecified; do not invent levels, stats, RPG panels, or a fixed per-turn time.
@@ -483,6 +483,8 @@ function buildBookPrompt(bookId: string, isZh: boolean): string {
- patch_chapter_text:对已有章节做局部定点修补。
- replace_chapter_text:用户已经给出某章完整替换正文时,整章覆盖并标记复核;不要用它让模型自己生成新正文,模型生成型重写仍走 reviser。
- research_web:用户明确要求联网研究、事实核查、年代/职业/地域/制度资料时使用;报告保存为参考材料,不会自动改当前书设定或正文。
- ingest_material:用户给 URL、上传 PDF/Markdown/文本资料,或要求“先读/归档这份资料”时使用;资料卡保存在 .inkos/materials,不会自动改当前书设定或正文。
- retrieve_material:基于当前任务从 .inkos/materials 召回相关片段;返回带路径和字符范围的证据指针。它只读取参考资料,不改设定或正文。
- grep:搜索内容。
- ls:列出文件或章节。
@@ -504,7 +506,7 @@ function buildBookPrompt(bookId: string, isZh: boolean): string {
- 用户要求某章内局部小修 → patch_chapter_text。
- 用户粘贴/提供某章完整新正文并要求替换 → replace_chapter_text。
- 用户要求生成或重做封面 → generate_cover。
- 用户要求查外部事实、年代职业细节、真实地域制度资料 → research_web;如需把研究结果写入设定,必须再由用户明确确认后用 write_truth_file。
- 用户要求查外部事实、年代职业细节、真实地域制度资料 → research_web用户提供 URL/PDF/文本资料 → ingest_material;用户要求基于已归档资料回答、对照、续写或整理 → retrieve_material如需把研究结果或资料内容写入设定,必须再由用户明确确认后用 write_truth_file。
- 其他普通讨论 → 直接回答。
## 章节索引
@@ -539,6 +541,8 @@ ${commonOutputRules(true)}`
- patch_chapter_text: apply a local chapter patch.
- replace_chapter_text: replace a whole chapter only when the user provides the complete replacement chapter text; mark it for review. Do not use it for model-generated rewrites — use reviser.
- research_web: collect web research or fact checks for era/profession/region/institution details. Reports are saved as reference material and do not automatically change canon or prose.
- ingest_material: archive a user-provided URL, uploaded PDF, Markdown, or text file into .inkos/materials. Material cards are references only and do not automatically change canon or prose.
- retrieve_material: retrieve task-relevant snippets from .inkos/materials with path and character-range evidence pointers. It reads reference materials only and does not change canon or prose.
- grep: search content.
- ls: list files or chapters.
@@ -560,7 +564,7 @@ ${commonOutputRules(true)}`
- Local chapter edits → patch_chapter_text.
- User-provided full replacement for an existing chapter → replace_chapter_text.
- Cover generation/regeneration → generate_cover.
- External facts, era/profession details, or real-world regional/institutional references → research_web. If the research should affect canon, wait for explicit confirmation and then use write_truth_file.
- External facts, era/profession details, or real-world regional/institutional references → research_web. User-provided URLs/PDF/text files → ingest_material. Archived-material questions, comparisons, continuations, or summaries → retrieve_material. If research or material content should affect canon, wait for explicit confirmation and then use write_truth_file.
- Ordinary discussion → answer directly.
## Chapter Index
+168
View File
@@ -16,6 +16,8 @@ import { normalizePlatformId, normalizePlatformOrOther } from "../models/book.js
import { generateShortFictionCover, runShortFictionProduction } from "../pipeline/short-fiction-runner.js";
import { runInteractiveFilmCreation, runScriptCreation, runStoryboardCreation } from "../pipeline/script-storyboard-runner.js";
import { runResearchReport } from "../agents/researcher.js";
import { ingestMaterial } from "../materials/ingest.js";
import { retrieveMaterials } from "../materials/retrieve.js";
import type { ScriptTargetFormat } from "../agents/script-storyboard.js";
import { createPlayDB, type PlayGraphDB } from "../play/play-db-factory.js";
import { PlayRunner, type PlayOpeningSeedResult, type PlayReplayResult, type PlayStepResult, type PlayVariantRestoreResult } from "../play/play-runner.js";
@@ -878,6 +880,172 @@ export function createResearchWebTool(projectRoot: string): AgentTool<typeof Res
};
}
// ---------------------------------------------------------------------------
// 3. Material Ingestion Tool (ingest_material)
// ---------------------------------------------------------------------------
const IngestMaterialParams = Type.Object({
sourceKind: Type.Union([
Type.Literal("url"),
Type.Literal("file"),
], {
description: "Use url for an external URL; use file for a user-uploaded file path shown in the Uploaded Files block.",
}),
url: Type.Optional(Type.String({
description: "HTTP/HTTPS URL to fetch and extract. Supports HTML/text/JSON/PDF.",
})),
filePath: Type.Optional(Type.String({
description: "Project-relative stored_path from the Uploaded Files block, e.g. .inkos/uploads/session/file.pdf.",
})),
filename: Type.Optional(Type.String({
description: "Original filename when known.",
})),
mimeType: Type.Optional(Type.String({
description: "MIME type when known, e.g. application/pdf or text/markdown.",
})),
title: Type.Optional(Type.String({
description: "Human-readable material title.",
})),
purpose: Type.Optional(Type.Union([
Type.Literal("reference"),
Type.Literal("worldbuilding"),
Type.Literal("script"),
Type.Literal("storyboard"),
Type.Literal("research"),
Type.Literal("general"),
], {
description: "Why this material is being ingested. It remains reference material unless the user explicitly promotes it.",
})),
});
type IngestMaterialParamsType = Static<typeof IngestMaterialParams>;
export function createIngestMaterialTool(projectRoot: string): AgentTool<typeof IngestMaterialParams> {
return {
name: "ingest_material",
description:
"Extract and archive a user-provided URL or uploaded file into .inkos/materials as traceable Markdown. " +
"Supports HTML/text/JSON/Markdown/PDF. This creates reference material only; it must not mutate canon, chapters, scripts, or play state.",
label: "Ingest Material",
parameters: IngestMaterialParams,
async execute(
_toolCallId: string,
params: IngestMaterialParamsType,
_signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback,
): Promise<AgentToolResult<unknown>> {
onUpdate?.(textResult(params.sourceKind === "url"
? `Extracting URL: ${params.url ?? "(missing)"}`
: `Extracting file: ${params.filePath ?? params.filename ?? "(missing)"}`));
const asset = await ingestMaterial(projectRoot, {
sourceKind: params.sourceKind,
url: params.url,
filePath: params.filePath,
filename: params.filename,
mimeType: params.mimeType,
title: params.title,
purpose: params.purpose ?? "reference",
});
return textResult(
[
`Material ingested: ${asset.markdownPath}`,
`Kind: ${asset.kind}; chars: ${asset.charCount}; source: ${asset.source}`,
asset.totalPages !== undefined ? `PDF pages: ${asset.totalPages}` : "",
"",
"Excerpt:",
asset.excerpt,
].filter(Boolean).join("\n"),
{
kind: "material_ingested",
asset,
},
);
},
};
}
// ---------------------------------------------------------------------------
// 4. Material Retrieval Tool (retrieve_material)
// ---------------------------------------------------------------------------
const RetrieveMaterialParams = Type.Object({
query: Type.String({
description: "Natural-language query written by the agent from the user's current task, e.g. 冷库赔偿款 0607 账页 or storyboard shot requirements.",
}),
purpose: Type.Optional(Type.Union([
Type.Literal("reference"),
Type.Literal("worldbuilding"),
Type.Literal("script"),
Type.Literal("storyboard"),
Type.Literal("research"),
Type.Literal("general"),
], {
description: "Optional material purpose filter.",
})),
limit: Type.Optional(Type.Number({
description: "Maximum number of material snippets to return. Default 5.",
})),
});
type RetrieveMaterialParamsType = Static<typeof RetrieveMaterialParams>;
export function createRetrieveMaterialTool(projectRoot: string): AgentTool<typeof RetrieveMaterialParams> {
return {
name: "retrieve_material",
description:
"Retrieve traceable snippets from previously ingested .inkos/materials reference cards. " +
"The agent supplies the semantic query; InkOS returns evidence pointers. This must not mutate canon, chapters, scripts, or play state.",
label: "Retrieve Material",
parameters: RetrieveMaterialParams,
async execute(
_toolCallId: string,
params: RetrieveMaterialParamsType,
_signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback,
): Promise<AgentToolResult<unknown>> {
onUpdate?.(textResult(`Retrieving materials: ${params.query}`));
const results = await retrieveMaterials(projectRoot, {
query: params.query,
purpose: params.purpose,
limit: params.limit,
});
if (results.length === 0) {
return textResult(
"No matching archived materials were found. Ask the user to upload or ingest relevant material if needed.",
{
kind: "material_retrieval",
query: params.query,
purpose: params.purpose,
results: [],
},
);
}
return textResult(
[
`Retrieved ${results.length} material snippet${results.length === 1 ? "" : "s"}.`,
"",
...results.flatMap((result, index) => [
`## ${index + 1}. ${result.title}`,
`- source: ${result.source}`,
`- path: ${result.markdownPath}:${result.charStart}-${result.charEnd}`,
`- purpose: ${result.purpose}`,
`- score: ${result.score.toFixed(2)}`,
"",
result.excerpt,
"",
]),
].join("\n"),
{
kind: "material_retrieval",
query: params.query,
purpose: params.purpose,
results,
},
);
},
};
}
function slugResearchTopic(topic: string): string {
const slug = topic
.normalize("NFKC")
+9 -1
View File
@@ -12,6 +12,7 @@ export {
createStoryboardCreationTool,
createInteractiveFilmCreationTool,
createResearchWebTool,
createIngestMaterialTool,
createGenerateCoverTool,
createPlayStartTool,
createPlayReviseTool,
@@ -19,7 +20,14 @@ export {
createGrepTool,
createLsTool,
} from "./agent-tools.js";
export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";
export {
abortAgentSession,
runAgentSession,
evictAgentCache,
type AgentSessionAttachment,
type AgentSessionConfig,
type AgentSessionResult,
} from "./agent-session.js";
export { createBookContextTransform } from "./context-transform.js";
export {
createSetWorldAnchorTool,
+14 -1
View File
@@ -122,6 +122,7 @@ export async function composeGovernedChapter(input: ComposeChapterInput): Promis
usedSkills: skillContextPlan.usedSkillIds,
promptPacks: skillContextPlan.promptPackIds,
contextNeeds: skillContextPlan.contextNeedIds,
compression: budgeted.compression,
});
const {
contextPath,
@@ -153,7 +154,11 @@ async function applyContextBudgetIfNeeded(params: {
readonly contextBudget?: ContextBudget;
readonly compiler?: CompressibleContextCompiler;
readonly onContextCompression?: ContextCompressionCallback;
}): Promise<{ readonly contextPackage: ContextPackage; readonly notes: string[] }> {
}): Promise<{
readonly contextPackage: ContextPackage;
readonly notes: string[];
readonly compression?: ChapterTrace["compression"];
}> {
const budget = params.contextBudget;
if (!budget || budget.contextWindowTokens <= 0) {
return { contextPackage: params.contextPackage, notes: [] };
@@ -269,6 +274,14 @@ async function applyContextBudgetIfNeeded(params: {
],
}),
notes: ["compiled-compressible-context"],
compression: {
compiledSource: "runtime/compiled-compressible-context",
protectedSources: protectedEntries.map((entry) => entry.source),
compressedSources: compressibleEntries.map((entry) => entry.source),
protectedTokens,
compressibleTokens,
budgetTokens: compileBudget,
},
};
}
+287
View File
@@ -0,0 +1,287 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, extname, join, relative } from "node:path";
import { extractText, getDocumentProxy } from "unpdf";
import { safeChildPath } from "../utils/path-safety.js";
export type MaterialPurpose = "reference" | "worldbuilding" | "script" | "storyboard" | "research" | "general";
export type MaterialSourceKind = "url" | "file";
export type MaterialKind = "webpage" | "pdf" | "text";
export interface IngestMaterialInput {
readonly sourceKind: MaterialSourceKind;
readonly url?: string;
readonly filePath?: string;
readonly filename?: string;
readonly mimeType?: string;
readonly title?: string;
readonly purpose?: MaterialPurpose;
}
export interface MaterialAsset {
readonly id: string;
readonly title: string;
readonly kind: MaterialKind;
readonly purpose: MaterialPurpose;
readonly source: string;
readonly mimeType: string;
readonly markdownPath: string;
readonly manifestPath: string;
readonly charCount: number;
readonly excerpt: string;
readonly totalPages?: number;
}
export interface IngestMaterialDeps {
readonly fetch?: typeof fetch;
readonly now?: () => Date;
}
const MAX_SOURCE_BYTES = 18 * 1024 * 1024;
const EXCERPT_CHARS = 1600;
export async function ingestMaterial(
projectRoot: string,
input: IngestMaterialInput,
deps: IngestMaterialDeps = {},
): Promise<MaterialAsset> {
const now = deps.now?.() ?? new Date();
const purpose = input.purpose ?? "reference";
const source = await readMaterialSource(projectRoot, input, deps);
const title = (input.title?.trim() || source.title || titleFromSource(input) || "material").slice(0, 120);
const id = `${now.toISOString().replace(/[:.]/g, "-")}-${slug(title)}`;
const materialsDir = join(projectRoot, ".inkos", "materials");
await mkdir(materialsDir, { recursive: true });
const markdown = renderMaterialMarkdown({
title,
kind: source.kind,
purpose,
source: source.source,
mimeType: source.mimeType,
totalPages: source.totalPages,
text: source.text,
});
const markdownPathAbs = join(materialsDir, `${id}.md`);
const manifestPathAbs = join(materialsDir, `${id}.json`);
await writeFile(markdownPathAbs, markdown, "utf-8");
const asset: MaterialAsset = {
id,
title,
kind: source.kind,
purpose,
source: source.source,
mimeType: source.mimeType,
markdownPath: relative(projectRoot, markdownPathAbs),
manifestPath: relative(projectRoot, manifestPathAbs),
charCount: source.text.length,
excerpt: source.text.slice(0, EXCERPT_CHARS),
...(source.totalPages !== undefined ? { totalPages: source.totalPages } : {}),
};
await writeFile(manifestPathAbs, JSON.stringify(asset, null, 2), "utf-8");
return asset;
}
interface MaterialSource {
readonly kind: MaterialKind;
readonly source: string;
readonly title?: string;
readonly mimeType: string;
readonly text: string;
readonly totalPages?: number;
}
async function readMaterialSource(
projectRoot: string,
input: IngestMaterialInput,
deps: IngestMaterialDeps,
): Promise<MaterialSource> {
if (input.sourceKind === "url") {
if (!input.url) throw new Error("ingest_material.url is required for URL sources.");
return readUrlMaterial(input.url, deps.fetch ?? fetch);
}
if (!input.filePath) throw new Error("ingest_material.filePath is required for file sources.");
const safePath = safeChildPath(projectRoot, input.filePath);
const buffer = await readFile(safePath);
if (buffer.byteLength > MAX_SOURCE_BYTES) {
throw new Error(`Material file is too large (${buffer.byteLength} bytes).`);
}
const filename = input.filename || basename(safePath);
const mimeType = input.mimeType || mimeFromFilename(filename);
return extractBufferMaterial(buffer, {
source: relative(projectRoot, safePath),
filename,
mimeType,
});
}
async function readUrlMaterial(url: string, fetchImpl: typeof fetch): Promise<MaterialSource> {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
}
const response = await fetchImpl(url, {
headers: {
"User-Agent": "InkOS/1.6 material-ingestion",
"Accept": "text/html, text/plain, application/json, application/pdf, */*",
},
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status} ${response.statusText}`);
}
const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || mimeFromFilename(parsed.pathname);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.byteLength > MAX_SOURCE_BYTES) {
throw new Error(`Fetched material is too large (${buffer.byteLength} bytes).`);
}
return extractBufferMaterial(buffer, {
source: url,
filename: basename(parsed.pathname) || parsed.hostname,
mimeType,
});
}
async function extractBufferMaterial(
buffer: Buffer,
meta: { readonly source: string; readonly filename: string; readonly mimeType: string },
): Promise<MaterialSource> {
const mimeType = meta.mimeType || mimeFromFilename(meta.filename);
if (isPdf(meta.filename, mimeType)) {
const pdf = await getDocumentProxy(new Uint8Array(buffer));
const extracted = await extractText(pdf, { mergePages: true });
const text = normalizeText(extracted.text);
if (!text) throw new Error("PDF text extraction returned no text. Scanned PDFs require OCR and are not supported yet.");
return {
kind: "pdf",
source: meta.source,
title: stripExtension(meta.filename),
mimeType: "application/pdf",
text,
totalPages: extracted.totalPages,
};
}
const raw = buffer.toString("utf-8");
if (isHtml(meta.filename, mimeType)) {
return {
kind: "webpage",
source: meta.source,
title: extractHtmlTitle(raw) || stripExtension(meta.filename),
mimeType,
text: normalizeText(htmlToText(raw)),
};
}
if (isTextLike(meta.filename, mimeType)) {
return {
kind: "text",
source: meta.source,
title: stripExtension(meta.filename),
mimeType,
text: normalizeText(raw),
};
}
throw new Error(`Unsupported material type: ${mimeType || meta.filename}`);
}
function renderMaterialMarkdown(input: {
readonly title: string;
readonly kind: MaterialKind;
readonly purpose: MaterialPurpose;
readonly source: string;
readonly mimeType: string;
readonly text: string;
readonly totalPages?: number;
}): string {
return [
`# ${input.title}`,
"",
"## Metadata",
`- kind: ${input.kind}`,
`- purpose: ${input.purpose}`,
`- source: ${input.source}`,
`- mime_type: ${input.mimeType}`,
input.totalPages !== undefined ? `- total_pages: ${input.totalPages}` : "",
`- char_count: ${input.text.length}`,
"",
"## Extracted content",
input.text,
"",
].filter((line) => line !== "").join("\n");
}
function mimeFromFilename(filename: string): string {
const ext = extname(filename).toLowerCase();
if (ext === ".pdf") return "application/pdf";
if (ext === ".html" || ext === ".htm") return "text/html";
if (ext === ".json") return "application/json";
if (ext === ".md" || ext === ".markdown") return "text/markdown";
if (ext === ".csv") return "text/csv";
return "text/plain";
}
function isPdf(filename: string, mimeType: string): boolean {
return mimeType.includes("pdf") || extname(filename).toLowerCase() === ".pdf";
}
function isHtml(filename: string, mimeType: string): boolean {
const ext = extname(filename).toLowerCase();
return mimeType.includes("html") || ext === ".html" || ext === ".htm";
}
function isTextLike(filename: string, mimeType: string): boolean {
if (mimeType.startsWith("text/")) return true;
if (mimeType.includes("json") || mimeType.includes("xml") || mimeType.includes("yaml")) return true;
return [".txt", ".md", ".markdown", ".json", ".csv", ".tsv", ".yaml", ".yml", ".log"].includes(extname(filename).toLowerCase());
}
function htmlToText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ");
}
function extractHtmlTitle(html: string): string | undefined {
const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
return match ? decodeHtml(match[1]).trim().slice(0, 120) : undefined;
}
function decodeHtml(value: string): string {
return value
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, "\"")
.replace(/&#39;/gi, "'");
}
function normalizeText(value: string): string {
return decodeHtml(value).replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
function stripExtension(filename: string): string {
const ext = extname(filename);
return ext ? filename.slice(0, -ext.length) : filename;
}
function titleFromSource(input: IngestMaterialInput): string | undefined {
if (input.filename) return stripExtension(input.filename);
if (input.filePath) return stripExtension(basename(input.filePath));
if (!input.url) return undefined;
try {
const parsed = new URL(input.url);
return stripExtension(basename(parsed.pathname)) || parsed.hostname;
} catch {
return undefined;
}
}
function slug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "material";
}
+134
View File
@@ -0,0 +1,134 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { safeChildPath } from "../utils/path-safety.js";
import type { MaterialAsset, MaterialPurpose } from "./ingest.js";
export interface RetrieveMaterialsInput {
readonly query: string;
readonly purpose?: MaterialPurpose;
readonly limit?: number;
}
export interface RetrievedMaterial {
readonly id: string;
readonly title: string;
readonly kind: MaterialAsset["kind"];
readonly purpose: MaterialPurpose;
readonly source: string;
readonly markdownPath: string;
readonly score: number;
readonly excerpt: string;
readonly charStart: number;
readonly charEnd: number;
}
const DEFAULT_LIMIT = 5;
const MAX_LIMIT = 12;
const SNIPPET_RADIUS = 700;
export async function retrieveMaterials(
projectRoot: string,
input: RetrieveMaterialsInput,
): Promise<RetrievedMaterial[]> {
const queryTerms = extractTerms(input.query);
const assets = await listMaterialAssets(projectRoot);
const results: RetrievedMaterial[] = [];
for (const asset of assets) {
if (input.purpose && asset.purpose !== input.purpose) continue;
const markdownPath = safeChildPath(projectRoot, asset.markdownPath);
let markdown = "";
try {
markdown = await readFile(markdownPath, "utf-8");
} catch {
continue;
}
const score = scoreMaterial(asset, markdown, queryTerms);
if (queryTerms.length > 0 && score <= 0) continue;
const snippet = buildSnippet(markdown, queryTerms);
results.push({
id: asset.id,
title: asset.title,
kind: asset.kind,
purpose: asset.purpose,
source: asset.source,
markdownPath: asset.markdownPath,
score,
excerpt: snippet.excerpt,
charStart: snippet.charStart,
charEnd: snippet.charEnd,
});
}
return results
.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
.slice(0, normalizeLimit(input.limit));
}
async function listMaterialAssets(projectRoot: string): Promise<MaterialAsset[]> {
const materialsDir = join(projectRoot, ".inkos", "materials");
let entries: string[] = [];
try {
entries = await readdir(materialsDir);
} catch {
return [];
}
const assets: MaterialAsset[] = [];
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
try {
const raw = await readFile(join(materialsDir, entry), "utf-8");
const asset = JSON.parse(raw) as MaterialAsset;
if (asset.id && asset.markdownPath && asset.title) assets.push(asset);
} catch {
// Ignore corrupt stale manifests; retrieval should not break the chat turn.
}
}
return assets;
}
function scoreMaterial(asset: MaterialAsset, markdown: string, terms: readonly string[]): number {
if (terms.length === 0) return 1;
const title = asset.title.toLowerCase();
const source = asset.source.toLowerCase();
const body = markdown.toLowerCase();
let score = 0;
for (const term of terms) {
const normalized = term.toLowerCase();
if (title.includes(normalized)) score += 8;
if (source.includes(normalized)) score += 4;
const first = body.indexOf(normalized);
if (first >= 0) score += 2 + Math.max(0, 2 - first / 4000);
}
return score;
}
function buildSnippet(markdown: string, terms: readonly string[]): { excerpt: string; charStart: number; charEnd: number } {
const lower = markdown.toLowerCase();
let hit = -1;
for (const term of terms) {
const idx = lower.indexOf(term.toLowerCase());
if (idx >= 0 && (hit < 0 || idx < hit)) hit = idx;
}
const center = hit >= 0 ? hit : Math.min(markdown.length, 500);
const charStart = Math.max(0, center - SNIPPET_RADIUS);
const charEnd = Math.min(markdown.length, center + SNIPPET_RADIUS);
return {
excerpt: markdown.slice(charStart, charEnd).trim(),
charStart,
charEnd,
};
}
function normalizeLimit(limit: number | undefined): number {
if (!Number.isFinite(limit ?? DEFAULT_LIMIT)) return DEFAULT_LIMIT;
return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit ?? DEFAULT_LIMIT)));
}
function extractTerms(query: string): string[] {
const raw = query.normalize("NFKC").trim().toLowerCase();
if (!raw) return [];
const terms = new Set<string>();
for (const match of raw.matchAll(/[\p{L}\p{N}]{2,}/gu)) {
terms.add(match[0]);
}
return [...terms].slice(0, 24);
}
@@ -112,6 +112,14 @@ export const ChapterTraceSchema = z.object({
compressibleTokens: 0,
totalSelectedTokens: 0,
}),
compression: z.object({
compiledSource: z.string().min(1),
protectedSources: z.array(z.string()).default([]),
compressedSources: z.array(z.string()).default([]),
protectedTokens: z.number().int().nonnegative().default(0),
compressibleTokens: z.number().int().nonnegative().default(0),
budgetTokens: z.number().int().nonnegative().default(0),
}).optional(),
notes: z.array(z.string()).default([]),
});
@@ -92,6 +92,7 @@ export function buildGovernedTrace(params: {
readonly usedSkills?: ReadonlyArray<string>;
readonly promptPacks?: ReadonlyArray<string>;
readonly contextNeeds?: ReadonlyArray<string>;
readonly compression?: ChapterTrace["compression"];
}): ChapterTrace {
const protectedEntries = params.contextPackage.selectedContext.filter((entry) =>
isProtectedContextSource(entry.source),
@@ -119,6 +120,7 @@ export function buildGovernedTrace(params: {
compressibleTokens,
totalSelectedTokens: protectedTokens + compressibleTokens,
},
...(params.compression ? { compression: params.compression } : {}),
notes: params.notes ?? [],
});
}
+69
View File
@@ -30,6 +30,7 @@ const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
const loadProjectSessionMock = vi.fn();
const resolveSessionActiveBookMock = vi.fn();
const runAgentSessionMock = vi.fn();
const abortAgentSessionMock = vi.fn();
const playRunnerStepMock = vi.fn();
const playRunnerCtorArgs: unknown[] = [];
const generatePlayImageMock = vi.fn();
@@ -249,6 +250,7 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
loadProjectSession: loadProjectSessionMock,
resolveSessionActiveBook: resolveSessionActiveBookMock,
runAgentSession: runAgentSessionMock,
abortAgentSession: abortAgentSessionMock,
createSubAgentTool: actual.createSubAgentTool,
createShortFictionRunTool: actual.createShortFictionRunTool,
createGenerateCoverTool: actual.createGenerateCoverTool,
@@ -511,6 +513,7 @@ describe("createStudioServer daemon lifecycle", () => {
rollbackToChapterMock.mockResolvedValue([]);
pipelineConfigs.length = 0;
runAgentSessionMock.mockReset();
abortAgentSessionMock.mockReset();
playRunnerStepMock.mockReset();
playRunnerCtorArgs.length = 0;
playRunnerStepMock.mockResolvedValue({
@@ -2600,6 +2603,20 @@ describe("createStudioServer daemon lifecycle", () => {
await expect(response.json()).resolves.toEqual({ ok: true });
});
it("aborts a cached agent session through POST /api/v1/sessions/:sessionId/abort", async () => {
abortAgentSessionMock.mockReturnValueOnce(true);
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/v1/sessions/agent-session-1/abort", {
method: "POST",
});
expect(response.status).toBe(200);
expect(abortAgentSessionMock).toHaveBeenCalledWith(root, "agent-session-1");
await expect(response.json()).resolves.toEqual({ ok: true, aborted: true });
});
it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
runAgentSessionMock.mockImplementationOnce(async (config: { onEvent?: (event: unknown) => void }) => {
config.onEvent?.({
@@ -2652,6 +2669,58 @@ describe("createStudioServer daemon lifecycle", () => {
);
});
it("stores uploaded attachments and forwards them to the agent session", async () => {
const note = Buffer.from("# 参考资料\n主角必须保留第一人称。", "utf-8").toString("base64");
const image = Buffer.from("fakepng", "utf-8").toString("base64");
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction: "按附件继续讨论",
activeBookId: "demo-book",
sessionId: "agent-session-1",
attachments: [
{
id: "note-1",
filename: "brief.md",
mediaType: "text/markdown",
size: Buffer.byteLength(note, "base64"),
dataUrl: `data:text/markdown;base64,${note}`,
},
{
id: "img-1",
filename: "reference.png",
mediaType: "image/png",
size: Buffer.byteLength(image, "base64"),
dataUrl: `data:image/png;base64,${image}`,
},
],
}),
});
expect(response.status).toBe(200);
const agentConfig = runAgentSessionMock.mock.calls.at(-1)?.[0] as { attachments?: Array<Record<string, unknown>> };
expect(agentConfig.attachments).toHaveLength(2);
expect(agentConfig.attachments?.[0]).toMatchObject({
id: "note-1",
filename: "brief.md",
mimeType: "text/markdown",
text: "# 参考资料\n主角必须保留第一人称。",
});
expect(agentConfig.attachments?.[1]).toMatchObject({
id: "img-1",
filename: "reference.png",
mimeType: "image/png",
image: { data: image, mimeType: "image/png" },
});
const storedPath = agentConfig.attachments?.[0]?.storedPath;
expect(typeof storedPath).toBe("string");
await expect(access(join(root, storedPath as string))).resolves.toBeUndefined();
});
it("executes confirmed create-book action directly without asking the chat model to call tools", async () => {
loadBookSessionMock.mockResolvedValueOnce({
sessionId: "agent-session-1",
+134 -1
View File
@@ -22,6 +22,7 @@ import {
deleteBookSession,
migrateBookSession,
SessionAlreadyMigratedError,
abortAgentSession,
runAgentSession,
resolveServicePreset,
resolveServiceProviderFamily,
@@ -107,6 +108,7 @@ import {
type LogEntry,
type RequestedIntent,
type SessionKind,
type AgentSessionAttachment,
} from "@actalk/inkos-core";
import { access, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -489,6 +491,126 @@ function normalizeStudioSkillId(value: unknown, field = "skillId"): string {
return id;
}
type StudioAgentAttachmentPayload = {
readonly id?: string;
readonly filename?: string;
readonly mediaType?: string;
readonly size?: number;
readonly dataUrl?: string;
};
const MAX_AGENT_ATTACHMENTS = 8;
const MAX_AGENT_ATTACHMENT_BYTES = 4 * 1024 * 1024;
const MAX_AGENT_ATTACHMENT_TEXT_CHARS = 120_000;
function safeUploadFileName(value: string): string {
const trimmed = value.trim().replace(/[/\\\0]/g, "_").replace(/\s+/g, " ");
const safe = trimmed.replace(/[^\p{L}\p{N}._ -]+/gu, "_").slice(0, 120).trim();
return safe || "upload";
}
function isTextAttachment(filename: string, mimeType: string): boolean {
const lower = filename.toLowerCase();
return mimeType.startsWith("text/")
|| [
".txt",
".md",
".markdown",
".json",
".csv",
".tsv",
".yaml",
".yml",
".log",
].some((suffix) => lower.endsWith(suffix));
}
function parseDataUrl(dataUrl: string): { mimeType: string; buffer: Buffer } {
const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(dataUrl);
if (!match) {
throw new ApiError(400, "INVALID_ATTACHMENT_DATA_URL", "Attachment must be a base64 data URL");
}
const mimeType = match[1]?.trim() || "application/octet-stream";
return { mimeType, buffer: Buffer.from(match[2] ?? "", "base64") };
}
async function normalizeAgentAttachments(
root: string,
sessionId: string,
value: unknown,
): Promise<AgentSessionAttachment[]> {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
throw new ApiError(400, "INVALID_ATTACHMENTS", "attachments must be an array");
}
if (value.length > MAX_AGENT_ATTACHMENTS) {
throw new ApiError(413, "TOO_MANY_ATTACHMENTS", `At most ${MAX_AGENT_ATTACHMENTS} files can be attached to one message`);
}
const uploadDir = join(root, ".inkos", "uploads", safeUploadFileName(sessionId));
const out: AgentSessionAttachment[] = [];
for (const [index, raw] of value.entries()) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new ApiError(400, "INVALID_ATTACHMENT", "Each attachment must be an object");
}
const payload = raw as StudioAgentAttachmentPayload;
const filename = safeUploadFileName(payload.filename || `upload-${index + 1}`);
if (!payload.dataUrl) {
throw new ApiError(400, "INVALID_ATTACHMENT", `Attachment ${filename} is missing dataUrl`);
}
const parsed = parseDataUrl(payload.dataUrl);
const mimeType = payload.mediaType?.trim() || parsed.mimeType;
if (parsed.buffer.byteLength > MAX_AGENT_ATTACHMENT_BYTES) {
throw new ApiError(413, "ATTACHMENT_TOO_LARGE", `${filename} exceeds ${MAX_AGENT_ATTACHMENT_BYTES} bytes`);
}
await mkdir(uploadDir, { recursive: true });
const storedName = `${Date.now()}-${index + 1}-${filename}`;
const storedPath = join(uploadDir, storedName);
await writeFile(storedPath, parsed.buffer);
const relPath = relative(root, storedPath);
if (mimeType.startsWith("image/")) {
out.push({
id: payload.id || `${Date.now()}-${index}`,
filename,
mimeType,
size: parsed.buffer.byteLength,
storedPath: relPath,
image: {
data: parsed.buffer.toString("base64"),
mimeType,
},
});
continue;
}
if (isTextAttachment(filename, mimeType)) {
const text = parsed.buffer.toString("utf-8");
if (text.length > MAX_AGENT_ATTACHMENT_TEXT_CHARS) {
throw new ApiError(413, "ATTACHMENT_TEXT_TOO_LARGE", `${filename} is too large to inject without semantic compaction`);
}
out.push({
id: payload.id || `${Date.now()}-${index}`,
filename,
mimeType,
size: parsed.buffer.byteLength,
storedPath: relPath,
text,
});
continue;
}
out.push({
id: payload.id || `${Date.now()}-${index}`,
filename,
mimeType,
size: parsed.buffer.byteLength,
storedPath: relPath,
});
}
return out;
}
function projectSkillsDir(root: string): string {
return join(root, ".inkos", "skills");
}
@@ -3740,6 +3862,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
return c.json({ ok: true });
});
app.post("/api/v1/sessions/:sessionId/abort", async (c) => {
const sessionId = c.req.param("sessionId");
const aborted = abortAgentSession(root, sessionId);
broadcast("agent:aborted", { sessionId, aborted });
return c.json({ ok: true, aborted });
});
app.post("/api/v1/agent", async (c) => {
const {
instruction,
@@ -3751,6 +3880,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
actionPayload: reqActionPayload,
requestedSkills: reqRequestedSkills,
disabledSkills: reqDisabledSkills,
attachments: reqAttachments,
playMode: reqPlayMode,
model: reqModel,
service: reqService,
@@ -3764,6 +3894,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
actionPayload?: unknown;
requestedSkills?: unknown;
disabledSkills?: unknown;
attachments?: unknown;
playMode?: string;
model?: string;
service?: string;
@@ -3785,9 +3916,10 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
const actionPayload = normalizeStudioActionPayload(reqActionPayload);
const requestedSkills = normalizeStudioSkillIdList(reqRequestedSkills, "requestedSkills");
const disabledSkills = normalizeStudioSkillIdList(reqDisabledSkills, "disabledSkills");
const attachments = await normalizeAgentAttachments(root, sessionId, reqAttachments);
const playMode = normalizeStudioPlayMode(reqPlayMode);
broadcast("agent:start", { instruction, activeBookId, sessionId, actionSource, requestedIntent, requestedSkills });
broadcast("agent:start", { instruction, activeBookId, sessionId, actionSource, requestedIntent, requestedSkills, attachments: attachments.length });
try {
// Load config + create LLM client (pipeline created after model resolution)
@@ -4234,6 +4366,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
actionPayload,
requestedSkills,
disabledSkills,
attachments,
sessionId: bookSession.sessionId,
language: surfaceLanguage,
onContextCompression: (event) => {
+141 -10
View File
@@ -3,7 +3,7 @@ import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import type { SSEMessage } from "../hooks/use-sse";
import { fetchJson, postApi, useApi } from "../hooks/use-api";
import type { MessagePart } from "../store/chat/types";
import type { ChatAttachmentPayload, MessagePart } from "../store/chat/types";
import { chatSelectors, useChatStore } from "../store/chat";
import type { ChatSessionKind } from "../store/chat";
import { useServiceStore } from "../store/service";
@@ -26,15 +26,16 @@ import { PlayHud } from "../components/chat/PlayHud";
import { PlayChoicePanel } from "../components/chat/PlayChoicePanel";
import { latestPlayChoiceSet } from "../components/chat/play-choices";
import {
Loader2,
BotMessageSquare,
ArrowUp,
ChevronDown,
Check,
Plus,
X,
Paperclip,
Gamepad2,
Palette,
Square,
} from "lucide-react";
import { Shimmer } from "../components/ai-elements/shimmer";
import {
@@ -105,6 +106,51 @@ interface CoverConfigResponse {
readonly providers?: ReadonlyArray<{ readonly service: string; readonly connected?: boolean }>;
}
const MAX_CHAT_ATTACHMENTS = 8;
const MAX_CHAT_ATTACHMENT_BYTES = 4 * 1024 * 1024;
const CHAT_ATTACHMENT_ACCEPT = [
"image/*",
"text/plain",
"text/markdown",
"application/json",
"text/csv",
".txt",
".md",
".markdown",
".json",
".csv",
".tsv",
".yaml",
".yml",
".log",
".pdf",
].join(",");
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
reader.onload = () => resolve(String(reader.result ?? ""));
reader.readAsDataURL(file);
});
}
async function serializeChatAttachments(files: ReadonlyArray<File>): Promise<ChatAttachmentPayload[]> {
return Promise.all(files.map(async (file) => ({
id: `${file.name}-${file.size}-${file.lastModified}`,
filename: file.name,
mediaType: file.type || "application/octet-stream",
size: file.size,
dataUrl: await fileToDataUrl(file),
})));
}
function formatFileSize(size: number): string {
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
if (size >= 1024) return `${Math.ceil(size / 1024)} KB`;
return `${size} B`;
}
interface SkillsResponse {
readonly skills: ReadonlyArray<StudioSkill>;
readonly diagnostics?: ReadonlyArray<{ readonly path?: string; readonly message?: string }>;
@@ -373,6 +419,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
// -- Store actions --
const setInput = useChatStore((s) => s.setInput);
const sendMessage = useChatStore((s) => s.sendMessage);
const abortSession = useChatStore((s) => s.abortSession);
const setSelectedModel = useChatStore((s) => s.setSelectedModel);
const loadSessionList = useChatStore((s) => s.loadSessionList);
const createSession = useChatStore((s) => s.createSession);
@@ -384,6 +431,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
const scrollRef = useRef<HTMLDivElement>(null);
const scrollFrameRef = useRef<ScrollFrameId | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const autoScrollPinnedRef = useRef(true);
const isZh = t("nav.connected") === "\u5DF2\u8FDE\u63A5";
@@ -423,6 +471,8 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
const [skillSaving, setSkillSaving] = useState(false);
const [skillCreateError, setSkillCreateError] = useState<string | null>(null);
const [showSkillCreate, setShowSkillCreate] = useState(false);
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const { data: skillsData, loading: skillsLoading, error: skillsError, refetch: refetchSkills } = useApi<SkillsResponse>("/skills");
const worldPanelInsetClass = currentSessionKind === "play" && worldPanelOpen ? "lg:pr-[380px]" : "";
const availableSkills = skillsData?.skills ?? [];
@@ -638,17 +688,45 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
};
}, [activeBookId, activateSession, createSession, loadSessionDetail, loadSessionList, mode]);
const onSend = (text: string) => {
const addAttachedFiles = (files: FileList | File[]) => {
const incoming = Array.from(files);
const accepted: File[] = [];
const rejected: string[] = [];
for (const file of incoming) {
if (file.size > MAX_CHAT_ATTACHMENT_BYTES) {
rejected.push(`${file.name} > ${formatFileSize(MAX_CHAT_ATTACHMENT_BYTES)}`);
continue;
}
accepted.push(file);
}
setAttachedFiles((prev) => [...prev, ...accepted].slice(0, MAX_CHAT_ATTACHMENTS));
setAttachmentError(rejected.length > 0
? (isZh ? `以下文件过大,未添加:${rejected.join("、")}` : `Some files were too large: ${rejected.join(", ")}`)
: null);
};
const onSend = async (text: string) => {
if (!activeSessionId) return;
if (!text.trim()) return;
const hasPendingMessage = Boolean(text.trim()) || attachedFiles.length > 0;
if (!hasPendingMessage) {
if (loading) await abortSession(activeSessionId);
return;
}
const requestedSkills = selectedSkillIdsForSend(selectedSkillIds);
autoScrollPinnedRef.current = true;
void sendMessage(activeSessionId, text, {
const attachments = await serializeChatAttachments(attachedFiles);
if (loading) {
await abortSession(activeSessionId);
}
await sendMessage(activeSessionId, text, {
activeBookId,
sessionKind: currentSessionKind,
actionSource: "free-text",
requestedSkills,
attachments,
});
setAttachedFiles([]);
setAttachmentError(null);
if (requestedSkills?.length) {
setSelectedSkillIds([]);
setSkillPanelOpen(false);
@@ -998,6 +1076,17 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
}}
/>
) : null}
<input
ref={fileInputRef}
type="file"
multiple
accept={CHAT_ATTACHMENT_ACCEPT}
className="hidden"
onChange={(event) => {
if (event.currentTarget.files) addAttachedFiles(event.currentTarget.files);
event.currentTarget.value = "";
}}
/>
{selectedSkills.length > 0 ? (
<div className="flex flex-wrap gap-1.5 border-b border-border/20 px-3 py-2">
{selectedSkills.map((skill) => (
@@ -1018,6 +1107,35 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
))}
</div>
) : null}
{attachedFiles.length > 0 || attachmentError ? (
<div className="border-b border-border/20 px-3 py-2">
{attachedFiles.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{attachedFiles.map((file) => (
<span
key={`${file.name}-${file.size}-${file.lastModified}`}
className="inline-flex max-w-[220px] items-center gap-1.5 rounded-full border border-border/50 bg-secondary/60 px-2.5 py-1 text-xs text-muted-foreground"
title={`${file.name} · ${file.type || "application/octet-stream"} · ${formatFileSize(file.size)}`}
>
<Paperclip size={12} />
<span className="truncate">{file.name}</span>
<button
type="button"
onClick={() => setAttachedFiles((prev) => prev.filter((item) => item !== file))}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={isZh ? `移除 ${file.name}` : `Remove ${file.name}`}
>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
{attachmentError ? (
<div className="mt-1 text-xs leading-5 text-destructive">{attachmentError}</div>
) : null}
</div>
) : null}
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
@@ -1029,23 +1147,36 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
>
<Plus size={16} strokeWidth={2.4} />
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!activeSessionId}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/50 text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary disabled:opacity-30"
title={isZh ? "上传图片或资料" : "Attach files"}
aria-label={isZh ? "上传图片或资料" : "Attach files"}
>
<Paperclip size={16} strokeWidth={2.3} />
</button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void onSend(input); } }}
placeholder={isZh ? "输入指令..." : "Enter command..."}
disabled={loading || !activeSessionId}
disabled={!activeSessionId}
rows={1}
className="flex-1 bg-transparent text-base leading-7 placeholder:text-muted-foreground/50 outline-none! border-none! ring-0! shadow-none focus:outline-none! focus:ring-0! focus:border-none! resize-none disabled:opacity-50 max-h-[200px] overflow-y-auto"
/>
<button
type="button"
onClick={() => onSend(input)}
disabled={!input.trim() || loading || !activeSessionId}
onClick={() => void onSend(input)}
disabled={(!input.trim() && attachedFiles.length === 0 && !loading) || !activeSessionId}
className="w-8 h-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center shrink-0 hover:scale-105 active:scale-95 transition-all disabled:opacity-20 disabled:scale-100 shadow-sm shadow-primary/20"
title={loading && !input.trim() && attachedFiles.length === 0 ? (isZh ? "停止当前回复" : "Stop") : undefined}
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <ArrowUp size={14} strokeWidth={2.5} />}
{loading && !input.trim() && attachedFiles.length === 0
? <Square size={13} fill="currentColor" />
: <ArrowUp size={14} strokeWidth={2.5} />}
</button>
</div>
<div className="flex items-center gap-2 px-3 pb-2 border-t border-border/20 pt-1.5">
@@ -1,6 +1,7 @@
import type { StateCreator } from "zustand";
import type {
AgentResponse,
ChatAttachmentPayload,
ChatSessionKind,
ChatStore,
MessageActions,
@@ -53,6 +54,21 @@ function mergeSkillIds(
return out;
}
function formatAttachmentSize(size: number): string {
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
if (size >= 1024) return `${Math.ceil(size / 1024)} KB`;
return `${size} B`;
}
function formatUserMessageForDisplay(text: string, attachments: ReadonlyArray<ChatAttachmentPayload>): string {
if (attachments.length === 0) return text;
const lines = text ? [text, "", "附件:"] : ["附件:"];
for (const attachment of attachments) {
lines.push(`- ${attachment.filename} (${attachment.mediaType || "application/octet-stream"}, ${formatAttachmentSize(attachment.size)})`);
}
return lines.join("\n");
}
export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => ({
activateSession: (sessionId) =>
set({ activeSessionId: sessionId }),
@@ -292,6 +308,23 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
});
},
abortSession: async (sessionId) => {
const session = get().sessions[sessionId];
session?.stream?.close();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({
isStreaming: false,
stream: null,
lastError: null,
})),
}));
try {
await fetchJson(`/sessions/${sessionId}/abort`, { method: "POST" });
} catch (error) {
get().addErrorMessage(sessionId, error instanceof Error ? error.message : String(error));
}
},
loadSessionDetail: async (sessionId) => {
// 草稿会话:磁盘上还没有文件,直接跳过远端拉取。
// 本地已有消息:不拉取远端,避免流式中或未持久化的消息被覆盖。
@@ -350,8 +383,10 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
sendMessage: async (sessionId, text, options?: SendMessageOptions) => {
const trimmed = text.trim();
const attachments = options?.attachments ?? [];
const session = get().sessions[sessionId];
if (!trimmed || !session || session.isStreaming) return;
if ((!trimmed && attachments.length === 0) || !session || session.isStreaming) return;
const userInstruction = trimmed || "请阅读我上传的文件。";
const activeBookId = options?.activeBookId ?? session.bookId ?? undefined;
const sessionKind: ChatSessionKind = options?.sessionKind
?? session.sessionKind
@@ -360,7 +395,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
const playMode = options?.playMode ?? session.playMode;
if (!get().selectedModel) {
get().addUserMessage(sessionId, trimmed);
get().addUserMessage(sessionId, formatUserMessageForDisplay(userInstruction, attachments));
get().addErrorMessage(sessionId, "请先选择一个模型");
return;
}
@@ -393,7 +428,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
}
}
const skillDirectives = parseSkillDirectives(trimmed);
const skillDirectives = parseSkillDirectives(userInstruction);
const instruction = skillDirectives.instruction;
const requestedSkills = mergeSkillIds(skillDirectives.requestedSkills, options?.requestedSkills);
const disabledSkills = mergeSkillIds([], options?.disabledSkills);
@@ -408,7 +443,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
})),
}));
get().addUserMessage(sessionId, trimmed);
get().addUserMessage(sessionId, formatUserMessageForDisplay(userInstruction, attachments));
session.stream?.close();
const streamEs = new EventSource("/api/v1/events");
set((state) => ({
@@ -430,6 +465,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
actionPayload: options?.actionPayload,
requestedSkills,
disabledSkills,
attachments,
sessionId,
model: get().selectedModel ?? undefined,
service: get().selectedService ?? undefined,
@@ -242,6 +242,24 @@ export function attachSessionStreamListeners({
streamEs.addEventListener("draft:error", flushTextDeltas);
streamEs.addEventListener("agent:complete", flushTextDeltas);
streamEs.addEventListener("agent:aborted", (event: MessageEvent) => {
try {
const data = event.data ? JSON.parse(event.data) : null;
if (!sessionMatchesEvent(sessionId, data)) return;
flushTextDeltas();
progressThrottle.flush();
streamEs.close();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({
isStreaming: false,
stream: null,
})),
}));
} catch {
// ignore
}
});
streamEs.addEventListener("thinking:start", (event: MessageEvent) => {
try {
const data = event.data ? JSON.parse(event.data) : null;
+10
View File
@@ -126,9 +126,18 @@ export interface SendMessageOptions {
readonly actionPayload?: ChatActionPayload;
readonly requestedSkills?: ReadonlyArray<string>;
readonly disabledSkills?: ReadonlyArray<string>;
readonly attachments?: ReadonlyArray<ChatAttachmentPayload>;
readonly playMode?: PlayMode;
}
export interface ChatAttachmentPayload {
readonly id: string;
readonly filename: string;
readonly mediaType: string;
readonly size: number;
readonly dataUrl: string;
}
export interface SessionRuntime {
readonly sessionId: string;
readonly bookId: string | null;
@@ -186,6 +195,7 @@ export interface MessageActions {
deleteSession: (sessionId: string) => Promise<void>;
loadSessionDetail: (sessionId: string) => Promise<void>;
sendMessage: (sessionId: string, text: string, options?: SendMessageOptions) => Promise<void>;
abortSession: (sessionId: string) => Promise<void>;
setSelectedModel: (model: string, service: string) => void;
}
+13
View File
@@ -87,6 +87,9 @@ importers:
undici:
specifier: 6.21.3
version: 6.21.3
unpdf:
specifier: ^1.6.2
version: 1.6.2
zod:
specifier: ^3.25.76
version: 3.25.76
@@ -4649,6 +4652,14 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
unpdf@1.6.2:
resolution: {integrity: sha512-zQ80ySoPuPHOsvIoRp/nJyQt8TOUoTh1+WBCGcBvlddQNgKDLRwm0AY3x8Q35I7+kIiRSgqMx+Ma2pl9McIp7A==}
peerDependencies:
'@napi-rs/canvas': ^0.1.69
peerDependenciesMeta:
'@napi-rs/canvas':
optional: true
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
@@ -10135,6 +10146,8 @@ snapshots:
universalify@2.0.1: {}
unpdf@1.6.2: {}
unpipe@1.0.0: {}
until-async@3.0.2: {}