error.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import { TextField } from "@opencode-ai/ui/text-field"
  2. import { Logo } from "@opencode-ai/ui/logo"
  3. import { Button } from "@opencode-ai/ui/button"
  4. import { Component, Show } from "solid-js"
  5. import { createStore } from "solid-js/store"
  6. import { usePlatform } from "@/context/platform"
  7. import { useLanguage } from "@/context/language"
  8. import { Icon } from "@opencode-ai/ui/icon"
  9. export type InitError = {
  10. name: string
  11. data: Record<string, unknown>
  12. }
  13. type Translator = ReturnType<typeof useLanguage>["t"]
  14. function isInitError(error: unknown): error is InitError {
  15. return (
  16. typeof error === "object" &&
  17. error !== null &&
  18. "name" in error &&
  19. "data" in error &&
  20. typeof (error as InitError).data === "object"
  21. )
  22. }
  23. function safeJson(value: unknown): string {
  24. const seen = new WeakSet<object>()
  25. const json = JSON.stringify(
  26. value,
  27. (_key, val) => {
  28. if (typeof val === "bigint") return val.toString()
  29. if (typeof val === "object" && val) {
  30. if (seen.has(val)) return "[Circular]"
  31. seen.add(val)
  32. }
  33. return val
  34. },
  35. 2,
  36. )
  37. return json ?? String(value)
  38. }
  39. function formatInitError(error: InitError, t: Translator): string {
  40. const data = error.data
  41. switch (error.name) {
  42. case "MCPFailed": {
  43. const name = typeof data.name === "string" ? data.name : ""
  44. return t("error.chain.mcpFailed", { name })
  45. }
  46. case "ProviderAuthError": {
  47. const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
  48. const message = typeof data.message === "string" ? data.message : safeJson(data.message)
  49. return t("error.chain.providerAuthFailed", { provider: providerID, message })
  50. }
  51. case "APIError": {
  52. const message = typeof data.message === "string" ? data.message : t("error.chain.apiError")
  53. const lines: string[] = [message]
  54. if (typeof data.statusCode === "number") {
  55. lines.push(t("error.chain.status", { status: data.statusCode }))
  56. }
  57. if (typeof data.isRetryable === "boolean") {
  58. lines.push(t("error.chain.retryable", { retryable: data.isRetryable }))
  59. }
  60. if (typeof data.responseBody === "string" && data.responseBody) {
  61. lines.push(t("error.chain.responseBody", { body: data.responseBody }))
  62. }
  63. return lines.join("\n")
  64. }
  65. case "ProviderModelNotFoundError": {
  66. const { providerID, modelID, suggestions } = data as {
  67. providerID: string
  68. modelID: string
  69. suggestions?: string[]
  70. }
  71. const suggestionsLine =
  72. Array.isArray(suggestions) && suggestions.length
  73. ? [t("error.chain.didYouMean", { suggestions: suggestions.join(", ") })]
  74. : []
  75. return [
  76. t("error.chain.modelNotFound", { provider: providerID, model: modelID }),
  77. ...suggestionsLine,
  78. t("error.chain.checkConfig"),
  79. ].join("\n")
  80. }
  81. case "ProviderInitError": {
  82. const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
  83. return t("error.chain.providerInitFailed", { provider: providerID })
  84. }
  85. case "ConfigJsonError": {
  86. const path = typeof data.path === "string" ? data.path : safeJson(data.path)
  87. const message = typeof data.message === "string" ? data.message : ""
  88. if (message) return t("error.chain.configJsonInvalidWithMessage", { path, message })
  89. return t("error.chain.configJsonInvalid", { path })
  90. }
  91. case "ConfigDirectoryTypoError": {
  92. const path = typeof data.path === "string" ? data.path : safeJson(data.path)
  93. const dir = typeof data.dir === "string" ? data.dir : safeJson(data.dir)
  94. const suggestion = typeof data.suggestion === "string" ? data.suggestion : safeJson(data.suggestion)
  95. return t("error.chain.configDirectoryTypo", { dir, path, suggestion })
  96. }
  97. case "ConfigFrontmatterError": {
  98. const path = typeof data.path === "string" ? data.path : safeJson(data.path)
  99. const message = typeof data.message === "string" ? data.message : safeJson(data.message)
  100. return t("error.chain.configFrontmatterError", { path, message })
  101. }
  102. case "ConfigInvalidError": {
  103. const issues = Array.isArray(data.issues)
  104. ? data.issues.map(
  105. (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
  106. )
  107. : []
  108. const message = typeof data.message === "string" ? data.message : ""
  109. const path = typeof data.path === "string" ? data.path : safeJson(data.path)
  110. const line = message
  111. ? t("error.chain.configInvalidWithMessage", { path, message })
  112. : t("error.chain.configInvalid", { path })
  113. return [line, ...issues].join("\n")
  114. }
  115. case "UnknownError":
  116. return typeof data.message === "string" ? data.message : safeJson(data)
  117. default:
  118. if (typeof data.message === "string") return data.message
  119. return safeJson(data)
  120. }
  121. }
  122. function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessage?: string): string {
  123. if (!error) return t("error.chain.unknown")
  124. if (isInitError(error)) {
  125. const message = formatInitError(error, t)
  126. if (depth > 0 && parentMessage === message) return ""
  127. const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
  128. return indent + `${error.name}\n${message}`
  129. }
  130. if (error instanceof Error) {
  131. const isDuplicate = depth > 0 && parentMessage === error.message
  132. const parts: string[] = []
  133. const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
  134. const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
  135. const stack = error.stack?.trim()
  136. if (stack) {
  137. const startsWithHeader = stack.startsWith(header)
  138. if (isDuplicate && startsWithHeader) {
  139. const trace = stack.split("\n").slice(1).join("\n").trim()
  140. if (trace) {
  141. parts.push(indent + trace)
  142. }
  143. }
  144. if (isDuplicate && !startsWithHeader) {
  145. parts.push(indent + stack)
  146. }
  147. if (!isDuplicate && startsWithHeader) {
  148. parts.push(indent + stack)
  149. }
  150. if (!isDuplicate && !startsWithHeader) {
  151. parts.push(indent + `${header}\n${stack}`)
  152. }
  153. }
  154. if (!stack && !isDuplicate) {
  155. parts.push(indent + header)
  156. }
  157. if (error.cause) {
  158. const causeResult = formatErrorChain(error.cause, t, depth + 1, error.message)
  159. if (causeResult) {
  160. parts.push(causeResult)
  161. }
  162. }
  163. return parts.join("\n\n")
  164. }
  165. if (typeof error === "string") {
  166. if (depth > 0 && parentMessage === error) return ""
  167. const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
  168. return indent + error
  169. }
  170. const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
  171. return indent + safeJson(error)
  172. }
  173. function formatError(error: unknown, t: Translator): string {
  174. return formatErrorChain(error, t, 0)
  175. }
  176. interface ErrorPageProps {
  177. error: unknown
  178. }
  179. export const ErrorPage: Component<ErrorPageProps> = (props) => {
  180. const platform = usePlatform()
  181. const language = useLanguage()
  182. const [store, setStore] = createStore({
  183. checking: false,
  184. version: undefined as string | undefined,
  185. })
  186. async function checkForUpdates() {
  187. if (!platform.checkUpdate) return
  188. setStore("checking", true)
  189. const result = await platform.checkUpdate()
  190. setStore("checking", false)
  191. if (result.updateAvailable && result.version) setStore("version", result.version)
  192. }
  193. async function installUpdate() {
  194. if (!platform.update || !platform.restart) return
  195. await platform.update()
  196. await platform.restart()
  197. }
  198. return (
  199. <div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
  200. <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
  201. <Logo class="w-58.5 opacity-12 shrink-0" />
  202. <div class="flex flex-col items-center gap-2 text-center">
  203. <h1 class="text-lg font-medium text-text-strong">{language.t("error.page.title")}</h1>
  204. <p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
  205. </div>
  206. <TextField
  207. value={formatError(props.error, language.t)}
  208. readOnly
  209. copyable
  210. multiline
  211. class="max-h-96 w-full font-mono text-xs no-scrollbar"
  212. label={language.t("error.page.details.label")}
  213. hideLabel
  214. />
  215. <div class="flex items-center gap-3">
  216. <Button size="large" onClick={platform.restart}>
  217. {language.t("error.page.action.restart")}
  218. </Button>
  219. <Show when={platform.checkUpdate}>
  220. <Show
  221. when={store.version}
  222. fallback={
  223. <Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
  224. {store.checking
  225. ? language.t("error.page.action.checking")
  226. : language.t("error.page.action.checkUpdates")}
  227. </Button>
  228. }
  229. >
  230. <Button size="large" onClick={installUpdate}>
  231. {language.t("error.page.action.updateTo", { version: store.version ?? "" })}
  232. </Button>
  233. </Show>
  234. </Show>
  235. </div>
  236. <div class="flex flex-col items-center gap-2">
  237. <div class="flex items-center justify-center gap-1">
  238. {language.t("error.page.report.prefix")}
  239. <button
  240. type="button"
  241. class="flex items-center text-text-interactive-base gap-1"
  242. onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
  243. >
  244. <div>{language.t("error.page.report.discord")}</div>
  245. <Icon name="discord" class="text-text-interactive-base" />
  246. </button>
  247. </div>
  248. <Show when={platform.version}>
  249. {(version) => (
  250. <p class="text-xs text-text-weak">{language.t("error.page.version", { version: version() })}</p>
  251. )}
  252. </Show>
  253. </div>
  254. </div>
  255. </div>
  256. )
  257. }