mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-30 05:43:55 +00:00
refactor(desktop): improve error handling and translation in server error formatting (#16171)
This commit is contained in:
parent
62909e917a
commit
27baa2d65c
@ -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()
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,28 +7,31 @@ export type ConfigInvalidError = {
|
||||
}
|
||||
}
|
||||
|
||||
type Label = {
|
||||
unknown: string
|
||||
invalidConfiguration: string
|
||||
}
|
||||
|
||||
const fallback: Label = {
|
||||
unknown: "Unknown error",
|
||||
invalidConfiguration: "Invalid configuration",
|
||||
}
|
||||
|
||||
function resolveLabel(labels: Partial<Label> | undefined): Label {
|
||||
return {
|
||||
unknown: labels?.unknown ?? fallback.unknown,
|
||||
invalidConfiguration: labels?.invalidConfiguration ?? fallback.invalidConfiguration,
|
||||
export type ProviderModelNotFoundError = {
|
||||
name: "ProviderModelNotFoundError"
|
||||
data: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
suggestions?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function formatServerError(error: unknown, labels?: Partial<Label>) {
|
||||
if (isConfigInvalidErrorLike(error)) return parseReabaleConfigInvalidError(error, labels)
|
||||
type Translator = (key: string, vars?: Record<string, string | number>) => string
|
||||
|
||||
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, 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 : ""
|
||||
const detail = errorInput.data.message?.trim() ?? ""
|
||||
const issues = (errorInput.data.issues ?? []).map((issue) => {
|
||||
return `${issue.path.join(".")}: ${issue.message}`
|
||||
})
|
||||
if (issues.length) return [head, file, "", ...issues].filter(Boolean).join("\n")
|
||||
return [head, file, detail].filter(Boolean).join("\n")
|
||||
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) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user