mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 23:02:03 +08:00
feat(studio): polish play runtime experience
This commit is contained in:
@@ -25,6 +25,7 @@ _*.md
|
||||
# 用户运行时数据(会话记录、书籍内容、项目密钥、提示词模板不属于项目代码)
|
||||
.inkos/
|
||||
books/
|
||||
worlds/
|
||||
inkos.json
|
||||
prompt/
|
||||
CLAUDE.md
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface ShortFictionCoverOptions {
|
||||
readonly intro?: string;
|
||||
readonly sellingPoints?: string | ReadonlyArray<string>;
|
||||
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<ShortFictionRunResult> {
|
||||
@@ -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, "包装提示:")),
|
||||
"",
|
||||
"封面方向:平台短篇书封,不是电影海报。标题字要成为主视觉,预留两到四行大字排版区;人物近景或半身,表情有冷笑、震惊、崩溃、压迫或反杀感;道具少而大,一眼能看出冲突。",
|
||||
"颜色高对比、高饱和,适合手机列表缩略图。避免写实会议摄影、横版视频缩略图、杂志大片、小清新细字和长段文字。",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -127,9 +127,9 @@ export function App() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={nav.toDashboard}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border/50 bg-card/70 px-3 py-1.5 text-sm font-semibold text-foreground hover:bg-secondary/50 transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border/50 bg-card/70 px-3.5 py-2 text-[17px] font-semibold text-foreground hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<House size={14} />
|
||||
<House size={18} />
|
||||
<span>首页</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span className="font-serif">InkOS Studio</span>
|
||||
@@ -137,13 +137,13 @@ export function App() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-0.5 bg-muted/50 rounded-md p-0.5">
|
||||
<div className="flex gap-0.5 bg-muted/50 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={async () => {
|
||||
await putApi("/project", { language: "zh" });
|
||||
refetchProject();
|
||||
}}
|
||||
className={`text-xs px-2 py-0.5 rounded ${currentLang === "zh" ? "bg-primary text-primary-foreground" : "text-muted-foreground"}`}
|
||||
className={`px-2.5 py-1 text-[16px] font-medium rounded-md ${currentLang === "zh" ? "bg-primary text-primary-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
中
|
||||
</button>
|
||||
@@ -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
|
||||
</button>
|
||||
@@ -162,7 +162,7 @@ export function App() {
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isDark ? <Sun size={14} /> : <Moon size={14} />}
|
||||
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -272,12 +272,12 @@ export function Sidebar({ nav, activePage, sse, t }: {
|
||||
<div className="px-6 py-8">
|
||||
<button
|
||||
onClick={nav.toDashboard}
|
||||
className="group flex items-center gap-2 hover:opacity-80 transition-all duration-300"
|
||||
className="group flex items-center gap-3 hover:opacity-80 transition-all duration-300"
|
||||
>
|
||||
<InkosLogo className="w-8 h-8 shrink-0 group-hover:scale-105 transition-transform" />
|
||||
<InkosLogo className="w-11 h-11 shrink-0 group-hover:scale-105 transition-transform" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-serif text-xl leading-none italic font-medium">InkOS</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold mt-1">Studio</span>
|
||||
<span className="font-serif text-[27px] leading-none italic font-medium">InkOS</span>
|
||||
<span className="text-[13px] uppercase tracking-[0.22em] text-muted-foreground font-bold mt-1.5">Studio</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -286,20 +286,20 @@ export function Sidebar({ nav, activePage, sse, t }: {
|
||||
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-6">
|
||||
{/* InkOS Create Section — always visible, two columns × four rows */}
|
||||
<div>
|
||||
<div className="px-3 mb-2">
|
||||
<span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
|
||||
<div className="px-3 mb-2.5">
|
||||
<span className="text-[16px] leading-6 uppercase tracking-[0.1em] text-muted-foreground font-bold">
|
||||
{t("nav.createSection")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<CreateItem icon={<BookPlus size={14} />} label={t("nav.createNovel")} active={activePage === "book-create"} onClick={handleOpenBookCreate} />
|
||||
<CreateItem icon={<ScrollText size={14} />} label={t("nav.createShort")} onClick={() => launchProjectMode("short")} />
|
||||
<CreateItem icon={<Feather size={14} />} label={t("nav.createFanfic")} onClick={() => nav.toImport("fanfic")} />
|
||||
<CreateItem icon={<BookCopy size={14} />} label={t("nav.createSpinoff")} onClick={() => nav.toImport("spinoff")} />
|
||||
<CreateItem icon={<Wand2 size={14} />} label={t("nav.createImitation")} onClick={() => nav.toImport("imitation")} />
|
||||
<CreateItem icon={<FileInput size={14} />} label={t("nav.createContinuation")} onClick={() => nav.toImport("chapters")} />
|
||||
<CreateItem icon={<GitBranch size={14} />} label={t("nav.createBranching")} onClick={() => launchProjectMode("play", "guided")} />
|
||||
<CreateItem icon={<Gamepad2 size={14} />} label={t("nav.createFree")} onClick={() => launchProjectMode("play", "open")} />
|
||||
<CreateItem icon={<BookPlus size={16} />} label={t("nav.createNovel")} active={activePage === "book-create"} onClick={handleOpenBookCreate} />
|
||||
<CreateItem icon={<ScrollText size={16} />} label={t("nav.createShort")} onClick={() => launchProjectMode("short")} />
|
||||
<CreateItem icon={<Feather size={16} />} label={t("nav.createFanfic")} onClick={() => nav.toImport("fanfic")} />
|
||||
<CreateItem icon={<BookCopy size={16} />} label={t("nav.createSpinoff")} onClick={() => nav.toImport("spinoff")} />
|
||||
<CreateItem icon={<Wand2 size={16} />} label={t("nav.createImitation")} onClick={() => nav.toImport("imitation")} />
|
||||
<CreateItem icon={<FileInput size={16} />} label={t("nav.createContinuation")} onClick={() => nav.toImport("chapters")} />
|
||||
<CreateItem icon={<GitBranch size={16} />} label={t("nav.createBranching")} onClick={() => launchProjectMode("play", "guided")} />
|
||||
<CreateItem icon={<Gamepad2 size={16} />} label={t("nav.createFree")} onClick={() => launchProjectMode("play", "open")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -330,7 +330,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openBook(book.id)}
|
||||
className={`flex min-w-0 flex-1 items-center gap-1.5 py-1.5 pr-2 rounded-md text-sm transition-colors ${
|
||||
className={`flex min-w-0 flex-1 items-center gap-1.5 py-1.5 pr-2 rounded-md text-[15px] leading-6 transition-colors ${
|
||||
isActiveBook ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground hover:bg-secondary/30"
|
||||
}`}
|
||||
>
|
||||
@@ -353,7 +353,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSession(book.id, session.sessionId)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 pl-9 pr-2 py-1 text-left text-[13px] transition-colors"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 pl-9 pr-2 py-1.5 text-left text-[14px] leading-5 transition-colors"
|
||||
>
|
||||
<span className={`truncate flex-1 ${isActiveSession ? "text-foreground" : "text-muted-foreground group-hover/session:text-foreground"}`}>
|
||||
{label}
|
||||
@@ -397,7 +397,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateSession(book.id)}
|
||||
className="w-full flex items-center gap-2 pl-9 pr-2 py-1 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
className="w-full flex items-center gap-2 pl-9 pr-2 py-1.5 text-[13px] text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>新建会话</span>
|
||||
@@ -448,7 +448,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openProjectChatSession(session.sessionId)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 pl-2 pr-2 py-1 text-left text-[13px] transition-colors"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 pl-2 pr-2 py-1.5 text-left text-[14px] leading-5 transition-colors"
|
||||
>
|
||||
<SessionKindIcon
|
||||
kind={session.sessionKind}
|
||||
@@ -496,7 +496,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateProjectChatSession}
|
||||
className="w-full flex items-center gap-2 pl-2 pr-2 py-1 text-xs text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
className="w-full flex items-center gap-2 pl-2 pr-2 py-1.5 text-[13px] text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>新建会话</span>
|
||||
@@ -710,13 +710,13 @@ function SectionHeader({ label, expanded, onToggle }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="group flex w-full items-center gap-1.5 px-3 py-1 text-left"
|
||||
className="group flex w-full items-center gap-1.5 px-3 py-2 text-left"
|
||||
>
|
||||
<span className="flex-1 text-[11px] uppercase tracking-widest text-muted-foreground font-bold group-hover:text-foreground transition-colors">
|
||||
<span className="flex-1 text-[16px] leading-6 uppercase tracking-[0.1em] text-muted-foreground font-bold group-hover:text-foreground transition-colors">
|
||||
{label}
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
size={15}
|
||||
className={`text-muted-foreground/50 transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
@@ -733,7 +733,7 @@ function CreateItem({ icon, label, active, onClick }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs transition-all ${
|
||||
className={`flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-2.5 text-left text-[16px] leading-6 transition-all ${
|
||||
active
|
||||
? "border border-border bg-secondary text-foreground font-medium shadow-sm"
|
||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
|
||||
|
||||
@@ -54,7 +54,7 @@ export const MessageContent = ({
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-base",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
@@ -327,7 +327,7 @@ export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full text-[16px] leading-[1.6] font-['SimSun','Songti_SC','STSong',serif] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>p+p]:mt-4",
|
||||
"size-full text-[17px] leading-[1.72] font-['SimSun','Songti_SC','STSong',serif] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>p+p]:mt-4",
|
||||
className
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
|
||||
@@ -57,7 +57,7 @@ function renderTruthBody(
|
||||
if (file === "current_state.md") {
|
||||
const { isEmpty, body: stateBody } = presentCurrentState(content);
|
||||
return isEmpty ? (
|
||||
<p className="text-sm text-muted-foreground/60 italic">
|
||||
<p className="text-[14px] leading-6 text-muted-foreground/60 italic">
|
||||
还没有运行状态。开始写作后,每写完一章这里会自动记录最新的故事进展。
|
||||
</p>
|
||||
) : (
|
||||
@@ -66,7 +66,7 @@ function renderTruthBody(
|
||||
}
|
||||
if (file === "emotional_arcs.md" && !hasTableRows(content)) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground/60 italic">
|
||||
<p className="text-[14px] leading-6 text-muted-foreground/60 italic">
|
||||
还没有情感弧线记录。开始写作后,这里会记录角色在各章的情绪变化。
|
||||
</p>
|
||||
);
|
||||
@@ -161,7 +161,7 @@ function ArtifactView({ bookId }: { readonly bookId: string }) {
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<span className="text-sm font-medium truncate flex-1">{label}</span>
|
||||
<span className="text-[15px] leading-6 font-medium truncate flex-1">{label}</span>
|
||||
{!loading && content !== null && !editing && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
@@ -194,15 +194,15 @@ function ArtifactView({ bookId }: { readonly bookId: string }) {
|
||||
<Loader2 size={16} className="text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
) : content === null ? (
|
||||
<p className="text-xs text-muted-foreground/50 italic px-4 py-3">文件不存在</p>
|
||||
<p className="text-[14px] leading-6 text-muted-foreground/50 italic px-4 py-3">文件不存在</p>
|
||||
) : editing ? (
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="w-full h-full min-h-[300px] bg-transparent text-sm leading-7 px-4 py-3 resize-none outline-none border-0 font-mono"
|
||||
className="w-full h-full min-h-[300px] bg-transparent text-[15px] leading-7 px-4 py-3 resize-none outline-none border-0 font-mono"
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm leading-7">
|
||||
<div className="px-4 py-3 text-[15px] leading-7">
|
||||
{renderTruthBody(isChapter ? null : artifactFile, content, frontmatter, body)}
|
||||
</div>
|
||||
)}
|
||||
@@ -245,7 +245,7 @@ function PanelView({ bookId, theme: _theme, t, sse }: BookSidebarProps) {
|
||||
{activeOp && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/5 border border-primary/10">
|
||||
<Loader2 size={12} className="text-primary animate-spin shrink-0" />
|
||||
<span className="text-xs text-primary font-medium">
|
||||
<span className="text-[14px] leading-5 text-primary font-medium">
|
||||
{OP_LABELS[activeOp] ?? activeOp}
|
||||
</span>
|
||||
</div>
|
||||
@@ -331,7 +331,7 @@ export function BookSidebarToggle({ bookId, theme, t, sse }: BookSidebarProps) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/20">
|
||||
<span className="text-xs font-medium text-muted-foreground">书籍信息</span>
|
||||
<span className="text-[15px] leading-6 font-medium text-muted-foreground">书籍信息</span>
|
||||
<button onClick={() => setOpen(false)} className="text-muted-foreground hover:text-foreground">
|
||||
<PanelRightClose size={14} />
|
||||
</button>
|
||||
|
||||
@@ -24,9 +24,9 @@ export function ChatMessage({
|
||||
<Message from={role}>
|
||||
<MessageContent>
|
||||
{isUser ? (
|
||||
<div className="text-sm leading-relaxed">{content}</div>
|
||||
<div className="text-[17px] leading-[1.72]">{content}</div>
|
||||
) : isError ? (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<div className="flex items-center gap-2 text-[17px] leading-[1.72] text-destructive">
|
||||
<XCircle size={14} className="shrink-0" />
|
||||
<span>{content.replace(/^\u2717\s*/, "")}</span>
|
||||
</div>
|
||||
|
||||
@@ -6,22 +6,23 @@ export function PlayChoicePanel(props: {
|
||||
}) {
|
||||
if (props.choices.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground/60">
|
||||
<div className="px-4 py-3 text-center text-xs text-muted-foreground/60">
|
||||
{props.disabled ? (props.isZh ? "推进中…" : "Advancing…") : (props.isZh ? "等待场景给出选项…" : "Waiting for choices…")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="shrink-0 border-t border-border/40 px-4 py-3">
|
||||
<div className="max-w-3xl mx-auto space-y-2">
|
||||
<div className="px-4 py-3">
|
||||
<div className="mx-auto flex max-w-3xl flex-wrap items-center justify-center gap-2.5">
|
||||
{props.choices.map((choice, i) => (
|
||||
<button
|
||||
key={`${i}-${choice}`}
|
||||
type="button"
|
||||
disabled={props.disabled}
|
||||
onClick={() => props.onChoose(choice)}
|
||||
className="w-full text-left rounded-xl border border-border/40 bg-secondary/30 px-3 py-2 text-sm leading-6 hover:border-primary/40 hover:bg-primary/5 transition-all disabled:opacity-40 disabled:pointer-events-none"
|
||||
className="group inline-flex items-center gap-2 rounded-full border border-border/50 bg-secondary/40 px-4 py-2 text-sm leading-5 text-foreground/90 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/50 hover:bg-primary/10 hover:text-primary hover:shadow-md hover:shadow-primary/10 active:translate-y-0 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<span aria-hidden className="text-xs text-primary/50 transition-colors group-hover:text-primary">▸</span>
|
||||
{choice}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Gamepad2, X, ChevronDown } from "lucide-react";
|
||||
import { Gamepad2, X, ChevronDown, ChevronLeft } from "lucide-react";
|
||||
import { fetchJson } from "../../hooks/use-api";
|
||||
import {
|
||||
HOLDING_TYPES, HOLDING_GLYPH, SLOT_GLYPH, EVIDENCE_LADDER,
|
||||
@@ -323,12 +323,12 @@ export function PlayHud(props: {
|
||||
readonly open: boolean;
|
||||
readonly onClose: () => void;
|
||||
readonly sessionTitle?: string | null;
|
||||
// Reports the current turn's scene image up so the chat can render it centrally.
|
||||
readonly onSceneImageUrl?: (url: string | undefined) => void;
|
||||
readonly imageSettings?: PlayImageSettings;
|
||||
}) {
|
||||
const { sessionId, isStreaming, isZh, open, onClose, onSceneImageUrl } = props;
|
||||
const { sessionId, isStreaming, isZh, open, onClose } = props;
|
||||
const base = `/play/runs/${encodeURIComponent(sessionId)}/main`;
|
||||
const [selectedHoldingId, setSelectedHoldingId] = useState<string | null>(null);
|
||||
const [selectedFacingId, setSelectedFacingId] = useState<string | null>(null);
|
||||
const [run, setRun] = useState<PlayRunResponse | null>(null);
|
||||
const [settings, setSettings] = useState<PlayImageSettings>({ actors: false, moments: false, inventory: false });
|
||||
const [coverReady, setCoverReady] = useState(false);
|
||||
@@ -341,12 +341,11 @@ export function PlayHud(props: {
|
||||
const data = await fetchJson<PlayRunResponse>(base);
|
||||
setRun(data);
|
||||
if (data.imageSettings) setSettings(data.imageSettings);
|
||||
onSceneImageUrl?.(data.sceneImageUrl);
|
||||
} catch {
|
||||
// A play session may not have a persisted world yet (no first action).
|
||||
// Leaving run null renders the empty state; do not surface an error.
|
||||
}
|
||||
}, [base, onSceneImageUrl]);
|
||||
}, [base]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
@@ -370,20 +369,6 @@ export function PlayHud(props: {
|
||||
.catch(() => setCoverReady(false));
|
||||
}, []);
|
||||
|
||||
const toggleSetting = useCallback(async (key: keyof PlayImageSettings) => {
|
||||
const next = { ...settings, [key]: !settings[key] };
|
||||
setSettings(next);
|
||||
try {
|
||||
await fetchJson(`${base}/image-settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
} catch {
|
||||
setSettings(settings); // revert on failure
|
||||
}
|
||||
}, [settings, base]);
|
||||
|
||||
const generate = useCallback(async (
|
||||
key: string,
|
||||
body: { target: "entity"; entityId: string } | { target: "scene" },
|
||||
@@ -407,18 +392,20 @@ export function PlayHud(props: {
|
||||
}, [base, load]);
|
||||
|
||||
const view = useMemo(() => buildView(run), [run]);
|
||||
const effectiveImageSettings = props.imageSettings ?? settings;
|
||||
// The selected holding is looked up fresh from the current view, so if it
|
||||
// disappears on the next turn the panel falls back to the list automatically.
|
||||
const selectedHolding = view?.holdings.find((h) => h.id === selectedHoldingId) ?? null;
|
||||
const selectedFacing = view?.facing.find((h) => h.id === selectedFacingId && h.imageUrl) ?? null;
|
||||
|
||||
// Auto-illustrate new actors / inventory / current moment when the toggle is on and an image
|
||||
// API is configured. Decoupled + deduped (inFlight): never blocks a turn,
|
||||
// images appear on the next refresh.
|
||||
useEffect(() => {
|
||||
if (!coverReady || !view) return;
|
||||
buildAutoImageRequests(view, settings, run?.sceneImageUrl)
|
||||
buildAutoImageRequests(view, effectiveImageSettings, run?.sceneImageUrl)
|
||||
.forEach((request) => void generate(request.key, request.body));
|
||||
}, [coverReady, settings, view, run?.sceneImageUrl, generate]);
|
||||
}, [coverReady, effectiveImageSettings, view, run?.sceneImageUrl, generate]);
|
||||
|
||||
const title = props.sessionTitle?.trim() || run?.title?.trim() || (isZh ? "互动世界" : "Play World");
|
||||
|
||||
@@ -427,20 +414,20 @@ export function PlayHud(props: {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<aside className="absolute bottom-28 right-0 top-0 z-20 flex w-[330px] flex-col border-l border-border/40 bg-card/95 backdrop-blur shadow-xl">
|
||||
<header className="relative flex items-center gap-2.5 border-b border-border/40 px-4 py-3">
|
||||
<aside className="absolute bottom-28 right-0 top-0 z-20 flex w-[380px] max-w-[calc(100vw-1rem)] flex-col border-l border-border/40 bg-card/95 backdrop-blur shadow-xl">
|
||||
<header className="relative flex min-w-0 items-center gap-2.5 overflow-hidden border-b border-border/40 px-4 py-3">
|
||||
<span aria-hidden className="pointer-events-none absolute inset-x-0 -bottom-px h-px bg-gradient-to-r from-transparent via-primary/50 to-transparent" />
|
||||
{view?.turn != null ? (
|
||||
<div className="flex shrink-0 flex-col items-center leading-none">
|
||||
<span className="text-xl font-extrabold text-primary">{view.turn}</span>
|
||||
<span className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/60">{isZh ? "幕" : "Turn"}</span>
|
||||
<span className="text-[24px] leading-6 font-extrabold text-primary">{view.turn}</span>
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wider text-muted-foreground/60">{isZh ? "幕" : "Turn"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Gamepad2 size={16} className="shrink-0 text-primary" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-bold text-foreground">{title}</div>
|
||||
<div className="mt-0.5 text-[10.5px] text-muted-foreground">
|
||||
<div className="truncate text-[16px] leading-6 font-bold text-foreground">{title}</div>
|
||||
<div className="mt-0.5 text-[14px] leading-5 text-muted-foreground">
|
||||
{view?.turn == null
|
||||
? (isZh ? "尚未开始" : "Not started")
|
||||
: view?.mode
|
||||
@@ -449,21 +436,21 @@ export function PlayHud(props: {
|
||||
</div>
|
||||
</div>
|
||||
{view?.time?.value ? (
|
||||
<span className="shrink-0 rounded-full bg-secondary/60 px-2 py-0.5 text-[10.5px] text-muted-foreground" title={view.time.note ?? undefined}>
|
||||
<span className="max-w-[122px] shrink-0 truncate rounded-full bg-secondary/60 px-2.5 py-1 text-[15px] leading-6 text-muted-foreground" title={view.time.note ?? view.time.value}>
|
||||
{view.time.value}
|
||||
</span>
|
||||
) : null}
|
||||
<button type="button" onClick={() => { onClose(); setSelectedHoldingId(null); }} className="shrink-0 text-muted-foreground hover:text-foreground" title={isZh ? "收起" : "Collapse"}>
|
||||
<button type="button" onClick={() => { onClose(); setSelectedHoldingId(null); setSelectedFacingId(null); }} className="shrink-0 text-muted-foreground hover:text-foreground" title={isZh ? "收起" : "Collapse"}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4 text-sm">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4 text-[15px]">
|
||||
{!view ? (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border/50 bg-secondary/10 px-4 py-8 text-center">
|
||||
<span className="text-3xl opacity-80">🎲</span>
|
||||
<p className="text-[13px] font-semibold text-foreground">{isZh ? "这个世界还在沉睡" : "This world is still asleep"}</p>
|
||||
<p className="text-[11.5px] leading-6 text-muted-foreground">
|
||||
<p className="text-[16px] leading-6 font-semibold text-foreground">{isZh ? "这个世界还在沉睡" : "This world is still asleep"}</p>
|
||||
<p className="text-[14px] leading-6 text-muted-foreground">
|
||||
{isZh
|
||||
? "在左边写下你的第一个动作,人物、线索、状态会在这里逐渐点亮。"
|
||||
: "Take your first action on the left — characters, clues, and state will light up here."}
|
||||
@@ -476,6 +463,12 @@ export function PlayHud(props: {
|
||||
generating={generating.has(selectedHolding.id)}
|
||||
onBack={() => setSelectedHoldingId(null)}
|
||||
/>
|
||||
) : selectedFacing ? (
|
||||
<HudRowInspect
|
||||
row={selectedFacing}
|
||||
isZh={isZh}
|
||||
onBack={() => setSelectedFacingId(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{view.time && (view.time.note || view.time.details.length > 0) ? (
|
||||
@@ -495,7 +488,13 @@ export function PlayHud(props: {
|
||||
emptyText={isZh ? "周围还没有出现地点或人物" : "No places or people around yet"}
|
||||
>
|
||||
{view.facing.map((row) => (
|
||||
<Row key={row.id} row={row} isZh={isZh} generating={generating.has(row.id)} />
|
||||
<Row
|
||||
key={row.id}
|
||||
row={row}
|
||||
isZh={isZh}
|
||||
generating={generating.has(row.id)}
|
||||
onOpenVisual={row.imageUrl ? () => { setSelectedHoldingId(null); setSelectedFacingId(row.id); } : undefined}
|
||||
/>
|
||||
))}
|
||||
</Zone>
|
||||
|
||||
@@ -511,7 +510,7 @@ export function PlayHud(props: {
|
||||
row={row}
|
||||
isZh={isZh}
|
||||
generating={generating.has(row.id)}
|
||||
onOpen={() => setSelectedHoldingId(row.id)}
|
||||
onOpen={() => { setSelectedFacingId(null); setSelectedHoldingId(row.id); }}
|
||||
/>
|
||||
))}
|
||||
</Zone>
|
||||
@@ -528,19 +527,11 @@ export function PlayHud(props: {
|
||||
</Zone>
|
||||
|
||||
{view.premise && (
|
||||
<div className="rounded-lg border border-border/30 bg-secondary/30 px-3 py-2 text-[11px] leading-5 text-muted-foreground">
|
||||
<div className="rounded-lg border border-border/30 bg-secondary/30 px-3 py-2 text-[15px] leading-7 text-muted-foreground">
|
||||
{view.premise}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlayImagePanel
|
||||
isZh={isZh}
|
||||
settings={settings}
|
||||
coverReady={coverReady}
|
||||
onToggle={toggleSetting}
|
||||
onIllustrateMoment={() => generate("scene", { target: "scene" })}
|
||||
momentBusy={generating.has("scene")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -548,62 +539,6 @@ export function PlayHud(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function PlayImagePanel(props: {
|
||||
readonly isZh: boolean;
|
||||
readonly settings: PlayImageSettings;
|
||||
readonly coverReady: boolean;
|
||||
readonly onToggle: (key: keyof PlayImageSettings) => void;
|
||||
readonly onIllustrateMoment: () => void;
|
||||
readonly momentBusy: boolean;
|
||||
}) {
|
||||
const { isZh, settings, coverReady, onToggle, onIllustrateMoment, momentBusy } = props;
|
||||
const options: ReadonlyArray<{ key: keyof PlayImageSettings; label: string }> = [
|
||||
{ key: "actors", label: isZh ? "为角色配图" : "Illustrate characters" },
|
||||
{ key: "moments", label: isZh ? "为时刻配图" : "Illustrate moments" },
|
||||
{ key: "inventory", label: isZh ? "为背包配图" : "Illustrate inventory" },
|
||||
];
|
||||
return (
|
||||
<section className="relative rounded-xl border border-border/40 bg-secondary/20 px-3 pb-3 pt-2.5">
|
||||
<span aria-hidden className="absolute left-3 top-0 h-0.5 w-5 -translate-y-px rounded-full bg-primary/70" />
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
<span className="text-xs">🎨</span>{isZh ? "自动配图" : "Auto illustration"}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{options.map((opt) => (
|
||||
<label
|
||||
key={opt.key}
|
||||
className={`flex items-center gap-2 text-[12px] ${coverReady ? "cursor-pointer text-foreground" : "cursor-not-allowed text-muted-foreground/40"}`}
|
||||
title={coverReady ? undefined : (isZh ? "先在「模型配置」里配好生图 API 才能开启" : "Configure an image API in Model Settings first")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!coverReady}
|
||||
checked={coverReady && settings[opt.key]}
|
||||
onChange={() => onToggle(opt.key)}
|
||||
className="h-3.5 w-3.5 accent-primary"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{!coverReady ? (
|
||||
<p className="mt-2 text-[11px] leading-4 text-muted-foreground/50">
|
||||
{isZh ? "未检测到可用的生图 API。在「模型配置」里配好后即可勾选。" : "No image API configured. Set one up in Model Settings to enable."}
|
||||
</p>
|
||||
) : settings.moments ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onIllustrateMoment}
|
||||
disabled={momentBusy}
|
||||
className="mt-2 w-full rounded-lg border border-border/40 bg-secondary/40 px-2.5 py-1.5 text-[12px] font-medium text-foreground hover:text-primary disabled:opacity-50"
|
||||
>
|
||||
{momentBusy ? (isZh ? "配图中…" : "Illustrating…") : (isZh ? "为这一刻配图" : "Illustrate this moment")}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Zone(props: {
|
||||
readonly title: string;
|
||||
readonly icon: string;
|
||||
@@ -618,11 +553,11 @@ function Zone(props: {
|
||||
return (
|
||||
<section className="relative rounded-xl border border-border/40 bg-secondary/20 px-3 pb-3 pt-2.5">
|
||||
<span aria-hidden className="absolute left-3 top-0 h-0.5 w-5 -translate-y-px rounded-full bg-primary/70" />
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
<span className="text-xs">{props.icon}</span>{props.title}
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[15px] leading-6 font-semibold uppercase tracking-[0.08em] text-muted-foreground/70">
|
||||
<span className="text-[16px]">{props.icon}</span>{props.title}
|
||||
</h3>
|
||||
{props.empty ? (
|
||||
<p className="text-[11px] leading-5 text-muted-foreground/50">{props.emptyText}</p>
|
||||
<p className="text-[15px] leading-6 text-muted-foreground/50">{props.emptyText}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">{props.children}</div>
|
||||
)}
|
||||
@@ -630,38 +565,104 @@ function Zone(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ row, isZh, generating }: { readonly row: HudRow; readonly isZh: boolean; readonly generating?: boolean }) {
|
||||
function HudRowInspect(props: {
|
||||
readonly row: HudRow;
|
||||
readonly isZh: boolean;
|
||||
readonly onBack: () => void;
|
||||
}) {
|
||||
const { row, isZh, onBack } = props;
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
<button type="button" onClick={onBack} className="flex items-center gap-1 text-[14px] leading-6 text-muted-foreground hover:text-foreground">
|
||||
<ChevronLeft size={14} /> {isZh ? "返回" : "Back"}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 overflow-hidden rounded-xl border border-border/40 bg-secondary/30">
|
||||
<div className="flex min-h-[220px] items-center justify-center bg-gradient-to-b from-secondary/60 to-transparent">
|
||||
{row.imageUrl ? (
|
||||
<img src={row.imageUrl} alt="" aria-hidden="true" className="max-h-[420px] w-full object-contain" />
|
||||
) : (
|
||||
<span className="text-4xl">{row.glyph}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3 px-3 py-3">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className="min-w-0 flex-1 break-words text-[18px] leading-7 font-bold text-foreground">{row.label}</span>
|
||||
{row.value ? (
|
||||
<span className="shrink-0 rounded-full bg-secondary/60 px-2.5 py-1 text-[13px] leading-5 font-medium text-primary">
|
||||
{row.value}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{row.note ? <p className="break-words text-[14px] leading-6 text-muted-foreground">{row.note}</p> : null}
|
||||
{row.details.length > 0 ? (
|
||||
<div className="space-y-2 border-t border-border/30 pt-2">
|
||||
{row.details.map((detail, i) => (
|
||||
<p key={i} className="break-words text-[14px] leading-6 text-muted-foreground">
|
||||
{detail.label ? <span className="text-muted-foreground/50">{detail.label} </span> : null}
|
||||
{detail.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
row,
|
||||
isZh,
|
||||
generating,
|
||||
onOpenVisual,
|
||||
}: {
|
||||
readonly row: HudRow;
|
||||
readonly isZh: boolean;
|
||||
readonly generating?: boolean;
|
||||
readonly onOpenVisual?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const expandable = row.details.length > 0;
|
||||
const hasImage = !!row.imageUrl;
|
||||
const opensVisual = hasImage && !!onOpenVisual;
|
||||
const interactive = expandable || opensVisual;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/30 bg-secondary/30">
|
||||
<div className="min-w-0 rounded-lg border border-border/30 bg-secondary/30">
|
||||
<div
|
||||
role={expandable ? "button" : undefined}
|
||||
title={expandable ? (open ? (isZh ? "收起" : "Collapse") : (isZh ? "展开详情" : "Show details")) : undefined}
|
||||
onClick={expandable ? () => setOpen((o) => !o) : undefined}
|
||||
className={`px-2.5 py-1.5 ${expandable ? "cursor-pointer" : ""}`}
|
||||
role={interactive ? "button" : undefined}
|
||||
aria-label={interactive ? `${row.label} ${opensVisual ? (isZh ? "查看大图" : "view image") : open ? (isZh ? "收起详情" : "collapse details") : (isZh ? "展开详情" : "show details")}` : undefined}
|
||||
onClick={opensVisual ? onOpenVisual : expandable ? () => setOpen((o) => !o) : undefined}
|
||||
className={`px-2.5 py-2 ${interactive ? "cursor-pointer" : ""}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
{row.imageUrl ? (
|
||||
<img src={row.imageUrl} alt="" aria-hidden="true" className="h-7 w-7 shrink-0 self-center rounded object-cover" />
|
||||
<img src={row.imageUrl} alt="" aria-hidden="true" className="h-12 w-12 shrink-0 self-center rounded-lg object-cover" />
|
||||
) : (
|
||||
<span className="shrink-0 text-xs">{generating ? "⏳" : row.glyph}</span>
|
||||
<span className="shrink-0 pt-1 text-base leading-6">{generating ? "⏳" : row.glyph}</span>
|
||||
)}
|
||||
<span className="text-[13px] font-medium text-foreground">{row.label}</span>
|
||||
{row.value ? <span className="ml-auto text-[13px] font-semibold text-primary">{row.value}</span> : null}
|
||||
{expandable ? (
|
||||
<span className="min-w-0 flex-1 break-words text-[15px] leading-6 font-medium text-foreground">{row.label}</span>
|
||||
{row.value ? (
|
||||
<span
|
||||
className="ml-auto min-w-0 max-w-[58%] truncate text-right text-[15px] leading-6 font-semibold text-primary"
|
||||
title={row.value}
|
||||
>
|
||||
{row.value}
|
||||
</span>
|
||||
) : null}
|
||||
{expandable && !opensVisual ? (
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`${row.value ? "ml-1.5" : "ml-auto"} shrink-0 text-muted-foreground/50 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{row.note ? <div className="mt-0.5 pl-5 text-[11px] leading-4 text-muted-foreground">{row.note}</div> : null}
|
||||
{row.note ? <div className={`mt-1 break-words text-[14px] leading-6 text-muted-foreground ${hasImage ? "pl-[58px]" : "pl-6"}`}>{row.note}</div> : null}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="space-y-1 px-2.5 pb-2 pl-7">
|
||||
<div className={`min-w-0 space-y-1 px-2.5 pb-2 ${hasImage ? "pl-[68px]" : "pl-8"}`}>
|
||||
{row.details.map((detail, i) => (
|
||||
<p key={i} className="text-[11px] leading-5 text-muted-foreground">
|
||||
<p key={i} className="break-words text-[14px] leading-6 text-muted-foreground">
|
||||
{detail.label ? <span className="text-muted-foreground/50">{detail.label} </span> : null}
|
||||
{detail.text}
|
||||
</p>
|
||||
|
||||
@@ -106,6 +106,8 @@ export interface PlayToolDetails {
|
||||
readonly title?: string;
|
||||
readonly worldId?: string;
|
||||
readonly runId?: string;
|
||||
readonly turn?: number;
|
||||
readonly sceneImageUrl?: string;
|
||||
readonly sceneText?: string;
|
||||
readonly suggestedActions?: readonly string[];
|
||||
readonly variantId?: string;
|
||||
@@ -149,6 +151,12 @@ function numberField(record: Record<string, unknown>, key: string): number | und
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function nestedNumberField(record: Record<string, unknown>, objectKey: string, key: string): number | undefined {
|
||||
const value = record[objectKey];
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
return numberField(value as Record<string, unknown>, key);
|
||||
}
|
||||
|
||||
function actionPayloadField(record: Record<string, unknown>): ChatActionPayload | undefined {
|
||||
const value = record.actionPayload;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
@@ -238,12 +246,85 @@ export function getPlayToolDetails(exec: ToolExecution): PlayToolDetails | null
|
||||
title: stringField(record, "title"),
|
||||
worldId: stringField(record, "worldId"),
|
||||
runId: stringField(record, "runId"),
|
||||
turn: numberField(record, "turn")
|
||||
?? nestedNumberField(record, "currentState", "turn")
|
||||
?? (record.kind === "play_world_started" ? 0 : undefined),
|
||||
sceneImageUrl: stringField(record, "sceneImageUrl"),
|
||||
sceneText: stringField(record, "sceneText"),
|
||||
suggestedActions: suggested,
|
||||
variantId: stringField(record, "variantId"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlaySceneImageUrl(details: PlayToolDetails): string | null {
|
||||
if (details.sceneImageUrl) {
|
||||
return buildApiUrl(details.sceneImageUrl);
|
||||
}
|
||||
if (!details.worldId || !details.runId || details.turn == null) return null;
|
||||
const file = `scene-turn-${Math.trunc(details.turn)}.png`;
|
||||
return buildApiUrl(
|
||||
`/play/runs/${encodeURIComponent(details.worldId)}/${encodeURIComponent(details.runId)}/images/${encodeURIComponent(file)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function withCacheBust(url: string, attempt: number): string {
|
||||
const joiner = url.includes("?") ? "&" : "?";
|
||||
return `${url}${joiner}v=${attempt}`;
|
||||
}
|
||||
|
||||
function PlaySceneImagePreview({ details }: { details: PlayToolDetails }) {
|
||||
const url = useMemo(() => buildPlaySceneImageUrl(details), [details]);
|
||||
const [readyUrl, setReadyUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setReadyUrl(null);
|
||||
if (!url) return;
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
let attempt = 0;
|
||||
const maxAttempts = 40;
|
||||
|
||||
const probe = () => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
if (!cancelled) setReadyUrl(url);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (cancelled) return;
|
||||
attempt += 1;
|
||||
if (attempt < maxAttempts) {
|
||||
timer = window.setTimeout(probe, 2000);
|
||||
}
|
||||
};
|
||||
image.src = withCacheBust(url, attempt);
|
||||
};
|
||||
|
||||
probe();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (!readyUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-border/40 bg-background/80">
|
||||
<img
|
||||
src={readyUrl}
|
||||
alt="本幕配图"
|
||||
className="block max-h-[420px] w-full object-contain bg-muted/20"
|
||||
loading="lazy"
|
||||
/>
|
||||
{details.turn != null && (
|
||||
<div className="border-t border-border/40 px-3 py-2.5 text-[14px] leading-6 text-muted-foreground">
|
||||
第 {Math.trunc(details.turn)} 幕配图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlayEditDetails(exec: ToolExecution): PlayEditDetails | null {
|
||||
if (exec.tool !== "play_edit") return null;
|
||||
if (!exec.details || typeof exec.details !== "object") return null;
|
||||
@@ -316,38 +397,38 @@ function ProposedActionPreview({
|
||||
const locked = resolution !== undefined;
|
||||
const contractRows = getProposedActionContractRows(details);
|
||||
return (
|
||||
<div className="mx-3 mb-3 mt-1 rounded-xl border border-primary/25 bg-primary/5 px-3 py-3">
|
||||
<div className="text-sm font-semibold text-foreground">{details.title ?? "确认执行"}</div>
|
||||
<div className="mx-3 mb-3 mt-1 rounded-xl border border-primary/25 bg-primary/5 px-4 py-3.5">
|
||||
<div className="text-[17px] leading-6 font-semibold text-foreground">{details.title ?? "确认执行"}</div>
|
||||
{details.summary && (
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{details.summary}</div>
|
||||
<div className="mt-1.5 whitespace-pre-wrap break-words text-[15px] leading-7 text-muted-foreground">{details.summary}</div>
|
||||
)}
|
||||
<div className="mt-2 rounded-lg bg-background/70 px-2.5 py-2 text-xs leading-5 text-muted-foreground">
|
||||
<div className="mt-2.5 whitespace-pre-wrap break-words rounded-lg bg-background/70 px-3 py-2.5 text-[15px] leading-7 text-muted-foreground">
|
||||
{details.instruction}
|
||||
</div>
|
||||
{contractRows.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{contractRows.map((row) => (
|
||||
<div key={row.label} className="rounded-lg border border-border/50 bg-background/60 px-2.5 py-2">
|
||||
<div className="text-[11px] font-semibold text-foreground">{row.label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{row.value}</div>
|
||||
<div key={row.label} className="rounded-lg border border-border/50 bg-background/60 px-3 py-2.5">
|
||||
<div className="text-[13px] leading-5 font-semibold text-foreground">{row.label}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words text-[15px] leading-7 text-muted-foreground">{row.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{resolution === "confirmed" ? (
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-primary">
|
||||
<Check size={13} className="shrink-0" />
|
||||
<div className="mt-3 flex items-center gap-1.5 text-[15px] leading-6 font-medium text-primary">
|
||||
<Check size={15} className="shrink-0" />
|
||||
{details.targetRoute ? "已打开" : "已执行"}
|
||||
</div>
|
||||
) : resolution === "rejected" ? (
|
||||
<div className="mt-3 text-xs font-medium text-muted-foreground">已取消</div>
|
||||
<div className="mt-3 text-[15px] leading-6 font-medium text-muted-foreground">已取消</div>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onProposedAction?.(details)}
|
||||
disabled={!onProposedAction || streaming || locked}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"
|
||||
className="rounded-lg bg-primary px-3.5 py-2 text-[15px] leading-6 font-medium text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{streaming ? "执行中…" : details.targetRoute ? "打开入口" : "继续执行"}
|
||||
</button>
|
||||
@@ -355,7 +436,7 @@ function ProposedActionPreview({
|
||||
type="button"
|
||||
onClick={() => onRejectProposedAction?.(details)}
|
||||
disabled={!onRejectProposedAction || streaming || locked}
|
||||
className="rounded-lg border border-border/60 bg-background/80 px-3 py-1.5 text-xs font-medium text-muted-foreground disabled:opacity-50"
|
||||
className="rounded-lg border border-border/60 bg-background/80 px-3.5 py-2 text-[15px] leading-6 font-medium text-muted-foreground disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -378,25 +459,20 @@ function PlayResultPreview({ exec }: { exec: ToolExecution }) {
|
||||
: "互动世界已推进";
|
||||
return (
|
||||
<div className="mx-3 mb-3 mt-1 rounded-xl border border-primary/20 bg-primary/5 px-3 py-3">
|
||||
<div className="mb-2 text-xs font-semibold text-primary">
|
||||
<div className="mb-2 text-[16px] leading-6 font-semibold text-primary">
|
||||
{label}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-6 text-foreground">{details.sceneText}</div>
|
||||
<div className="whitespace-pre-wrap text-base leading-7 text-foreground">{details.sceneText}</div>
|
||||
<PlaySceneImagePreview details={details} />
|
||||
{details.suggestedActions && details.suggestedActions.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{details.suggestedActions.slice(0, 4).map((action) => (
|
||||
<span key={action} className="rounded-full border border-border/60 bg-background/70 px-2 py-1 text-[11px] text-muted-foreground">
|
||||
<span key={action} className="rounded-full border border-border/60 bg-background/70 px-3 py-1.5 text-[15px] leading-6 text-muted-foreground">
|
||||
{action}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(details.worldId || details.runId) && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
{details.worldId}{details.runId ? ` / ${details.runId}` : ""}
|
||||
{details.variantId ? ` · ${details.variantId}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -413,15 +489,10 @@ function PlayEditPreview({ exec }: { exec: ToolExecution }) {
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div className="mx-3 mb-3 mt-1 rounded-xl border border-primary/20 bg-primary/5 px-3 py-2.5">
|
||||
<div className="text-xs font-semibold text-primary">互动世界设定已更新</div>
|
||||
<div className="text-[16px] leading-6 font-semibold text-primary">互动世界设定已更新</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{changes.length > 0 ? changes.join(" · ") : "已写入当前世界。"}
|
||||
</div>
|
||||
{(details.worldId || details.runId) && (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{details.worldId}{details.runId ? ` / ${details.runId}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -472,19 +543,19 @@ function PipelineExecution({
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="rounded-xl border border-border/40 bg-card/60">
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl hover:bg-card/80 transition-colors cursor-pointer">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
<span className="text-[16px] leading-6 font-medium text-foreground truncate">
|
||||
{exec.label}
|
||||
{bookId && <span className="text-muted-foreground font-normal"> · {bookId}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
<span className="text-[12px] text-muted-foreground/60">
|
||||
{isActive
|
||||
? formatDuration(exec.startedAt, exec.startedAt + elapsedMs)
|
||||
: exec.completedAt ? formatDuration(exec.startedAt, exec.completedAt) : ""}
|
||||
</span>
|
||||
<ExecStatusBadge status={exec.status} />
|
||||
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
<ChevronDown size={16} className={`text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<ProposedActionPreview
|
||||
|
||||
@@ -172,6 +172,7 @@ describe("groupChronologically", () => {
|
||||
runId: "main",
|
||||
sceneText: "你翻开账本,发现一张旧船票。",
|
||||
suggestedActions: ["藏起船票", "追问来人"],
|
||||
currentState: { turn: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,6 +181,7 @@ describe("groupChronologically", () => {
|
||||
title: "雨夜茶馆",
|
||||
worldId: "rain-teahouse",
|
||||
runId: "main",
|
||||
turn: 3,
|
||||
sceneText: "你翻开账本,发现一张旧船票。",
|
||||
suggestedActions: ["藏起船票", "追问来人"],
|
||||
});
|
||||
|
||||
@@ -14,23 +14,23 @@ export function HoldingInspect(props: {
|
||||
const kind = (isZh ? KIND_LABEL_ZH : KIND_LABEL_EN)[row.kind] ?? row.kind;
|
||||
const ladderLabel = isZh ? LADDER_LABEL_ZH : LADDER_LABEL_EN;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<button type="button" onClick={onBack} className="flex items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<button type="button" onClick={onBack} className="flex items-center gap-1 text-[14px] leading-6 text-muted-foreground hover:text-foreground">
|
||||
<ChevronLeft size={14} /> {isZh ? "返回" : "Back"}
|
||||
</button>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border/40 bg-secondary/30">
|
||||
<div className="flex h-24 items-center justify-center bg-gradient-to-b from-secondary/60 to-transparent text-4xl">
|
||||
{row.imageUrl ? <img src={row.imageUrl} alt="" aria-hidden="true" className="h-full w-full object-cover" /> : <span>{generating ? "⏳" : row.glyph}</span>}
|
||||
<div className="min-w-0 overflow-hidden rounded-xl border border-border/40 bg-secondary/30">
|
||||
<div className="flex min-h-[180px] items-center justify-center bg-gradient-to-b from-secondary/60 to-transparent text-4xl">
|
||||
{row.imageUrl ? <img src={row.imageUrl} alt="" aria-hidden="true" className="max-h-[320px] w-full object-contain" /> : <span>{generating ? "⏳" : row.glyph}</span>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-3 py-3">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[15px] font-bold text-foreground">{row.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground">{kind}</span>
|
||||
<div className="min-w-0 space-y-3 px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className="min-w-0 flex-1 break-words text-[17px] leading-6 font-bold text-foreground">{row.label}</span>
|
||||
<span className="shrink-0 pt-0.5 text-[13px] text-muted-foreground">{kind}</span>
|
||||
</div>
|
||||
{row.summary ? <p className="mt-1 text-[12px] leading-5 text-muted-foreground">{row.summary}</p> : null}
|
||||
{row.summary ? <p className="mt-1 break-words text-[14px] leading-6 text-muted-foreground">{row.summary}</p> : null}
|
||||
</div>
|
||||
|
||||
{lifecycle ? (
|
||||
@@ -39,7 +39,7 @@ export function HoldingInspect(props: {
|
||||
{lifecycle.stages.map((stage) => (
|
||||
<span
|
||||
key={stage}
|
||||
className={`flex-1 rounded px-1 py-1 text-center text-[9px] ${
|
||||
className={`flex-1 rounded px-1 py-1 text-center text-[11px] leading-4 ${
|
||||
stage === lifecycle.current ? "bg-primary/20 font-bold text-primary" : "bg-secondary/40 text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
@@ -47,45 +47,45 @@ export function HoldingInspect(props: {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{lifecycle.reason ? <p className="mt-1.5 text-[11px] text-emerald-300">▲ {lifecycle.reason}</p> : null}
|
||||
{lifecycle.reason ? <p className="mt-1.5 break-words text-[13px] leading-5 text-emerald-300">▲ {lifecycle.reason}</p> : null}
|
||||
</div>
|
||||
) : row.statusPill ? (
|
||||
<div>
|
||||
<span className="inline-block rounded-full bg-secondary/60 px-2.5 py-1 text-[12px] font-medium text-foreground">{row.statusPill}</span>
|
||||
<span className="inline-block rounded-full bg-secondary/60 px-2.5 py-1 text-[14px] leading-5 font-medium text-foreground">{row.statusPill}</span>
|
||||
{row.isFresh ? (
|
||||
<p className="mt-1.5 text-[11px] text-emerald-300">▲ {isZh ? "刚获得" : "Just acquired"}</p>
|
||||
<p className="mt-1.5 text-[13px] leading-5 text-emerald-300">▲ {isZh ? "刚获得" : "Just acquired"}</p>
|
||||
) : row.changeReason ? (
|
||||
<p className="mt-1.5 text-[11px] text-emerald-300">▲ {row.changeReason}</p>
|
||||
<p className="mt-1.5 break-words text-[13px] leading-5 text-emerald-300">▲ {row.changeReason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{row.meters.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] uppercase tracking-wider text-muted-foreground/60">{isZh ? "属性" : "Stats"}</h4>
|
||||
<h4 className="text-[13px] leading-5 uppercase tracking-wider text-muted-foreground/60">{isZh ? "属性" : "Stats"}</h4>
|
||||
{row.meters.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-2 text-[12px]">
|
||||
<span className="w-12 shrink-0 text-muted-foreground">{m.label}</span>
|
||||
<div key={m.id} className="flex min-w-0 items-center gap-2 text-[14px] leading-6">
|
||||
<span className="w-12 shrink-0 break-words text-muted-foreground">{m.label}</span>
|
||||
{m.ratio != null ? (
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary/60">
|
||||
<span className="block h-full bg-primary" style={{ width: `${Math.round(m.ratio * 100)}%` }} />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto font-semibold text-primary">{m.value}</span>
|
||||
<span className="ml-auto min-w-0 break-words font-semibold text-primary">{m.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{row.relations.length > 0 ? (
|
||||
<div>
|
||||
<h4 className="mb-1 text-[10px] uppercase tracking-wider text-muted-foreground/60">{isZh ? "牵动" : "Connections"}</h4>
|
||||
<div className="min-w-0">
|
||||
<h4 className="mb-1 text-[13px] leading-5 uppercase tracking-wider text-muted-foreground/60">{isZh ? "牵动" : "Connections"}</h4>
|
||||
<RelationWeb centerLabel={row.label} relations={row.relations} isZh={isZh} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{row.provenanceTurn != null ? (
|
||||
<p className="border-t border-border/30 pt-2 text-[11px] text-muted-foreground/70">
|
||||
<p className="break-words border-t border-border/30 pt-2 text-[13px] leading-5 text-muted-foreground/70">
|
||||
{isZh ? `第 ${row.provenanceTurn} 幕 取得` : `Acquired · Turn ${row.provenanceTurn}`}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -25,14 +25,14 @@ export function HoldingSlot(props: {
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-semibold text-foreground">{row.label}</span>
|
||||
<span className="truncate text-[15px] leading-6 font-semibold text-foreground">{row.label}</span>
|
||||
{row.isFresh ? (
|
||||
<span className="shrink-0 rounded-full bg-emerald-500/20 px-1.5 text-[10px] font-medium text-emerald-300">
|
||||
<span className="shrink-0 rounded-full bg-emerald-500/20 px-1.5 text-[12px] leading-5 font-medium text-emerald-300">
|
||||
{isZh ? "新" : "NEW"}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="mt-0.5 flex items-center gap-1.5 text-[14px] leading-6 text-muted-foreground">
|
||||
<span className="shrink-0">{kind}</span>
|
||||
{row.preview ? <span className="truncate">· {row.preview}</span> : null}
|
||||
</span>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function RelationWeb(props: {
|
||||
const cy = H / 2;
|
||||
const { nodes, overflow } = layoutRelations(props.relations, W, H);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={props.isZh ? "关系网" : "Relations"}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="block w-full min-w-0" role="img" aria-label={props.isZh ? "关系网" : "Relations"}>
|
||||
{nodes.map((n, i) => (
|
||||
<line key={`l${i}`} x1={cx} y1={cy} x2={n.x} y2={n.y} className="stroke-border" strokeWidth={1.5} />
|
||||
))}
|
||||
|
||||
@@ -9,8 +9,8 @@ export function StateGauge({ row }: { readonly row: HudRow }) {
|
||||
const isPressurePill = row.kind === "pressure" && row.ratio == null;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<span className="shrink-0 text-xs">{row.glyph}</span>
|
||||
<div className="flex items-center gap-2 text-[15px] leading-6">
|
||||
<span className="shrink-0 text-[15px]">{row.glyph}</span>
|
||||
<span className="text-muted-foreground">{row.label}</span>
|
||||
{row.ratio != null ? (
|
||||
<span className="ml-2 h-1.5 flex-1 overflow-hidden rounded-full bg-secondary/60">
|
||||
@@ -18,12 +18,12 @@ export function StateGauge({ row }: { readonly row: HudRow }) {
|
||||
</span>
|
||||
) : null}
|
||||
{isPressurePill ? (
|
||||
<span className="ml-auto rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] font-semibold text-amber-300">⚠ {row.value}</span>
|
||||
<span className="ml-auto rounded-full bg-amber-500/15 px-2 py-0.5 text-[14px] leading-5 font-semibold text-amber-300">⚠ {row.value}</span>
|
||||
) : row.value ? (
|
||||
<span className="ml-auto text-[13px] font-semibold text-primary">{row.value}</span>
|
||||
<span className="ml-auto text-[15px] leading-6 font-semibold text-primary">{row.value}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{cause ? <p className="mt-0.5 pl-6 text-[11px] leading-4 text-muted-foreground/70">{cause}</p> : null}
|
||||
{cause ? <p className="mt-0.5 pl-6 text-[14px] leading-6 text-muted-foreground/70">{cause}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ChaptersSection({ bookId, isZh }: ChaptersSectionProps) {
|
||||
return (
|
||||
<SidebarCard title={isZh ? "章节" : "Chapters"}>
|
||||
{chapters.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground/50 italic">
|
||||
<p className="text-[15px] leading-6 text-muted-foreground/50 italic">
|
||||
{isZh ? "暂无章节" : "No chapters"}
|
||||
</p>
|
||||
) : (
|
||||
@@ -48,12 +48,12 @@ export function ChaptersSection({ bookId, isZh }: ChaptersSectionProps) {
|
||||
<li
|
||||
key={`${ch.number}-${ch.title ?? ""}`}
|
||||
onClick={() => useChatStore.getState().openChapterArtifact(ch.number)}
|
||||
className="flex items-center gap-2 py-0.5 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors rounded px-1 -mx-1 hover:bg-secondary/50">
|
||||
<span className={cn("text-[10px] shrink-0", ind.color)}>{ind.symbol}</span>
|
||||
className="flex items-center gap-2 py-1 text-[15px] leading-6 text-muted-foreground cursor-pointer hover:text-foreground transition-colors rounded px-1 -mx-1 hover:bg-secondary/50">
|
||||
<span className={cn("text-[13px] shrink-0", ind.color)}>{ind.symbol}</span>
|
||||
<span className="truncate flex-1">
|
||||
{String(ch.number).padStart(2, "0")} {ch.title || (isZh ? `第${ch.number}章` : `Chapter ${ch.number}`)}
|
||||
</span>
|
||||
<span className="tabular-nums text-[10px] text-muted-foreground/50 shrink-0">
|
||||
<span className="tabular-nums text-[13px] text-muted-foreground/50 shrink-0">
|
||||
{(ch.wordCount ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -67,11 +67,11 @@ function RoleEntry({ role }: { readonly role: RoleRef }) {
|
||||
onClick={() => openArtifact(role.path)}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg bg-secondary/30 hover:bg-secondary/50 transition-colors text-left"
|
||||
>
|
||||
<Users size={14} className="shrink-0 text-muted-foreground/60" />
|
||||
<span className="text-sm font-medium text-foreground font-['SimSun','Songti_SC','STSong',serif] flex-1 truncate">
|
||||
<Users size={16} className="shrink-0 text-muted-foreground/60" />
|
||||
<span className="text-[15px] leading-6 font-medium text-foreground font-['SimSun','Songti_SC','STSong',serif] flex-1 truncate">
|
||||
{role.name}
|
||||
</span>
|
||||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full shrink-0", badge.color)}>
|
||||
<span className={cn("text-[12px] px-1.5 py-0.5 rounded-full shrink-0", badge.color)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -90,29 +90,29 @@ function CharacterCard({ char }: { readonly char: CharacterInfo }) {
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-2 text-left"
|
||||
>
|
||||
<Users size={14} className="shrink-0 text-muted-foreground/60" />
|
||||
<span className="text-sm font-medium text-foreground font-['SimSun','Songti_SC','STSong',serif] flex-1 truncate">
|
||||
<Users size={16} className="shrink-0 text-muted-foreground/60" />
|
||||
<span className="text-[15px] leading-6 font-medium text-foreground font-['SimSun','Songti_SC','STSong',serif] flex-1 truncate">
|
||||
{char.name}
|
||||
</span>
|
||||
{role && (
|
||||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full shrink-0", getRoleColor(role))}>
|
||||
<span className={cn("text-[12px] px-1.5 py-0.5 rounded-full shrink-0", getRoleColor(role))}>
|
||||
{role.split("/")[0].trim()}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={12} className={cn("text-muted-foreground/50 transition-transform shrink-0", expanded && "rotate-180")} />
|
||||
<ChevronDown size={14} className={cn("text-muted-foreground/50 transition-transform shrink-0", expanded && "rotate-180")} />
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-2.5 pb-2.5 space-y-1">
|
||||
{tags && (
|
||||
<p className="text-xs text-muted-foreground"><span className="text-muted-foreground/60">标签</span> {tags}</p>
|
||||
<p className="text-[14px] leading-6 text-muted-foreground"><span className="text-muted-foreground/60">标签</span> {tags}</p>
|
||||
)}
|
||||
{current && (
|
||||
<p className="text-xs text-muted-foreground"><span className="text-muted-foreground/60">当前</span> {current}</p>
|
||||
<p className="text-[14px] leading-6 text-muted-foreground"><span className="text-muted-foreground/60">当前</span> {current}</p>
|
||||
)}
|
||||
{Object.entries(char.fields)
|
||||
.filter(([k]) => !["定位", "Role", "标签", "Tags", "当前", "Current"].includes(k))
|
||||
.map(([key, val]) => (
|
||||
<p key={key} className="text-xs text-muted-foreground">
|
||||
<p key={key} className="text-[14px] leading-6 text-muted-foreground">
|
||||
<span className="text-muted-foreground/60">{key}</span> {val}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -42,9 +42,9 @@ export function FoundationSection({ bookId }: FoundationSectionProps) {
|
||||
<li key={item.name}>
|
||||
<button
|
||||
onClick={() => openArtifact(item.name)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors font-['SimSun','Songti_SC','STSong',serif]"
|
||||
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg text-[15px] leading-6 font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors font-['SimSun','Songti_SC','STSong',serif]"
|
||||
>
|
||||
<FileText size={14} className="shrink-0 text-muted-foreground/60" />
|
||||
<FileText size={16} className="shrink-0 text-muted-foreground/60" />
|
||||
<span className="truncate">{FOUNDATION_FILE_LABELS[item.name]}</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -12,9 +12,9 @@ export function FrontmatterCards({ cards }: FrontmatterCardsProps) {
|
||||
<div className="flex flex-col gap-1.5 mb-3">
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} className="rounded-lg bg-secondary/30 px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground/60 mb-1">{card.label}</div>
|
||||
<div className="text-[12px] leading-5 text-muted-foreground/60 mb-1">{card.label}</div>
|
||||
{card.values.length === 1 ? (
|
||||
<div className="text-sm text-foreground font-['SimSun','Songti_SC','STSong',serif]">
|
||||
<div className="text-[15px] leading-6 text-foreground font-['SimSun','Songti_SC','STSong',serif]">
|
||||
{card.values[0]}
|
||||
</div>
|
||||
) : (
|
||||
@@ -22,7 +22,7 @@ export function FrontmatterCards({ cards }: FrontmatterCardsProps) {
|
||||
{card.values.map((value, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-sm text-foreground flex gap-1.5 font-['SimSun','Songti_SC','STSong',serif]"
|
||||
className="text-[15px] leading-6 text-foreground flex gap-1.5 font-['SimSun','Songti_SC','STSong',serif]"
|
||||
>
|
||||
<span className="text-muted-foreground/40 shrink-0">·</span>
|
||||
<span>{value}</span>
|
||||
|
||||
@@ -22,7 +22,7 @@ function hookTypeColor(type: string): string {
|
||||
export function PendingHooksView({ content }: PendingHooksViewProps) {
|
||||
const hooks = parsePendingHooks(content);
|
||||
if (hooks.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground/60 italic">还没有埋下伏笔。</p>;
|
||||
return <p className="text-[14px] leading-6 text-muted-foreground/60 italic">还没有埋下伏笔。</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -30,30 +30,30 @@ export function PendingHooksView({ content }: PendingHooksViewProps) {
|
||||
<div key={hook.id} className="rounded-lg bg-secondary/30 px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-1.5 flex-wrap">
|
||||
{hook.promoted === false && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-zinc-500/10 text-muted-foreground">
|
||||
<span className="text-[12px] px-1.5 py-0.5 rounded-full bg-zinc-500/10 text-muted-foreground">
|
||||
种子
|
||||
</span>
|
||||
)}
|
||||
{hook.promoted === true && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||
<span className="text-[12px] px-1.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||
活跃
|
||||
</span>
|
||||
)}
|
||||
{hook.type && (
|
||||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full", hookTypeColor(hook.type))}>
|
||||
<span className={cn("text-[12px] px-1.5 py-0.5 rounded-full", hookTypeColor(hook.type))}>
|
||||
{hook.type}
|
||||
</span>
|
||||
)}
|
||||
{hook.core && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||
<span className="text-[12px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||
核心
|
||||
</span>
|
||||
)}
|
||||
{hook.payoff && (
|
||||
<span className="text-[11px] text-muted-foreground/50 ml-auto">回收 · {hook.payoff}</span>
|
||||
<span className="text-[12px] text-muted-foreground/50 ml-auto">回收 · {hook.payoff}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-6 font-['SimSun','Songti_SC','STSong',serif]">
|
||||
<p className="text-[15px] text-foreground leading-7 font-['SimSun','Songti_SC','STSong',serif]">
|
||||
{hook.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SidebarCard({ title, defaultOpen = true, children, actions }: Si
|
||||
<span className="text-base font-medium text-foreground font-['SimSun','Songti_SC','STSong',serif]">{title}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{actions}
|
||||
<ChevronDown className={cn("w-4 h-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
<ChevronDown className={cn("w-[18px] h-[18px] text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</div>
|
||||
</button>
|
||||
{open && <div className="px-3 pb-3">{children}</div>}
|
||||
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
const SIDEBAR_MD_CLASS =
|
||||
"text-xs text-muted-foreground leading-relaxed " +
|
||||
"text-[15px] text-muted-foreground leading-7 " +
|
||||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 " +
|
||||
"[&>p+p]:mt-1.5 [&_strong]:text-foreground [&_strong]:font-medium " +
|
||||
"[&>p+p]:mt-2 [&_strong]:text-foreground [&_strong]:font-medium " +
|
||||
"[&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-0.5 " +
|
||||
"[&_h1]:hidden [&_h2]:text-xs [&_h2]:font-medium [&_h2]:text-foreground [&_h2]:mt-1.5 [&_h2]:mb-0.5 " +
|
||||
"[&_h3]:text-xs [&_h3]:font-medium [&_h3]:text-foreground [&_h3]:mt-1.5 [&_h3]:mb-0.5 " +
|
||||
"[&_code]:text-[11px] [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:bg-secondary/60";
|
||||
"[&_h1]:hidden [&_h2]:text-[15px] [&_h2]:font-medium [&_h2]:text-foreground [&_h2]:mt-2 [&_h2]:mb-1 " +
|
||||
"[&_h3]:text-[15px] [&_h3]:font-medium [&_h3]:text-foreground [&_h3]:mt-2 [&_h3]:mb-1 " +
|
||||
"[&_code]:text-[12px] [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:bg-secondary/60";
|
||||
|
||||
interface LegacySummary {
|
||||
world: string;
|
||||
@@ -134,7 +134,7 @@ export function SummarySection({ bookId }: SummarySectionProps) {
|
||||
const openFull = (
|
||||
<button
|
||||
onClick={() => openArtifact("outline/story_frame.md")}
|
||||
className="mt-2 text-xs text-primary hover:underline font-['SimSun','Songti_SC','STSong',serif]"
|
||||
className="mt-2 text-[15px] leading-6 text-primary hover:underline font-['SimSun','Songti_SC','STSong',serif]"
|
||||
>
|
||||
查看完整设定 →
|
||||
</button>
|
||||
|
||||
@@ -6,8 +6,8 @@ const strings = {
|
||||
// Header
|
||||
"nav.books": { zh: "书籍", en: "Books" },
|
||||
"nav.newBook": { zh: "新建书籍", en: "New Book" },
|
||||
"nav.createSection": { zh: "InkOS 创作", en: "InkOS Create" },
|
||||
"nav.myBooks": { zh: "我的书架", en: "My Bookshelf" },
|
||||
"nav.createSection": { zh: "开始创作", en: "Start Creating" },
|
||||
"nav.myBooks": { zh: "我的创作", en: "My Works" },
|
||||
"nav.createNovel": { zh: "长篇小说", en: "Long Novel" },
|
||||
"nav.createShort": { zh: "短篇小说", en: "Short Story" },
|
||||
"nav.createPlay": { zh: "InkOS 互动", en: "InkOS Play" },
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 450;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -140,6 +140,23 @@ body {
|
||||
transition: background 0.5s ease, color 0.5s ease;
|
||||
}
|
||||
|
||||
/* ── UI readability scale ──
|
||||
Studio uses many Tailwind small-text utilities for dense panels. Raise the
|
||||
common small sizes globally so the whole app reads one step larger without
|
||||
touching layout spacing or display headings. */
|
||||
.text-xs { font-size: 0.8125rem; line-height: 1.15rem; }
|
||||
.text-sm { font-size: 0.9375rem; line-height: 1.45rem; }
|
||||
.text-base { font-size: 1.0625rem; line-height: 1.6rem; }
|
||||
.text-lg { font-size: 1.1875rem; line-height: 1.75rem; }
|
||||
.text-\[8px\] { font-size: 9px; }
|
||||
.text-\[9px\] { font-size: 10px; }
|
||||
.text-\[10px\] { font-size: 11px; }
|
||||
.text-\[10\.5px\] { font-size: 11.5px; }
|
||||
.text-\[11px\] { font-size: 12px; line-height: 1.15rem; }
|
||||
.text-\[11\.5px\] { font-size: 12.5px; line-height: 1.2rem; }
|
||||
.text-\[12px\] { font-size: 13px; line-height: 1.25rem; }
|
||||
.text-\[13px\] { font-size: 14px; line-height: 1.35rem; }
|
||||
|
||||
/* ── Cursor interaction rules ── */
|
||||
button, [role="button"], a, select, label[for], summary { cursor: pointer; }
|
||||
button:disabled, [aria-disabled="true"] { cursor: not-allowed; }
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ChevronDown,
|
||||
Check,
|
||||
Gamepad2,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
import { Shimmer } from "../components/ai-elements/shimmer";
|
||||
import {
|
||||
@@ -71,6 +72,22 @@ interface ServiceConfigPayload {
|
||||
readonly defaultModel?: string | null;
|
||||
}
|
||||
|
||||
interface PlayImageSettings {
|
||||
readonly actors: boolean;
|
||||
readonly moments: boolean;
|
||||
readonly inventory: boolean;
|
||||
}
|
||||
|
||||
interface PlayRunImagePayload {
|
||||
readonly imageSettings?: PlayImageSettings;
|
||||
}
|
||||
|
||||
interface CoverConfigResponse {
|
||||
readonly service?: string | null;
|
||||
readonly configured?: boolean;
|
||||
readonly providers?: ReadonlyArray<{ readonly service: string; readonly connected?: boolean }>;
|
||||
}
|
||||
|
||||
// -- Component --
|
||||
|
||||
export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-create", nav, theme, t, sse: _sse }: ChatPageProps) {
|
||||
@@ -114,7 +131,11 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
|
||||
// World panel (holdings / state / relations) defaults collapsed; the scene
|
||||
// image and choices live in the chat center now, opened on demand.
|
||||
const [worldPanelOpen, setWorldPanelOpen] = useState(false);
|
||||
const [playSceneImageUrl, setPlaySceneImageUrl] = useState<string | undefined>(undefined);
|
||||
const [playImageError, setPlayImageError] = useState<string | null>(null);
|
||||
const [playImageMenuOpen, setPlayImageMenuOpen] = useState(false);
|
||||
const [playImageSettings, setPlayImageSettings] = useState<PlayImageSettings>({ actors: false, moments: false, inventory: false });
|
||||
const [playImageCoverReady, setPlayImageCoverReady] = useState(false);
|
||||
const worldPanelInsetClass = currentSessionKind === "play" && worldPanelOpen ? "lg:pr-[380px]" : "";
|
||||
|
||||
// Derived: is the assistant currently streaming/thinking/executing tools?
|
||||
const isStreaming = useMemo(() => {
|
||||
@@ -362,6 +383,49 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { setPlayImageError(null); }, [activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || currentSessionKind !== "play") return;
|
||||
let cancelled = false;
|
||||
void fetchJson<PlayRunImagePayload>(`/play/runs/${encodeURIComponent(activeSessionId)}/main`)
|
||||
.then((payload) => {
|
||||
if (!cancelled && payload.imageSettings) setPlayImageSettings(payload.imageSettings);
|
||||
})
|
||||
.catch(() => {
|
||||
// No persisted play world yet.
|
||||
});
|
||||
void fetchJson<CoverConfigResponse>("/cover/config")
|
||||
.then((cfg) => {
|
||||
if (cancelled) return;
|
||||
const selected = cfg.service ?? null;
|
||||
setPlayImageCoverReady(
|
||||
cfg.configured ?? (!!selected && (cfg.providers ?? []).some((p) => p.service === selected && p.connected)),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPlayImageCoverReady(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [activeSessionId, currentSessionKind]);
|
||||
|
||||
const togglePlayImageSetting = async (key: keyof PlayImageSettings) => {
|
||||
if (!activeSessionId || currentSessionKind !== "play" || !playImageCoverReady) return;
|
||||
const next = { ...playImageSettings, [key]: !playImageSettings[key] };
|
||||
setPlayImageSettings(next);
|
||||
setPlayImageError(null);
|
||||
try {
|
||||
await fetchJson(`/play/runs/${encodeURIComponent(activeSessionId)}/main/image-settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
} catch (error) {
|
||||
setPlayImageSettings(playImageSettings);
|
||||
setPlayImageError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const emptyGuidance = (() => {
|
||||
if (currentSessionKind === "short") {
|
||||
return isZh
|
||||
@@ -383,7 +447,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
|
||||
{/* Message scroll area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="chat-message-scroll flex-1 overflow-y-auto [scrollbar-gutter:stable] px-4 py-6"
|
||||
className={`chat-message-scroll flex-1 overflow-y-auto [scrollbar-gutter:stable] px-4 py-6 transition-[padding] duration-200 ${worldPanelInsetClass}`}
|
||||
>
|
||||
{needsPlayModeChoice ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center select-none gap-4">
|
||||
@@ -521,45 +585,37 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
|
||||
|
||||
{/* Quick actions (only when a book is active) */}
|
||||
{hasBook && !showChoicePanel && (
|
||||
<div className="shrink-0 max-w-3xl mx-auto w-full px-4">
|
||||
<QuickActions
|
||||
onAction={handleQuickAction}
|
||||
disabled={loading || !activeSessionId}
|
||||
isZh={isZh}
|
||||
/>
|
||||
<div className={`shrink-0 transition-[padding] duration-200 ${worldPanelInsetClass}`}>
|
||||
<div className="max-w-3xl mx-auto w-full px-4">
|
||||
<QuickActions
|
||||
onAction={handleQuickAction}
|
||||
disabled={loading || !activeSessionId}
|
||||
isZh={isZh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play focus: the current scene image sits centered in the chat, choices
|
||||
directly beneath it. pick-playstyle hides this; choices are shortcuts,
|
||||
not a replacement for free actions. */}
|
||||
{currentSessionKind === "play" && !needsPlayModeChoice && (playSceneImageUrl || showChoicePanel) && (
|
||||
<div className="shrink-0">
|
||||
{playSceneImageUrl ? (
|
||||
<div className="flex justify-center px-4 pt-3">
|
||||
<img
|
||||
src={playSceneImageUrl}
|
||||
alt={isZh ? "本幕配图" : "This moment"}
|
||||
className="max-h-[40vh] w-auto max-w-3xl rounded-xl border border-border/40 object-contain shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showChoicePanel && (
|
||||
<PlayChoicePanel
|
||||
choices={playChoices}
|
||||
disabled={loading || !activeSessionId}
|
||||
isZh={isZh}
|
||||
onChoose={(action) => {
|
||||
if (activeSessionId) void sendMessage(activeSessionId, action, { activeBookId, sessionKind: "play", actionSource: "button" });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Play choices are shortcuts, not a replacement for free actions. Scene
|
||||
images render inside their corresponding chat result card so the
|
||||
visual history scrolls with the conversation. */}
|
||||
{currentSessionKind === "play" && !needsPlayModeChoice && showChoicePanel && (
|
||||
<div className={`shrink-0 transition-[padding] duration-200 ${worldPanelInsetClass}`}>
|
||||
<PlayChoicePanel
|
||||
choices={playChoices}
|
||||
disabled={loading || !activeSessionId}
|
||||
isZh={isZh}
|
||||
onChoose={(action) => {
|
||||
if (activeSessionId) void sendMessage(activeSessionId, action, { activeBookId, sessionKind: "play", actionSource: "button" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{needsPlayModeChoice ? null : (
|
||||
<div className="shrink-0 border-t border-border/40 px-4 py-3">
|
||||
<div className={`shrink-0 border-t border-border/40 px-4 py-3 transition-[padding] duration-200 ${worldPanelInsetClass}`}>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="rounded-xl bg-secondary/30 transition-all">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 rounded-xl bg-secondary/30 transition-all">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -569,7 +625,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
|
||||
placeholder={isZh ? "输入指令..." : "Enter command..."}
|
||||
disabled={loading || !activeSessionId}
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent text-sm leading-6 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"
|
||||
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"
|
||||
@@ -582,14 +638,14 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 pb-2 border-t border-border/20 pt-1.5">
|
||||
{modelPickerStatus === "loading" ? (
|
||||
<span className="text-xs text-muted-foreground/40 animate-pulse">加载模型...</span>
|
||||
<span className="text-[15px] text-muted-foreground/40 animate-pulse">加载模型...</span>
|
||||
) : modelPickerStatus === "ready" ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted text-sm transition-colors cursor-pointer">
|
||||
<span className="font-medium text-xs truncate max-w-[220px]">
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 px-2 py-1.5 rounded-md hover:bg-muted text-[16px] transition-colors cursor-pointer">
|
||||
<span className="font-medium truncate max-w-[260px]">
|
||||
{selectedModelLabel}
|
||||
</span>
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
<ChevronDown size={17} className="text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<ModelPickerContent
|
||||
groupedModels={groupedModels}
|
||||
@@ -602,7 +658,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
|
||||
) : (
|
||||
<button
|
||||
onClick={() => nav.toServices()}
|
||||
className="text-xs text-muted-foreground/50 hover:text-primary transition-colors"
|
||||
className="text-[15px] text-muted-foreground/50 hover:text-primary transition-colors"
|
||||
>
|
||||
配置模型 →
|
||||
</button>
|
||||
@@ -611,15 +667,67 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWorldPanelOpen((v) => !v)}
|
||||
className={`ml-auto flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${worldPanelOpen ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-muted hover:text-primary"}`}
|
||||
title={isZh ? "世界面板:持有 / 状态 / 关系" : "World panel: holdings / state / relations"}
|
||||
className={`ml-auto flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[16px] font-medium transition-colors ${worldPanelOpen ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-muted hover:text-primary"}`}
|
||||
title={isZh ? "查看世界:持有 / 状态 / 关系" : "View world: holdings / state / relations"}
|
||||
>
|
||||
<Gamepad2 size={14} />
|
||||
{isZh ? "世界" : "World"}
|
||||
<Gamepad2 size={18} />
|
||||
{isZh ? "查看世界" : "View World"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{currentSessionKind === "play" ? (
|
||||
<div className="relative mt-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlayImageMenuOpen((value) => !value)}
|
||||
disabled={loading || !activeSessionId}
|
||||
title={isZh ? "自动配图" : "Auto illustration"}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-xl border border-border/50 bg-secondary/40 shadow-sm transition-all hover:border-primary/50 hover:bg-primary/10 hover:text-primary active:scale-95 disabled:cursor-not-allowed disabled:opacity-30 ${playImageMenuOpen || playImageSettings.actors || playImageSettings.moments || playImageSettings.inventory ? "text-primary" : "text-muted-foreground"}`}
|
||||
aria-label={isZh ? "自动配图" : "Auto illustration"}
|
||||
>
|
||||
<Palette size={17} />
|
||||
</button>
|
||||
{playImageMenuOpen ? (
|
||||
<div className="absolute bottom-12 right-0 z-30 w-44 rounded-xl border border-border/50 bg-card/95 p-2 shadow-xl backdrop-blur">
|
||||
<div className="mb-1.5 px-1 text-[12px] leading-5 font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
{isZh ? "自动配图" : "Auto illustration"}
|
||||
</div>
|
||||
{(["actors", "moments", "inventory"] as const).map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-[14px] leading-6 ${playImageCoverReady ? "cursor-pointer text-foreground hover:bg-secondary/50" : "cursor-not-allowed text-muted-foreground/40"}`}
|
||||
title={playImageCoverReady ? undefined : (isZh ? "先在「模型配置」里配好生图 API 才能开启" : "Configure an image API in Model Settings first")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!playImageCoverReady}
|
||||
checked={playImageCoverReady && playImageSettings[key]}
|
||||
onChange={() => void togglePlayImageSetting(key)}
|
||||
className="h-4 w-4 accent-primary"
|
||||
/>
|
||||
{key === "actors"
|
||||
? (isZh ? "为角色配图" : "Characters")
|
||||
: key === "moments"
|
||||
? (isZh ? "为时刻配图" : "Moments")
|
||||
: (isZh ? "为背包配图" : "Inventory")}
|
||||
</label>
|
||||
))}
|
||||
{!playImageCoverReady ? (
|
||||
<p className="mt-1 px-1 text-[12px] leading-5 text-muted-foreground/50">
|
||||
{isZh ? "未检测到生图 API。" : "No image API configured."}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{playImageError ? (
|
||||
<p className="mt-2 text-right text-[13px] leading-5 text-destructive/80">
|
||||
{isZh ? `配图失败:${playImageError}` : `Image failed: ${playImageError}`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -631,7 +739,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
|
||||
isZh={isZh}
|
||||
open={worldPanelOpen}
|
||||
onClose={() => setWorldPanelOpen(false)}
|
||||
onSceneImageUrl={setPlaySceneImageUrl}
|
||||
imageSettings={playImageSettings}
|
||||
sessionTitle={activeSession?.title ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user