Просмотр исходного кода

refactor(desktop): improve error handling and translation in server error formatting (#16171)

OpeOginni 1 месяц назад
Родитель
Сommit
27baa2d65c

+ 2 - 1
packages/app/src/components/prompt-input/submit.ts

@@ -16,6 +16,7 @@ import { Identifier } from "@/utils/id"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { buildRequestParts } from "./build-request-parts"
 import { setCursorPosition } from "./editor-dom"
+import { formatServerError } from "@/utils/server-errors"
 
 type PendingPrompt = {
   abort: AbortController
@@ -286,7 +287,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
           .catch((err) => {
             showToast({
               title: language.t("prompt.toast.commandSendFailed.title"),
-              description: errorMessage(err),
+              description: formatServerError(err, language.t, language.t("common.requestFailed")),
             })
             restoreInput()
           })

+ 3 - 8
packages/app/src/context/global-sync.tsx

@@ -228,10 +228,7 @@ function createGlobalSync() {
         showToast({
           variant: "error",
           title: language.t("toast.session.listFailed.title", { project }),
-          description: formatServerError(err, {
-            unknown: language.t("error.chain.unknown"),
-            invalidConfiguration: language.t("error.server.invalidConfiguration"),
-          }),
+          description: formatServerError(err, language.t),
         })
       })
 
@@ -261,8 +258,7 @@ function createGlobalSync() {
         setStore: child[1],
         vcsCache: cache,
         loadSessions,
-        unknownError: language.t("error.chain.unknown"),
-        invalidConfigurationError: language.t("error.server.invalidConfiguration"),
+        translate: language.t,
       })
     })()
 
@@ -331,8 +327,7 @@ function createGlobalSync() {
         url: globalSDK.url,
       }),
       requestFailedTitle: language.t("common.requestFailed"),
-      unknownError: language.t("error.chain.unknown"),
-      invalidConfigurationError: language.t("error.server.invalidConfiguration"),
+      translate: language.t,
       formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
       setGlobalStore: setBootStore,
     })

+ 4 - 12
packages/app/src/context/global-sync/bootstrap.ts

@@ -36,8 +36,7 @@ export async function bootstrapGlobal(input: {
   connectErrorTitle: string
   connectErrorDescription: string
   requestFailedTitle: string
-  unknownError: string
-  invalidConfigurationError: string
+  translate: (key: string, vars?: Record<string, string | number>) => string
   formatMoreCount: (count: number) => string
   setGlobalStore: SetStoreFunction<GlobalStore>
 }) {
@@ -91,10 +90,7 @@ export async function bootstrapGlobal(input: {
   const results = await Promise.allSettled(tasks)
   const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
   if (errors.length) {
-    const message = formatServerError(errors[0], {
-      unknown: input.unknownError,
-      invalidConfiguration: input.invalidConfigurationError,
-    })
+    const message = formatServerError(errors[0], input.translate)
     const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
     showToast({
       variant: "error",
@@ -122,8 +118,7 @@ export async function bootstrapDirectory(input: {
   setStore: SetStoreFunction<State>
   vcsCache: VcsCache
   loadSessions: (directory: string) => Promise<void> | void
-  unknownError: string
-  invalidConfigurationError: string
+  translate: (key: string, vars?: Record<string, string | number>) => string
 }) {
   if (input.store.status !== "complete") input.setStore("status", "loading")
 
@@ -145,10 +140,7 @@ export async function bootstrapDirectory(input: {
     showToast({
       variant: "error",
       title: `Failed to reload ${project}`,
-      description: formatServerError(err, {
-        unknown: input.unknownError,
-        invalidConfiguration: input.invalidConfigurationError,
-      }),
+      description: formatServerError(err, input.translate),
     })
     input.setStore("status", "partial")
     return

+ 76 - 14
packages/app/src/utils/server-errors.test.ts

@@ -1,8 +1,37 @@
 import { describe, expect, test } from "bun:test"
-import type { ConfigInvalidError } from "./server-errors"
-import { formatServerError, parseReabaleConfigInvalidError } from "./server-errors"
+import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
+import { formatServerError, parseReadableConfigInvalidError } from "./server-errors"
 
-describe("parseReabaleConfigInvalidError", () => {
+function fill(text: string, vars?: Record<string, string | number>) {
+  if (!vars) return text
+  return text.replace(/{{\s*(\w+)\s*}}/g, (_, key: string) => {
+    const value = vars[key]
+    if (value === undefined) return ""
+    return String(value)
+  })
+}
+
+function useLanguageMock() {
+  const dict: Record<string, string> = {
+    "error.chain.unknown": "Erro desconhecido",
+    "error.chain.configInvalid": "Arquivo de config em {{path}} invalido",
+    "error.chain.configInvalidWithMessage": "Arquivo de config em {{path}} invalido: {{message}}",
+    "error.chain.modelNotFound": "Modelo nao encontrado: {{provider}}/{{model}}",
+    "error.chain.didYouMean": "Voce quis dizer: {{suggestions}}",
+    "error.chain.checkConfig": "Revise provider/model no config",
+  }
+  return {
+    t(key: string, vars?: Record<string, string | number>) {
+      const text = dict[key]
+      if (!text) return key
+      return fill(text, vars)
+    },
+  }
+}
+
+const language = useLanguageMock()
+
+describe("parseReadableConfigInvalidError", () => {
   test("formats issues with file path", () => {
     const error = {
       name: "ConfigInvalidError",
@@ -15,10 +44,10 @@ describe("parseReabaleConfigInvalidError", () => {
       },
     } satisfies ConfigInvalidError
 
-    const result = parseReabaleConfigInvalidError(error)
+    const result = parseReadableConfigInvalidError(error, language.t)
 
     expect(result).toBe(
-      ["Invalid configuration", "opencode.config.ts", "settings.host: Required", "mode: Invalid"].join("\n"),
+      ["Arquivo de config em opencode.config.ts invalido: settings.host: Required", "mode: Invalid"].join("\n"),
     )
   })
 
@@ -31,9 +60,9 @@ describe("parseReabaleConfigInvalidError", () => {
       },
     } satisfies ConfigInvalidError
 
-    const result = parseReabaleConfigInvalidError(error)
+    const result = parseReadableConfigInvalidError(error, language.t)
 
-    expect(result).toBe(["Invalid configuration", "Bad value"].join("\n"))
+    expect(result).toBe("Arquivo de config em config invalido: Bad value")
   })
 })
 
@@ -46,24 +75,57 @@ describe("formatServerError", () => {
       },
     } satisfies ConfigInvalidError
 
-    const result = formatServerError(error)
+    const result = formatServerError(error, language.t)
 
-    expect(result).toBe(["Invalid configuration", "Missing host"].join("\n"))
+    expect(result).toBe("Arquivo de config em config invalido: Missing host")
   })
 
   test("returns error messages", () => {
-    expect(formatServerError(new Error("Request failed with status 503"))).toBe("Request failed with status 503")
+    expect(formatServerError(new Error("Request failed with status 503"), language.t)).toBe(
+      "Request failed with status 503",
+    )
   })
 
   test("returns provided string errors", () => {
-    expect(formatServerError("Failed to connect to server")).toBe("Failed to connect to server")
+    expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server")
   })
 
-  test("falls back to unknown", () => {
-    expect(formatServerError(0)).toBe("Unknown error")
+  test("uses translated unknown fallback", () => {
+    expect(formatServerError(0, language.t)).toBe("Erro desconhecido")
   })
 
   test("falls back for unknown error objects and names", () => {
-    expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } })).toBe("Unknown error")
+    expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } }, language.t)).toBe(
+      "Erro desconhecido",
+    )
+  })
+
+  test("formats provider model errors using provider/model", () => {
+    const error = {
+      name: "ProviderModelNotFoundError",
+      data: {
+        providerID: "openai",
+        modelID: "gpt-4.1",
+      },
+    } satisfies ProviderModelNotFoundError
+
+    expect(formatServerError(error, language.t)).toBe(
+      ["Modelo nao encontrado: openai/gpt-4.1", "Revise provider/model no config"].join("\n"),
+    )
+  })
+
+  test("formats provider model suggestions", () => {
+    const error = {
+      name: "ProviderModelNotFoundError",
+      data: {
+        providerID: "x",
+        modelID: "y",
+        suggestions: ["x/y2", "x/y3"],
+      },
+    } satisfies ProviderModelNotFoundError
+
+    expect(formatServerError(error, language.t)).toBe(
+      ["Modelo nao encontrado: x/y", "Voce quis dizer: x/y2, x/y3", "Revise provider/model no config"].join("\n"),
+    )
   })
 })

+ 53 - 22
packages/app/src/utils/server-errors.ts

@@ -7,28 +7,31 @@ export type ConfigInvalidError = {
   }
 }
 
-type Label = {
-  unknown: string
-  invalidConfiguration: string
+export type ProviderModelNotFoundError = {
+  name: "ProviderModelNotFoundError"
+  data: {
+    providerID: string
+    modelID: string
+    suggestions?: string[]
+  }
 }
 
-const fallback: Label = {
-  unknown: "Unknown error",
-  invalidConfiguration: "Invalid configuration",
-}
+type Translator = (key: string, vars?: Record<string, string | number>) => string
 
-function resolveLabel(labels: Partial<Label> | undefined): Label {
-  return {
-    unknown: labels?.unknown ?? fallback.unknown,
-    invalidConfiguration: labels?.invalidConfiguration ?? fallback.invalidConfiguration,
-  }
+function tr(translator: Translator | undefined, key: string, text: string, vars?: Record<string, string | number>) {
+  if (!translator) return text
+  const out = translator(key, vars)
+  if (!out || out === key) return text
+  return out
 }
 
-export function formatServerError(error: unknown, labels?: Partial<Label>) {
-  if (isConfigInvalidErrorLike(error)) return parseReabaleConfigInvalidError(error, labels)
+export function formatServerError(error: unknown, translate?: Translator, fallback?: string) {
+  if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate)
+  if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate)
   if (error instanceof Error && error.message) return error.message
   if (typeof error === "string" && error) return error
-  return resolveLabel(labels).unknown
+  if (fallback) return fallback
+  return tr(translate, "error.chain.unknown", "Unknown error")
 }
 
 function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
@@ -37,13 +40,41 @@ function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
   return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null
 }
 
-export function parseReabaleConfigInvalidError(errorInput: ConfigInvalidError, labels?: Partial<Label>) {
-  const head = resolveLabel(labels).invalidConfiguration
-  const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : ""
+function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError {
+  if (typeof error !== "object" || error === null) return false
+  const o = error as Record<string, unknown>
+  return o.name === "ProviderModelNotFoundError" && typeof o.data === "object" && o.data !== null
+}
+
+export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, translator?: Translator) {
+  const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config"
   const detail = errorInput.data.message?.trim() ?? ""
-  const issues = (errorInput.data.issues ?? []).map((issue) => {
-    return `${issue.path.join(".")}: ${issue.message}`
+  const issues = (errorInput.data.issues ?? [])
+    .map((issue) => {
+      const msg = issue.message.trim()
+      if (!issue.path.length) return msg
+      return `${issue.path.join(".")}: ${msg}`
+    })
+    .filter(Boolean)
+  const msg = issues.length ? issues.join("\n") : detail
+  if (!msg) return tr(translator, "error.chain.configInvalid", `Config file at ${file} is invalid`, { path: file })
+  return tr(translator, "error.chain.configInvalidWithMessage", `Config file at ${file} is invalid: ${msg}`, {
+    path: file,
+    message: msg,
   })
-  if (issues.length) return [head, file, "", ...issues].filter(Boolean).join("\n")
-  return [head, file, detail].filter(Boolean).join("\n")
+}
+
+function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator) {
+  const p = errorInput.data.providerID.trim()
+  const m = errorInput.data.modelID.trim()
+  const list = (errorInput.data.suggestions ?? []).map((v) => v.trim()).filter(Boolean)
+  const body = tr(translator, "error.chain.modelNotFound", `Model not found: ${p}/${m}`, { provider: p, model: m })
+  const tail = tr(translator, "error.chain.checkConfig", "Check your config (opencode.json) provider/model names")
+  if (list.length) {
+    const suggestions = list.slice(0, 5).join(", ")
+    return [body, tr(translator, "error.chain.didYouMean", `Did you mean: ${suggestions}`, { suggestions }), tail].join(
+      "\n",
+    )
+  }
+  return [body, tail].join("\n")
 }