From c609bde76d68cc15891a39c7b4956b328ea39747 Mon Sep 17 00:00:00 2001 From: Ma Date: Tue, 9 Jun 2026 02:27:24 +0800 Subject: [PATCH] feat(studio): polish play runtime experience --- .gitignore | 1 + .../core/src/__tests__/play-image.test.ts | 21 +- .../__tests__/short-fiction-public.test.ts | 8 +- .../core/src/pipeline/short-fiction-runner.ts | 31 ++- packages/core/src/play/play-image.ts | 24 +- packages/studio/src/App.tsx | 12 +- packages/studio/src/api/server.test.ts | 31 +++ packages/studio/src/api/server.ts | 41 ++- packages/studio/src/components/Sidebar.tsx | 46 ++-- .../src/components/ai-elements/message.tsx | 4 +- .../src/components/chat/BookSidebar.tsx | 16 +- .../src/components/chat/ChatMessage.tsx | 4 +- .../src/components/chat/PlayChoicePanel.tsx | 9 +- .../studio/src/components/chat/PlayHud.tsx | 237 +++++++++--------- .../components/chat/ToolExecutionSteps.tsx | 131 +++++++--- .../chat/__tests__/ToolExecutionSteps.test.ts | 2 + .../chat/play-hud/HoldingInspect.tsx | 46 ++-- .../components/chat/play-hud/HoldingSlot.tsx | 6 +- .../components/chat/play-hud/RelationWeb.tsx | 2 +- .../components/chat/play-hud/StateGauge.tsx | 10 +- .../components/sidebar/ChaptersSection.tsx | 8 +- .../components/sidebar/CharacterSection.tsx | 20 +- .../components/sidebar/FoundationSection.tsx | 4 +- .../components/sidebar/FrontmatterCards.tsx | 6 +- .../components/sidebar/PendingHooksView.tsx | 14 +- .../src/components/sidebar/SidebarCard.tsx | 2 +- .../src/components/sidebar/SummarySection.tsx | 12 +- packages/studio/src/hooks/use-i18n.ts | 4 +- packages/studio/src/index.css | 19 +- packages/studio/src/pages/ChatPage.tsx | 198 +++++++++++---- 30 files changed, 629 insertions(+), 340 deletions(-) diff --git a/.gitignore b/.gitignore index 29918eb5..6c123184 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ _*.md # 用户运行时数据(会话记录、书籍内容、项目密钥、提示词模板不属于项目代码) .inkos/ books/ +worlds/ inkos.json prompt/ CLAUDE.md diff --git a/packages/core/src/__tests__/play-image.test.ts b/packages/core/src/__tests__/play-image.test.ts index 9a2ddbaa..28086358 100644 --- a/packages/core/src/__tests__/play-image.test.ts +++ b/packages/core/src/__tests__/play-image.test.ts @@ -14,34 +14,39 @@ import { } from "../play/play-image.js"; describe("play image prompts", () => { - it("frames an actor as a portrait and anchors it to the world premise", () => { + it("frames an actor as an entity image and anchors it to the world premise without forcing a style", () => { const prompt = buildPlayEntityImagePrompt( { type: "actor", label: "林深", summary: "急诊科医生,能读濒死者的记忆" }, "都市超自然:触碰濒死者会读到对方一段记忆,代价是丢失自己的记忆。", ); - expect(prompt).toContain("半身肖像"); + expect(prompt).toContain("为这个角色生成配图"); expect(prompt).toContain("林深"); expect(prompt).toContain("世界设定"); expect(prompt).toContain("急诊科医生"); - expect(prompt).toContain("无任何文字"); // no-text/watermark guard + expect(prompt).not.toContain("写实或半写实"); + expect(prompt).not.toContain("不添加文字"); + expect(prompt).not.toContain("水印"); + expect(prompt).not.toContain("拼贴"); }); - it("frames an item as a neutral-background still", () => { + it("frames an item without assuming a neutral-background still", () => { const prompt = buildPlayEntityImagePrompt({ type: "item", label: "红线手环" }); - expect(prompt).toContain("静物特写"); + expect(prompt).toContain("为这件物品生成配图"); expect(prompt).toContain("红线手环"); + expect(prompt).not.toContain("中性背景"); }); it("falls back to a generic concept frame for unknown entity types", () => { const prompt = buildPlayEntityImagePrompt({ type: "rule", label: "记忆债规则" }); expect(prompt).toContain("记忆债规则"); - expect(prompt).toContain("贴合世界设定"); + expect(prompt).toContain("为这个对象生成配图"); }); - it("builds a wide cinematic prompt for a moment from its scene prose", () => { + it("builds a moment prompt from scene prose without forcing cinematic wide framing", () => { const prompt = buildPlaySceneImagePrompt("雨夜,她站在便利店门口,没有抬头。", "情感悬疑"); - expect(prompt).toContain("横构图"); expect(prompt).toContain("便利店门口"); + expect(prompt).not.toContain("电影感"); + expect(prompt).not.toContain("横构图"); }); it("includes user-defined world and visual contracts without assuming RPG tiers", () => { diff --git a/packages/core/src/__tests__/short-fiction-public.test.ts b/packages/core/src/__tests__/short-fiction-public.test.ts index 57509eae..0eb8ddd1 100644 --- a/packages/core/src/__tests__/short-fiction-public.test.ts +++ b/packages/core/src/__tests__/short-fiction-public.test.ts @@ -203,7 +203,7 @@ describe("public short-fiction chain", () => { const originalFetch = globalThis.fetch; process.env.INKOS_TEST_COVER_KEY = "sk-cover"; try { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + const fetchMock = vi.fn(async (_url: unknown, _init?: { readonly body?: unknown }) => new Response(JSON.stringify({ data: [{ b64_json: "ZmFrZQ==" }], }), { status: 200, headers: { "content-type": "application/json" } })); globalThis.fetch = fetchMock as never; @@ -233,6 +233,12 @@ describe("public short-fiction chain", () => { body: expect.stringContaining("离婚协议他递了三年"), }), ); + const body = String(fetchMock.mock.calls[0]?.[1]?.body ?? ""); + expect(body).toContain("按用户给出的标题、简介、卖点和视觉要求生成封面图。"); + expect(body).not.toContain("中文商业短篇小说"); + expect(body).not.toContain("不添加文字"); + expect(body).not.toContain("水印"); + expect(body).not.toContain("固定模板"); } finally { globalThis.fetch = originalFetch; delete process.env.INKOS_TEST_COVER_KEY; diff --git a/packages/core/src/pipeline/short-fiction-runner.ts b/packages/core/src/pipeline/short-fiction-runner.ts index e89be5cb..20d19bfe 100644 --- a/packages/core/src/pipeline/short-fiction-runner.ts +++ b/packages/core/src/pipeline/short-fiction-runner.ts @@ -75,6 +75,7 @@ export interface ShortFictionCoverOptions { readonly intro?: string; readonly sellingPoints?: string | ReadonlyArray; readonly coverPrompt?: string; + readonly promptMode?: CoverPromptMode; readonly outputDir?: string; readonly coverBaseUrl?: string; readonly coverEndpoint?: string; @@ -90,6 +91,8 @@ export interface ShortFictionCoverResult { readonly coverImagePath: string; } +type CoverPromptMode = "short" | "generic"; + export async function runShortFictionProduction( options: ShortFictionRunOptions, ): Promise { @@ -371,12 +374,14 @@ export async function generateShortFictionCover( rawContent: "", }; const promptPath = join(outputDir, "cover-prompt.md"); - await writeText(options.projectRoot, promptPath, buildCoverImagePrompt(salesPackage)); + const imagePrompt = buildCoverImagePrompt(salesPackage, options.promptMode ?? "generic"); + await writeText(options.projectRoot, promptPath, imagePrompt); const artifact = await generateCoverImageArtifact({ root: options.projectRoot, outputDir, salesPackage, + promptMode: options.promptMode ?? "generic", coverBaseUrl: options.coverBaseUrl, coverEndpoint: options.coverEndpoint, coverModel: options.coverModel, @@ -477,6 +482,7 @@ async function generateCoverImageArtifact(input: { readonly root: string; readonly outputDir: string; readonly salesPackage: ShortFictionSalesPackage; + readonly promptMode?: CoverPromptMode; readonly coverBaseUrl?: string; readonly coverEndpoint?: string; readonly coverModel?: string; @@ -491,7 +497,7 @@ async function generateCoverImageArtifact(input: { coverApiKeyEnv: input.coverApiKeyEnv, }); const size = input.coverSize || process.env.INKOS_COVER_SIZE || "1024x1360"; - const { buffer, extension } = await generateImageFromPrompt(request, buildCoverImagePrompt(input.salesPackage), size); + const { buffer, extension } = await generateImageFromPrompt(request, buildCoverImagePrompt(input.salesPackage, input.promptMode ?? "short"), size); const coverPath = join(input.outputDir, extension === "jpg" ? "cover.jpg" : "cover.png"); await writeBinary(input.root, coverPath, buffer); return { coverImagePath: projectPath(coverPath) }; @@ -807,13 +813,24 @@ function resolveCoverEndpoint(coverEndpoint?: string, coverBaseUrl?: string): st return `${baseUrl.replace(/\/+$/u, "")}/images/generations`; } -function buildCoverImagePrompt(salesPackage: ShortFictionSalesPackage): string { - return [ - "为中文商业短篇小说生成手机端平台书封,3:4竖图。", - `主标题:${salesPackage.title}`, +function buildCoverImagePrompt(salesPackage: ShortFictionSalesPackage, mode: CoverPromptMode): string { + const base = [ + `标题:${salesPackage.title}`, salesPackage.intro ? `简介:${salesPackage.intro}` : "", salesPackage.sellingPoints.length > 0 ? `卖点:${salesPackage.sellingPoints.join(";")}` : "", - salesPackage.coverPrompt ? `包装提示:${salesPackage.coverPrompt}` : "", + salesPackage.coverPrompt ? `用户视觉要求:${salesPackage.coverPrompt}` : "", + ].filter(Boolean); + + if (mode === "generic") { + return [ + "按用户给出的标题、简介、卖点和视觉要求生成封面图。", + ...base, + ].join("\n"); + } + + return [ + "为中文商业短篇小说生成手机端平台书封,3:4竖图。", + ...base.map((line) => line.replace(/^标题:/u, "主标题:").replace(/^用户视觉要求:/u, "包装提示:")), "", "封面方向:平台短篇书封,不是电影海报。标题字要成为主视觉,预留两到四行大字排版区;人物近景或半身,表情有冷笑、震惊、崩溃、压迫或反杀感;道具少而大,一眼能看出冲突。", "颜色高对比、高饱和,适合手机列表缩略图。避免写实会议摄影、横版视频缩略图、杂志大片、小清新细字和长段文字。", diff --git a/packages/core/src/play/play-image.ts b/packages/core/src/play/play-image.ts index c6be4f0d..020e6d7e 100644 --- a/packages/core/src/play/play-image.ts +++ b/packages/core/src/play/play-image.ts @@ -15,16 +15,16 @@ import { resolveCoverGenerationRequest, } from "../pipeline/short-fiction-runner.js"; -/** Per-type framing so an actor reads as a portrait, an item as a still, etc. */ +/** Per-type task framing only; visual style must come from the world / visual contract. */ const SHOT_BY_TYPE: Record = { - actor: "为这个角色画一张半身肖像", - location: "为这个地点画一张环境立绘", - item: "为这件物品画一张静物特写(中性背景)", - evidence: "为这件证物画一张特写", - clue: "为这条线索画一张特写", - claim: "为这个主张画一张示意特写", - proof_chain: "为这条证据链画一张示意图", - organization: "为这个组织画一张代表性图像(标志或场所)", + actor: "为这个角色生成配图", + location: "为这个地点生成配图", + item: "为这件物品生成配图", + evidence: "为这件证物生成配图", + clue: "为这条线索生成配图", + claim: "为这个主张生成配图", + proof_chain: "为这条证据链生成配图", + organization: "为这个组织生成配图", }; function clamp(text: string, max: number): string { @@ -66,14 +66,13 @@ export function buildPlayEntityImagePrompt( worldPremise?: PlayImageWorldInput, ): string { const worldContext = renderImageWorldContext(worldPremise); - const subject = SHOT_BY_TYPE[entity.type] ?? "为这个对象画一张贴合世界设定的概念图"; + const subject = SHOT_BY_TYPE[entity.type] ?? "为这个对象生成配图"; const summary = entity.summary?.trim(); return [ worldContext, subject, `对象:${entity.label}`, summary ? `细节:${clamp(summary, 400)}` : "", - "要求:写实或半写实,单一主体清晰,构图聚焦,无任何文字、水印或多格拼贴。", ].filter(Boolean).join("\n"); } @@ -82,9 +81,8 @@ export function buildPlaySceneImagePrompt(sceneText: string, worldPremise?: Play const worldContext = renderImageWorldContext(worldPremise); return [ worldContext, - "为下面这一刻画一张电影感的横构图插画,捕捉当下的动作、氛围与情绪:", + "为下面这一刻生成配图,捕捉当下的动作、氛围与情绪:", clamp(sceneText, 900), - "要求:写实或半写实,重氛围与光影,无任何文字、水印或多格拼贴。", ].filter(Boolean).join("\n"); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 030045f9..f54564e8 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -127,9 +127,9 @@ export function App() {
-
+
@@ -152,7 +152,7 @@ export function App() { await putApi("/project", { language: "en" }); refetchProject(); }} - className={`text-xs px-2 py-0.5 rounded ${currentLang === "en" ? "bg-primary text-primary-foreground" : "text-muted-foreground"}`} + className={`px-2.5 py-1 text-[16px] font-medium rounded-md ${currentLang === "en" ? "bg-primary text-primary-foreground" : "text-muted-foreground"}`} > EN @@ -162,7 +162,7 @@ export function App() { onClick={() => setTheme(isDark ? "light" : "dark")} className="text-muted-foreground hover:text-foreground transition-colors" > - {isDark ? : } + {isDark ? : }
diff --git a/packages/studio/src/api/server.test.ts b/packages/studio/src/api/server.test.ts index 5e13b1e2..ca568df8 100644 --- a/packages/studio/src/api/server.test.ts +++ b/packages/studio/src/api/server.test.ts @@ -3610,6 +3610,37 @@ describe("createStudioServer daemon lifecycle", () => { expect(chatCompletionMock).not.toHaveBeenCalled(); }); + it("classifies InkOS parser/tool errors as internal instead of blaming the selected provider", async () => { + const internalError = "sub_agent writer failed: missing YAML frontmatter delimiters"; + runAgentSessionMock.mockResolvedValueOnce({ + responseText: "", + errorMessage: internalError, + messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: internalError }], + }); + + 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", + }), + }); + + expect(response.status).toBe(500); + const json = await response.json() as { error: { code: string; message: string }; response: string }; + expect(json.error.code).toBe("AGENT_INTERNAL_ERROR"); + expect(json.error.message).toContain("InkOS 内部流程错误"); + expect(json.error.message).toContain("missing YAML frontmatter delimiters"); + expect(json.error.message).not.toMatch(/kkaiapi/i); + expect(json.response).toBe(json.error.message); + expect(chatCompletionMock).not.toHaveBeenCalled(); + }); + it("does not replace an empty agent response with a second plain-chat call", async () => { runAgentSessionMock.mockResolvedValueOnce({ responseText: "", diff --git a/packages/studio/src/api/server.ts b/packages/studio/src/api/server.ts index c58dd8d3..b9648cf2 100644 --- a/packages/studio/src/api/server.ts +++ b/packages/studio/src/api/server.ts @@ -595,6 +595,35 @@ function validateAgentActionExecution(args: { return undefined; } +type AgentFailureKind = "llm" | "internal" | "unknown"; + +function classifyAgentFailure(message: string): AgentFailureKind { + const text = message.trim(); + if (!text) return "unknown"; + if ( + /API\s*返回|上游|upstream|Bad Gateway|temporarily unavailable|rate limit|quota|API Key|unauthorized|forbidden|无法连接到 API|fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|LLM returned empty response|Provider finish_reason|reasoning_content/i.test(text) + ) { + return "llm"; + } + if ( + /PlannerParseError|Architect output missing|required sections|missing YAML frontmatter|frontmatter delimiters|parseMemo|Book creation artifact is incomplete|Short-hit draft is incomplete|工具执行失败|执行失败|sub_agent|tool execution|RUNTIME_STATE_DELTA|JSON parse|解析失败/i.test(text) + ) { + return "internal"; + } + return "unknown"; +} + +function formatAgentFailure(message: string): { 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_ERROR", message, status: 500 }; +} + interface CollectedToolExec { id: string; tool: string; @@ -3780,10 +3809,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) { if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) { await finalizeCreatedBook(); } + const failure = formatAgentFailure(result.errorMessage); return c.json({ - error: { code: "AGENT_LLM_ERROR", message: result.errorMessage }, - response: result.errorMessage, - }, 502); + error: { code: failure.code, message: failure.message }, + response: failure.message, + }, failure.status); } const actionExecutionError = validateAgentActionExecution({ @@ -3856,9 +3886,10 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) { }, 429); } + const failure = formatAgentFailure(msg); return c.json( - { error: { code: "AGENT_ERROR", message: msg } }, - 500, + { error: { code: failure.code, message: failure.message } }, + failure.status, ); } }); diff --git a/packages/studio/src/components/Sidebar.tsx b/packages/studio/src/components/Sidebar.tsx index a04086b1..5030ca02 100644 --- a/packages/studio/src/components/Sidebar.tsx +++ b/packages/studio/src/components/Sidebar.tsx @@ -272,12 +272,12 @@ export function Sidebar({ nav, activePage, sse, t }: {
@@ -286,20 +286,20 @@ export function Sidebar({ nav, activePage, sse, t }: {
{/* InkOS Create Section — always visible, two columns × four rows */}
-
- +
+ {t("nav.createSection")}
- } label={t("nav.createNovel")} active={activePage === "book-create"} onClick={handleOpenBookCreate} /> - } label={t("nav.createShort")} onClick={() => launchProjectMode("short")} /> - } label={t("nav.createFanfic")} onClick={() => nav.toImport("fanfic")} /> - } label={t("nav.createSpinoff")} onClick={() => nav.toImport("spinoff")} /> - } label={t("nav.createImitation")} onClick={() => nav.toImport("imitation")} /> - } label={t("nav.createContinuation")} onClick={() => nav.toImport("chapters")} /> - } label={t("nav.createBranching")} onClick={() => launchProjectMode("play", "guided")} /> - } label={t("nav.createFree")} onClick={() => launchProjectMode("play", "open")} /> + } label={t("nav.createNovel")} active={activePage === "book-create"} onClick={handleOpenBookCreate} /> + } label={t("nav.createShort")} onClick={() => launchProjectMode("short")} /> + } label={t("nav.createFanfic")} onClick={() => nav.toImport("fanfic")} /> + } label={t("nav.createSpinoff")} onClick={() => nav.toImport("spinoff")} /> + } label={t("nav.createImitation")} onClick={() => nav.toImport("imitation")} /> + } label={t("nav.createContinuation")} onClick={() => nav.toImport("chapters")} /> + } label={t("nav.createBranching")} onClick={() => launchProjectMode("play", "guided")} /> + } label={t("nav.createFree")} onClick={() => launchProjectMode("play", "open")} />
@@ -330,7 +330,7 @@ export function Sidebar({ nav, activePage, sse, t }: { @@ -733,7 +733,7 @@ function CreateItem({ icon, label, active, onClick }: { - {label} + {label} {!loading && content !== null && !editing && (
) : content === null ? ( -

文件不存在

+

文件不存在

) : editing ? (