Przeglądaj źródła

fix(studio): localize runtime error copy

Ma 6 miesięcy temu
rodzic
commit
f0ade5f352

+ 15 - 0
packages/studio/src/hooks/use-api.test.ts

@@ -48,6 +48,21 @@ describe("fetchJson", () => {
 
 
     await expect(fetchJson("/books/../bad", {}, { fetchImpl })).rejects.toThrow("Invalid book ID: ../bad");
     await expect(fetchJson("/books/../bad", {}, { fetchImpl })).rejects.toThrow("Invalid book ID: ../bad");
   });
   });
+
+  it("localizes known runtime errors before throwing", async () => {
+    const fetchImpl = vi.fn(async () =>
+      new Response(JSON.stringify({
+        error: "Latest chapter 1 is state-degraded. Repair state or rewrite that chapter before continuing.",
+      }), {
+        status: 409,
+        headers: { "Content-Type": "application/json" },
+      }),
+    );
+
+    await expect(fetchJson("/books/demo/write-next", { method: "POST" }, { fetchImpl })).rejects.toThrow(
+      "最新第 1 章处于状态降级(state-degraded)。继续写下一章前,请先修复状态,或重写这一章。",
+    );
+  });
 });
 });
 
 
 describe("deriveInvalidationPaths", () => {
 describe("deriveInvalidationPaths", () => {

+ 4 - 3
packages/studio/src/hooks/use-api.ts

@@ -1,4 +1,5 @@
 import { useState, useEffect, useCallback } from "react";
 import { useState, useEffect, useCallback } from "react";
+import { localizeKnownRuntimeMessage } from "../lib/error-copy";
 
 
 const BASE = "/api/v1";
 const BASE = "/api/v1";
 const API_INVALIDATE_EVENT = "inkos:api-invalidate";
 const API_INVALIDATE_EVENT = "inkos:api-invalidate";
@@ -65,7 +66,7 @@ async function readErrorMessage(res: Response): Promise<string> {
     try {
     try {
       const json = await res.json() as { error?: unknown };
       const json = await res.json() as { error?: unknown };
       if (typeof json.error === "string" && json.error.trim()) {
       if (typeof json.error === "string" && json.error.trim()) {
-        return json.error;
+        return localizeKnownRuntimeMessage(json.error);
       }
       }
       if (
       if (
         json.error &&
         json.error &&
@@ -74,13 +75,13 @@ async function readErrorMessage(res: Response): Promise<string> {
         typeof (json.error as { message?: unknown }).message === "string" &&
         typeof (json.error as { message?: unknown }).message === "string" &&
         (json.error as { message: string }).message.trim()
         (json.error as { message: string }).message.trim()
       ) {
       ) {
-        return (json.error as { message: string }).message;
+        return localizeKnownRuntimeMessage((json.error as { message: string }).message);
       }
       }
     } catch {
     } catch {
       // fall through
       // fall through
     }
     }
   }
   }
-  return `${res.status} ${res.statusText}`.trim();
+  return localizeKnownRuntimeMessage(`${res.status} ${res.statusText}`.trim());
 }
 }
 
 
 export async function fetchJson<T>(
 export async function fetchJson<T>(

+ 20 - 0
packages/studio/src/lib/error-copy.test.ts

@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { localizeKnownRuntimeMessage } from "./error-copy";
+
+describe("localizeKnownRuntimeMessage", () => {
+  it("localizes the state-degraded continuation blocker", () => {
+    expect(localizeKnownRuntimeMessage(
+      "Latest chapter 1 is state-degraded. Repair state or rewrite that chapter before continuing.",
+    )).toBe("最新第 1 章处于状态降级(state-degraded)。继续写下一章前,请先修复状态,或重写这一章。");
+  });
+
+  it("localizes related state repair errors while preserving unknown messages", () => {
+    expect(localizeKnownRuntimeMessage("Chapter 3 is not state-degraded.")).toBe(
+      "第 3 章不是状态降级(state-degraded),无需按状态修复。",
+    );
+    expect(localizeKnownRuntimeMessage(
+      "Only the latest state-degraded chapter can be repaired safely (latest is 5).",
+    )).toBe("只能安全修复最新的状态降级(state-degraded)章节;当前最新章是第 5 章。");
+    expect(localizeKnownRuntimeMessage("Bad request")).toBe("Bad request");
+  });
+});

+ 29 - 0
packages/studio/src/lib/error-copy.ts

@@ -0,0 +1,29 @@
+const KNOWN_RUNTIME_REPLACEMENTS: ReadonlyArray<{
+  readonly pattern: RegExp;
+  readonly replacement: string;
+}> = [
+  {
+    pattern: /Latest chapter (\d+) is state-degraded\. Repair state or rewrite that chapter before continuing\./g,
+    replacement: "最新第 $1 章处于状态降级(state-degraded)。继续写下一章前,请先修复状态,或重写这一章。",
+  },
+  {
+    pattern: /Chapter (\d+) is not state-degraded\./g,
+    replacement: "第 $1 章不是状态降级(state-degraded),无需按状态修复。",
+  },
+  {
+    pattern: /Only the latest state-degraded chapter can be repaired safely \(latest is (\d+)\)\./g,
+    replacement: "只能安全修复最新的状态降级(state-degraded)章节;当前最新章是第 $1 章。",
+  },
+  {
+    pattern: /State repair still failed for chapter (\d+)\./g,
+    replacement: "第 $1 章状态修复仍然失败。",
+  },
+];
+
+export function localizeKnownRuntimeMessage(message: string): string {
+  let localized = message;
+  for (const entry of KNOWN_RUNTIME_REPLACEMENTS) {
+    localized = localized.replace(entry.pattern, entry.replacement);
+  }
+  return localized;
+}

+ 19 - 0
packages/studio/src/store/chat/__tests__/message-parts.test.ts

@@ -133,4 +133,23 @@ describe("buildPartsFromEvents", () => {
       expect(parts[0].execution.error).toBe("timeout");
       expect(parts[0].execution.error).toBe("timeout");
     }
     }
   });
   });
+
+  it("localizes known tool errors", () => {
+    const parts = buildPartsFromEvents([
+      { type: "tool:start", id: "t1", tool: "sub_agent", agent: "writer" },
+      {
+        type: "tool:end",
+        id: "t1",
+        isError: true,
+        result: "Latest chapter 1 is state-degraded. Repair state or rewrite that chapter before continuing.",
+      },
+    ]);
+
+    expect(parts[0].type).toBe("tool");
+    if (parts[0].type === "tool") {
+      expect(parts[0].execution.error).toBe(
+        "最新第 1 章处于状态降级(state-degraded)。继续写下一章前,请先修复状态,或重写这一章。",
+      );
+    }
+  });
 });
 });

+ 2 - 1
packages/studio/src/store/chat/parts-builder.ts

@@ -1,4 +1,5 @@
 import type { MessagePart, ToolExecution, PipelineStage } from "./types";
 import type { MessagePart, ToolExecution, PipelineStage } from "./types";
+import { localizeKnownRuntimeMessage } from "../../lib/error-copy";
 
 
 // -- Event types for the builder --
 // -- Event types for the builder --
 
 
@@ -118,7 +119,7 @@ export function buildPartsFromEvents(events: StreamEvent[]): MessagePart[] {
             const exec = p.execution;
             const exec = p.execution;
             exec.status = event.isError ? "error" : "completed";
             exec.status = event.isError ? "error" : "completed";
             exec.completedAt = Date.now();
             exec.completedAt = Date.now();
-            if (event.isError) exec.error = event.result;
+            if (event.isError) exec.error = event.result ? localizeKnownRuntimeMessage(event.result) : event.result;
             else exec.result = event.result;
             else exec.result = event.result;
             // Mark all remaining stages as completed
             // Mark all remaining stages as completed
             exec.stages = exec.stages?.map((s) =>
             exec.stages = exec.stages?.map((s) =>

+ 21 - 0
packages/studio/src/store/chat/slices/message/runtime.test.ts

@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+import { extractErrorMessage, extractToolError } from "./runtime";
+
+describe("chat runtime error copy", () => {
+  it("localizes known assistant errors", () => {
+    expect(extractErrorMessage({
+      message: "Latest chapter 1 is state-degraded. Repair state or rewrite that chapter before continuing.",
+    })).toBe("最新第 1 章处于状态降级(state-degraded)。继续写下一章前,请先修复状态,或重写这一章。");
+  });
+
+  it("localizes known tool errors", () => {
+    expect(extractToolError({
+      content: [
+        {
+          type: "text",
+          text: "Latest chapter 2 is state-degraded. Repair state or rewrite that chapter before continuing.",
+        },
+      ],
+    })).toBe("最新第 2 章处于状态降级(state-degraded)。继续写下一章前,请先修复状态,或重写这一章。");
+  });
+});

+ 7 - 6
packages/studio/src/store/chat/slices/message/runtime.ts

@@ -7,6 +7,7 @@ import type {
   SessionSummary,
   SessionSummary,
   ToolExecution,
   ToolExecution,
 } from "../../types";
 } from "../../types";
+import { localizeKnownRuntimeMessage } from "../../../../lib/error-copy";
 
 
 const NULL_BOOK_KEY = "__null__";
 const NULL_BOOK_KEY = "__null__";
 
 
@@ -30,8 +31,8 @@ export function bookKey(bookId: string | null | undefined): string {
 }
 }
 
 
 export function extractErrorMessage(error: string | { code?: string; message?: string }): string {
 export function extractErrorMessage(error: string | { code?: string; message?: string }): string {
-  if (typeof error === "string") return error;
-  return error.message ?? "Unknown error";
+  if (typeof error === "string") return localizeKnownRuntimeMessage(error);
+  return localizeKnownRuntimeMessage(error.message ?? "Unknown error");
 }
 }
 
 
 export function resolveToolLabel(tool: string, agent?: string): string {
 export function resolveToolLabel(tool: string, agent?: string): string {
@@ -49,16 +50,16 @@ export function summarizeResult(result: unknown): string {
 }
 }
 
 
 export function extractToolError(result: unknown): string {
 export function extractToolError(result: unknown): string {
-  if (typeof result === "string") return result.slice(0, 500);
+  if (typeof result === "string") return localizeKnownRuntimeMessage(result).slice(0, 500);
   if (result && typeof result === "object") {
   if (result && typeof result === "object") {
     const record = result as Record<string, unknown>;
     const record = result as Record<string, unknown>;
-    if (typeof record.content === "string") return record.content.slice(0, 500);
+    if (typeof record.content === "string") return localizeKnownRuntimeMessage(record.content).slice(0, 500);
     if (record.content && Array.isArray(record.content)) {
     if (record.content && Array.isArray(record.content)) {
       const textPart = record.content.find((content: any) => content.type === "text");
       const textPart = record.content.find((content: any) => content.type === "text");
-      if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
+      if (textPart) return localizeKnownRuntimeMessage((textPart as any).text ?? "").slice(0, 500);
     }
     }
   }
   }
-  return String(result).slice(0, 500);
+  return localizeKnownRuntimeMessage(String(result)).slice(0, 500);
 }
 }
 
 
 export function getOrCreateStream(
 export function getOrCreateStream(