@qthinker/opencode-deepseek-search 0.2.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/deepseek.ts CHANGED
@@ -1,15 +1,23 @@
1
1
  // DeepSeek Search / Fetch HTTP core.
2
2
  //
3
3
  // Responsibilities: request shaping, retry policy, response mapping and
4
- // user-facing (Chinese) error messages. No plugin context and no storage here,
5
- // so every mapper below is unit-testable without network access.
4
+ // user-facing error messages (rendered through the i18n tables). No plugin
5
+ // context and no storage here, so every mapper below is unit-testable without
6
+ // network access.
7
+
8
+ import { t } from "./i18n/index.ts"
9
+ import type { BalanceRead, Language } from "./rpc.ts"
6
10
 
7
11
  export const API_BASE = "https://api.deepseek.com"
8
12
  export const SEARCH_PATH = "/v1/tools/search"
9
13
  export const FETCH_PATH = "/v1/tools/fetch"
14
+ // Account balance endpoint, per the official docs:
15
+ // https://api-docs.deepseek.com/zh-cn/api/get-user-balance
16
+ export const BALANCE_PATH = "/user/balance"
10
17
 
11
18
  export const SEARCH_TIMEOUT_MS = 15_000
12
19
  export const FETCH_TIMEOUT_MS = 30_000
20
+ export const BALANCE_TIMEOUT_MS = 10_000
13
21
  const RETRY_DELAY_MS = 1_000
14
22
  const RETRY_DELAY_MAX_MS = 5_000
15
23
  export const MAX_QUERY_CHARS = 128
@@ -32,12 +40,8 @@ export interface FetchResult {
32
40
  truncated: boolean
33
41
  }
34
42
 
35
- export function missingKeyMessage(): string {
36
- return (
37
- "未配置 DeepSeek API Key:请设置环境变量 DEEPSEEK_API_KEY" +
38
- '(PowerShell: [Environment]::SetEnvironmentVariable("DEEPSEEK_API_KEY","sk-...","User")),' +
39
- '或在插件 options 中写 "apiKey": "{env:DEEPSEEK_API_KEY}",然后重启 opencode。'
40
- )
43
+ export function missingKeyMessage(language: Language): string {
44
+ return t(language, "error.missingKey")
41
45
  }
42
46
 
43
47
  // ---------- abort plumbing: combine the caller signal with a timeout ----------
@@ -80,42 +84,43 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
80
84
 
81
85
  // ---------- error mapping ----------
82
86
 
83
- function upstreamMessage(body: string): string {
87
+ function upstreamMessage(body: string, language: Language): string {
84
88
  try {
85
89
  const parsed = JSON.parse(body) as { error?: { message?: unknown } }
86
90
  const message = parsed?.error?.message
87
- return typeof message === "string" && message.trim() ? `:${message.trim()}` : ""
91
+ if (typeof message !== "string" || !message.trim()) return ""
92
+ // The provider text is kept verbatim; only the separator is localized.
93
+ const separator = language === "en" ? ": " : ":"
94
+ return `${separator}${message.trim()}`
88
95
  } catch {
89
96
  return ""
90
97
  }
91
98
  }
92
99
 
93
- export function httpError(status: number, body: string, isFetch: boolean): Error {
94
- const upstream = upstreamMessage(body)
95
- const fetchHint = isFetch ? " 可改用内置 webfetch 工具抓取该页面。" : ""
100
+ export function httpError(status: number, body: string, isFetch: boolean, language: Language): Error {
101
+ const upstream = upstreamMessage(body, language)
102
+ const hint = isFetch ? t(language, "error.http.fetchHint") : ""
103
+ const params = { upstream, hint, status }
96
104
  switch (status) {
97
105
  case 400:
98
- return new Error(`DeepSeek 请求参数无效(400)${upstream}。搜索词需 1~128 字符;Fetch 的 URL 需为合法的 http(s) 绝对地址。`)
106
+ return new Error(t(language, "error.http.400", params))
99
107
  case 401:
100
- return new Error(`DeepSeek API Key 无效(401)${upstream}。请检查 DEEPSEEK_API_KEY 环境变量或 options.apiKey。`)
108
+ return new Error(t(language, "error.http.401", params))
101
109
  case 402:
102
- return new Error("DeepSeek 账户余额不足(402),失败请求不计费。请充值后重试。")
110
+ return new Error(t(language, "error.http.402", params))
103
111
  case 403:
104
- return new Error(`DeepSeek Fetch 被目标站点安全策略拒绝(403)${upstream}。${fetchHint}`)
112
+ return new Error(t(language, "error.http.403", params))
105
113
  case 404:
106
- return new Error(`DeepSeek 无法提取该链接的正文(404)${upstream}。请确认链接可访问。${fetchHint}`)
114
+ return new Error(t(language, "error.http.404", params))
107
115
  case 408:
108
- return new Error("DeepSeek 请求超时(408),已退避重试一次仍失败。请稍后重试。")
116
+ return new Error(t(language, "error.http.408", params))
109
117
  case 413:
110
- return new Error("DeepSeek 请求体过大(413)。请缩短输入。")
118
+ return new Error(t(language, "error.http.413", params))
111
119
  case 429:
112
- return new Error("DeepSeek 触发限速(429,账号级 Search 100 QPS / Fetch 25 QPS),已退避重试一次仍失败。请稍后再试。")
120
+ return new Error(t(language, "error.http.429", params))
113
121
  default:
114
- if (status >= 500)
115
- return new Error(
116
- `DeepSeek 服务端错误(${status}),已退避重试一次仍失败。可把插件 options.mode 改为 "native" 暂用原生搜索。`,
117
- )
118
- return new Error(`DeepSeek 请求失败(HTTP ${status})${upstream}`)
122
+ if (status >= 500) return new Error(t(language, "error.http.5xx", params))
123
+ return new Error(t(language, "error.http.other", params))
119
124
  }
120
125
  }
121
126
 
@@ -127,21 +132,28 @@ function retryAfterMs(headers: Headers): number {
127
132
 
128
133
  // ---------- shared HTTP core: timeout, signal passthrough, single retry ----------
129
134
 
130
- export async function postJson(
135
+ async function requestJson(
136
+ method: "POST" | "GET",
131
137
  path: string,
132
138
  body: unknown,
133
139
  apiKey: string,
134
140
  timeoutMs: number,
141
+ language: Language,
135
142
  signal?: AbortSignal,
136
143
  ): Promise<unknown> {
137
- if (!apiKey) throw new Error(missingKeyMessage())
144
+ if (!apiKey) throw new Error(missingKeyMessage(language))
138
145
  for (let attempt = 1; ; attempt++) {
139
146
  const merged = mergeAbort(signal, timeoutMs)
140
147
  try {
148
+ // GET reads a resource, so it sends no body and no content-type header.
149
+ const headers: Record<string, string> =
150
+ method === "POST"
151
+ ? { "content-type": "application/json", authorization: `Bearer ${apiKey}` }
152
+ : { authorization: `Bearer ${apiKey}` }
141
153
  const res = await fetch(`${API_BASE}${path}`, {
142
- method: "POST",
143
- headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
144
- body: JSON.stringify(body),
154
+ method,
155
+ headers,
156
+ body: method === "POST" ? JSON.stringify(body) : undefined,
145
157
  signal: merged.signal,
146
158
  })
147
159
  if (res.ok) {
@@ -149,7 +161,7 @@ export async function postJson(
149
161
  try {
150
162
  return JSON.parse(text) as unknown
151
163
  } catch {
152
- throw new Error(`DeepSeek 返回了无法解析的响应(HTTP ${res.status})。请稍后重试。`)
164
+ throw new Error(t(language, "error.parse", { status: res.status }))
153
165
  }
154
166
  }
155
167
  const text = await res.text().catch(() => "")
@@ -157,18 +169,18 @@ export async function postJson(
157
169
  await sleep(retryAfterMs(res.headers), signal)
158
170
  continue
159
171
  }
160
- throw httpError(res.status, text, path === FETCH_PATH)
172
+ throw httpError(res.status, text, path === FETCH_PATH, language)
161
173
  } catch (e) {
162
174
  if (e instanceof Error && e.name === "AbortError") {
163
175
  throw new Error(
164
176
  signal?.aborted
165
- ? "DeepSeek 请求已取消。"
166
- : `DeepSeek 请求超时(${Math.round(timeoutMs / 1000)} 秒),请重试。`,
177
+ ? t(language, "error.aborted")
178
+ : t(language, "error.timeout", { seconds: Math.round(timeoutMs / 1000) }),
167
179
  )
168
180
  }
169
181
  // fetch() rejects with TypeError on DNS/TLS/proxy failures.
170
182
  if (e instanceof TypeError) {
171
- throw new Error(`无法连接 ${API_BASE}(网络错误:${e.message})。请检查网络或代理设置。`)
183
+ throw new Error(t(language, "error.network", { base: API_BASE, message: e.message }))
172
184
  }
173
185
  throw e
174
186
  } finally {
@@ -177,6 +189,27 @@ export async function postJson(
177
189
  }
178
190
  }
179
191
 
192
+ export function postJson(
193
+ path: string,
194
+ body: unknown,
195
+ apiKey: string,
196
+ timeoutMs: number,
197
+ language: Language,
198
+ signal?: AbortSignal,
199
+ ): Promise<unknown> {
200
+ return requestJson("POST", path, body, apiKey, timeoutMs, language, signal)
201
+ }
202
+
203
+ export function getJson(
204
+ path: string,
205
+ apiKey: string,
206
+ timeoutMs: number,
207
+ language: Language,
208
+ signal?: AbortSignal,
209
+ ): Promise<unknown> {
210
+ return requestJson("GET", path, undefined, apiKey, timeoutMs, language, signal)
211
+ }
212
+
180
213
  // ---------- DeepSeek Search -> WebSearch.Result[] ----------
181
214
 
182
215
  interface SearchRow {
@@ -222,39 +255,53 @@ export async function deepseekSearch(
222
255
  query: string,
223
256
  apiKey: string,
224
257
  count: number,
258
+ language: Language,
225
259
  signal?: AbortSignal,
226
260
  ): Promise<SearchResult[]> {
227
- const data = await postJson(SEARCH_PATH, buildSearchBody(query, count), apiKey, SEARCH_TIMEOUT_MS, signal)
261
+ const data = await postJson(
262
+ SEARCH_PATH,
263
+ buildSearchBody(query, count),
264
+ apiKey,
265
+ SEARCH_TIMEOUT_MS,
266
+ language,
267
+ signal,
268
+ )
228
269
  return mapSearchResults(data)
229
270
  }
230
271
 
231
272
  // ---------- DeepSeek Fetch -> Markdown with truncation ----------
232
273
 
233
- export function mapFetchResponse(data: unknown, url: string, maxChars: number): FetchResult {
274
+ export function mapFetchResponse(
275
+ data: unknown,
276
+ url: string,
277
+ maxChars: number,
278
+ language: Language,
279
+ ): FetchResult {
234
280
  const title = typeof (data as { title?: unknown })?.title === "string" ? (data as { title: string }).title : ""
235
281
  const raw = typeof (data as { content?: unknown })?.content === "string" ? (data as { content: string }).content : ""
236
282
  const truncated = raw.length > maxChars
237
283
  let body = truncated ? raw.slice(0, maxChars) : raw
238
284
  if (truncated) {
239
- body += `\n\n[DeepSeek Fetch 截断提示] 原文共 ${raw.length} 字符,超出上限 ${maxChars},此处仅保留前 ${maxChars} 字符。`
285
+ body += `\n\n${t(language, "error.fetch.truncated", { raw: raw.length, max: maxChars })}`
240
286
  }
241
287
  return {
242
- markdown: `# ${title || url}\n来源: ${url}\n\n${body}`,
288
+ markdown: `# ${title || url}\n${t(language, "fetch.source", { url })}\n\n${body}`,
243
289
  chars: raw.length,
244
290
  empty: raw.length === 0,
245
291
  truncated,
246
292
  }
247
293
  }
248
294
 
249
- export function validateUrl(url: string): string | undefined {
250
- if (!/^https?:\/\//i.test(url)) return `无效 URL:「${url}」。DeepSeek Fetch 仅支持 http/https 绝对地址。`
251
- if (url.length > MAX_URL_CHARS) return `URL 过长(${url.length} > ${MAX_URL_CHARS} 字符)。`
295
+ export function validateUrl(url: string, language: Language): string | undefined {
296
+ if (!/^https?:\/\//i.test(url)) return t(language, "error.validate.urlScheme", { url })
297
+ if (url.length > MAX_URL_CHARS)
298
+ return t(language, "error.validate.urlLong", { length: url.length, max: MAX_URL_CHARS })
252
299
  try {
253
300
  const parsed = new URL(url)
254
- if (!parsed.host) return `URL 缺少主机名:「${url}」。`
255
- if (parsed.username || parsed.password) return "URL 不能包含用户名/密码(userinfo)。"
301
+ if (!parsed.host) return t(language, "error.validate.urlHost", { url })
302
+ if (parsed.username || parsed.password) return t(language, "error.validate.urlUserinfo")
256
303
  } catch {
257
- return `无法解析 URL:「${url}」。`
304
+ return t(language, "error.validate.urlParse", { url })
258
305
  }
259
306
  return undefined
260
307
  }
@@ -263,15 +310,63 @@ export async function deepseekFetch(
263
310
  url: string,
264
311
  apiKey: string,
265
312
  maxChars: number,
313
+ language: Language,
266
314
  signal?: AbortSignal,
267
315
  ): Promise<FetchResult> {
268
- const data = await postJson(FETCH_PATH, { url }, apiKey, FETCH_TIMEOUT_MS, signal)
269
- return mapFetchResponse(data, url, maxChars)
316
+ const data = await postJson(FETCH_PATH, { url }, apiKey, FETCH_TIMEOUT_MS, language, signal)
317
+ return mapFetchResponse(data, url, maxChars, language)
318
+ }
319
+
320
+ // ---------- DeepSeek Balance -> BalanceRead ----------
321
+
322
+ interface BalanceInfo {
323
+ currency?: unknown
324
+ total_balance?: unknown
325
+ granted_balance?: unknown
326
+ topped_up_balance?: unknown
327
+ }
328
+
329
+ function balanceText(value: unknown): string | undefined {
330
+ return typeof value === "string" && value.length > 0 ? value : undefined
331
+ }
332
+
333
+ // `/user/balance` lists one entry per currency. The plugin bills in CNY, so that
334
+ // entry wins when present; anything else is accepted only as a fallback.
335
+ export function mapBalance(data: unknown): BalanceRead | undefined {
336
+ if (typeof data !== "object" || data === null) return undefined
337
+ const raw = data as { is_available?: unknown; balance_infos?: unknown }
338
+ const isAvailable = raw.is_available
339
+ if (typeof isAvailable !== "boolean") return undefined
340
+ const infos = raw.balance_infos
341
+ if (!Array.isArray(infos) || infos.length === 0) return undefined
342
+ const rows = infos as Array<BalanceInfo | null | undefined>
343
+ const entry = rows.find((row) => row?.currency === "CNY") ?? rows[0]
344
+ const currency = entry?.currency
345
+ if (currency !== "CNY" && currency !== "USD") return undefined
346
+ const totalBalance = balanceText(entry?.total_balance)
347
+ const grantedBalance = balanceText(entry?.granted_balance)
348
+ const toppedUpBalance = balanceText(entry?.topped_up_balance)
349
+ if (!totalBalance || !grantedBalance || !toppedUpBalance) return undefined
350
+ return { isAvailable, currency, totalBalance, grantedBalance, toppedUpBalance }
351
+ }
352
+
353
+ export async function deepseekBalance(
354
+ apiKey: string,
355
+ language: Language,
356
+ signal?: AbortSignal,
357
+ ): Promise<BalanceRead> {
358
+ const data = await getJson(BALANCE_PATH, apiKey, BALANCE_TIMEOUT_MS, language, signal)
359
+ const balance = mapBalance(data)
360
+ // No localizable message on purpose: the balance cache swallows this error, so
361
+ // it is a diagnostic string rather than user-facing copy.
362
+ if (!balance) throw new Error("DeepSeek balance response is missing required fields")
363
+ return balance
270
364
  }
271
365
 
272
- export function validateQuery(query: string): string | undefined {
273
- if (query.length === 0) return "搜索词为空:DeepSeek Search 需要非空 query(1~128 字符)。"
274
- if (query.length > MAX_QUERY_CHARS) return `搜索词过长(${query.length} 字符,上限 ${MAX_QUERY_CHARS}),请精简后重试。`
366
+ export function validateQuery(query: string, language: Language): string | undefined {
367
+ if (query.length === 0) return t(language, "error.validate.queryEmpty")
368
+ if (query.length > MAX_QUERY_CHARS)
369
+ return t(language, "error.validate.queryLong", { length: query.length, max: MAX_QUERY_CHARS })
275
370
  return undefined
276
371
  }
277
372
 
package/src/gui/index.tsx CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  type SessionPanelMenuItem,
15
15
  } from "@opencode/plugin/gui"
16
16
  import { createSignal } from "solid-js"
17
+ import { t } from "../i18n/index.ts"
17
18
  import { DeepSeekRpc, readModeResult, readStatus, type SearchStatus } from "../rpc.ts"
18
19
  import { DeepSeekPanel } from "./panel.tsx"
19
20
 
@@ -30,19 +31,28 @@ export default Plugin.define({
30
31
  const [error, setError] = createSignal<string | undefined>()
31
32
  const [busy, setBusy] = createSignal(false)
32
33
 
34
+ // The desktop side has no forced locale: the server resolves the language
35
+ // and carries it in `status`, so every label follows that signal.
36
+ const language = () => status()?.language ?? "en"
37
+
33
38
  const describeError = (e: unknown) => (e instanceof Error ? e.message : String(e)).slice(0, ERROR_MAX)
34
39
 
35
40
  // `status` takes a required first argument: plain JSON Schema degrades the
36
41
  // input type to `unknown`, and the host validates the payload against the
37
42
  // method's object schema, so an omitted input is rejected as invalid.
38
- async function refresh() {
43
+ //
44
+ // `force` asks the server to re-probe the account balance instead of serving
45
+ // the cache; only the panel's refresh button spends that request. The
46
+ // `changed` event sends `{}` exactly — an older server rejects unknown input
47
+ // properties, so a stray `force: false` would break it.
48
+ async function refresh(force = false) {
39
49
  try {
40
- const next = readStatus(await rpc.status({}))
50
+ const next = readStatus(await rpc.status(force ? { force: true } : {}))
41
51
  if (next) {
42
52
  setStatus(next)
43
53
  setError(undefined)
44
54
  } else {
45
- setError("状态数据格式不符")
55
+ setError(t(language(), "toast.statusMalformed"))
46
56
  }
47
57
  } catch (e) {
48
58
  setError(describeError(e))
@@ -53,7 +63,7 @@ export default Plugin.define({
53
63
  setBusy(true)
54
64
  try {
55
65
  const result = readModeResult(await rpc.setMode({ mode: next }))
56
- if (!result) ctx.toast.show({ title: "DeepSeek", description: "模式切换结果格式不符", variant: "error" })
66
+ if (!result) ctx.toast.show({ title: "DeepSeek", description: t(language(), "toast.modeMalformed"), variant: "error" })
57
67
  else if (!result.ok) ctx.toast.show({ title: "DeepSeek", description: result.message, variant: "error" })
58
68
  } catch (e) {
59
69
  ctx.toast.show({ title: "DeepSeek", description: describeError(e), variant: "error" })
@@ -63,13 +73,14 @@ export default Plugin.define({
63
73
  }
64
74
  }
65
75
 
66
- // Mirrors the TUI card: no status or a stale error rereads, otherwise
67
- // switch. A missing key still allows releasing the takeover (the shared
68
- // model hides the button only where there is genuinely nothing to do).
76
+ // Mirrors the TUI card: no status or a stale error rereads (a retry is a
77
+ // user action, so it also re-probes the balance), otherwise switch. A
78
+ // missing key still allows releasing the takeover (the shared model hides
79
+ // the button only where there is genuinely nothing to do).
69
80
  function toggle() {
70
81
  const current = status()
71
82
  if (!current || error()) {
72
- void refresh()
83
+ void refresh(true)
73
84
  return
74
85
  }
75
86
  void applyMode(current.mode === "deepseek" ? "native" : "deepseek")
@@ -86,7 +97,7 @@ export default Plugin.define({
86
97
  error={error()}
87
98
  busy={busy()}
88
99
  onToggle={toggle}
89
- onRefresh={() => void refresh()}
100
+ onRefresh={(force) => void refresh(force)}
90
101
  />
91
102
  ),
92
103
  })
@@ -96,18 +107,20 @@ export default Plugin.define({
96
107
  const openPanel = () => {
97
108
  const session = ctx.sessions.current()
98
109
  if (!session) {
99
- ctx.toast.show({ title: "DeepSeek", description: "请先打开一个会话" })
110
+ ctx.toast.show({ title: "DeepSeek", description: t(language(), "toast.openSessionFirst") })
100
111
  return
101
112
  }
102
113
  ctx.layout.open(`${ctx.id}:main`, session)
103
114
  }
104
115
 
105
- ctx.add(Command, {
116
+ // A factory, because the palette label has to follow the language that
117
+ // arrives with the status signal.
118
+ ctx.add(Command, (): Command => ({
106
119
  id: "open",
107
- title: "DeepSeek: 打开统计面板",
120
+ title: t(language(), "command.open"),
108
121
  section: "session",
109
122
  run: openPanel,
110
- })
123
+ }))
111
124
 
112
125
  // The new-tab launcher and the + menu list `session.panel` items, not panel
113
126
  // tabs, so without this the panel is reachable only by typing the command.
@@ -119,7 +132,7 @@ export default Plugin.define({
119
132
  menu: "session.panel",
120
133
  id: "open",
121
134
  title: "DeepSeek",
122
- description: "搜索用量与接管开关",
135
+ description: t(language(), "menu.description"),
123
136
  icon: "globe",
124
137
  order: 60,
125
138
  opened,
package/src/gui/panel.tsx CHANGED
@@ -3,15 +3,21 @@
3
3
  import type { JSX } from "solid-js"
4
4
  import { For, Show, createMemo, onCleanup, onMount } from "solid-js"
5
5
  import { usePlugin } from "@opencode/plugin/gui"
6
- import type { SearchStatus } from "../rpc.ts"
7
- import { sidebarRows, type SidebarRows } from "../status-view.ts"
6
+ import { t, type Key } from "../i18n/index.ts"
7
+ import type { BalanceStatus, Language, SearchStatus } from "../rpc.ts"
8
+ import { CURRENCY_SYMBOL, sidebarRows, type SidebarRows } from "../status-view.ts"
8
9
 
9
10
  export interface DeepSeekPanelProps {
10
11
  readonly status: SearchStatus | undefined
11
12
  readonly error: string | undefined
12
13
  readonly busy: boolean
13
14
  readonly onToggle: () => void
14
- readonly onRefresh: () => void
15
+ /**
16
+ * `force` re-probes the server's balance cache; only a user action (the
17
+ * refresh button) may set it. The 10s poll timer calls it bare, so the
18
+ * automatic path keeps sending `{}` and never bypasses the server TTL.
19
+ */
20
+ readonly onRefresh: (force?: boolean) => void
15
21
  }
16
22
 
17
23
  type Tone = SidebarRows["tone"]
@@ -28,9 +34,9 @@ const INK = {
28
34
  light: { on: "#1e7a4c", warn: "#8a5f08", error: "#b03931", off: "#6b7076", idle: "#6b7076", rule: "rgba(0,0,0,0.13)" },
29
35
  } as const
30
36
 
31
- const LABEL: Readonly<Record<Tone, string>> = {
32
- on: "停用(交还原生搜索)", off: "启用(由 DeepSeek 接管)",
33
- warn: "交还原生搜索", error: "重试", idle: "重试",
37
+ const LABEL_KEY: Readonly<Record<Tone, Key>> = {
38
+ on: "panel.label.on", off: "panel.label.off",
39
+ warn: "panel.label.warn", error: "panel.label.error", idle: "panel.label.idle",
34
40
  }
35
41
 
36
42
  const ICON = { on: "arrow-undo-down", off: "globe", warn: "arrow-undo-down", error: "refresh", idle: "refresh" } as const
@@ -66,12 +72,42 @@ function help(action: string, mono: string): JSX.Element {
66
72
  )
67
73
  }
68
74
 
75
+ /**
76
+ * The balance tooltip carries the granted/topped-up split, because those are the
77
+ * two numbers behind the total. It is skipped when only one component is
78
+ * non-zero: there it would just restate the total. The currency code rides
79
+ * along only for USD, whose `$` needs no introduction.
80
+ */
81
+ function balanceTip(balance: BalanceStatus, language: Language): string | undefined {
82
+ const hasGranted = Number.parseFloat(balance.grantedBalance) > 0
83
+ const hasTopped = Number.parseFloat(balance.toppedUpBalance) > 0
84
+ if (!hasGranted || !hasTopped) return undefined
85
+ const text = t(language, "balance.breakdown", { granted: balance.grantedBalance, topped: balance.toppedUpBalance })
86
+ return balance.currency === "USD" ? `${text} (USD)` : text
87
+ }
88
+
69
89
  export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
70
90
  const ctx = usePlugin()
71
- const rows = createMemo(() => sidebarRows({ status: props.status, error: props.error, maxColumns: Infinity }))
91
+ // The desktop plugin has no forced locale: the server resolves one and carries
92
+ // it in `status`. The first frame has no status and renders the loader, so the
93
+ // fallback only ever shows that.
94
+ const language = () => props.status?.language ?? "en"
95
+ const rows = createMemo(() => sidebarRows({ status: props.status, error: props.error, maxColumns: Infinity, language: language() }))
72
96
  const ink = createMemo(() => INK[ctx.appearance.mode()])
73
97
  const mono = () => ctx.appearance.font("mono")
74
98
 
99
+ /**
100
+ * The account block mirrors the view model's own emit condition (enabled or
101
+ * native mode), so `rows().lines` ends with the balance row exactly when
102
+ * this is defined and the usage table must stop before it. The block renders
103
+ * from the structured payload, not from re-parsing the line.
104
+ */
105
+ const balance = createMemo<BalanceStatus | undefined>(() => {
106
+ const account = props.status?.balance
107
+ if (account === undefined || (rows().tone !== "on" && rows().tone !== "off")) return undefined
108
+ return account
109
+ })
110
+
75
111
  // Cross-half `changed` delivery is the one runtime behaviour this repo cannot
76
112
  // verify, so the panel rereads on its own timer while it is mounted — the same
77
113
  // fallback the TUI sidebar uses.
@@ -89,14 +125,14 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
89
125
  }}
90
126
  >
91
127
  <header style={{ display: "flex", "align-items": "flex-start", gap: "8px" }}>
92
- <span aria-hidden="true" style={{ "flex-shrink": 0, "font-size": "10px", "line-height": "20px", "font-weight": 700, color: ink()[rows().tone] }}>
128
+ <span aria-hidden="true" style={{ "flex-shrink": 0, "font-size": "12px", "line-height": "20px", width: "12px", "text-align": "center", "font-weight": 700, color: ink()[rows().tone] }}>
93
129
  {GLYPH[rows().tone]}
94
130
  </span>
95
131
  <h2 aria-live="polite" style={{ flex: 1, "min-width": 0, margin: 0, "font-size": "13px", "font-weight": 600, "line-height": "20px", "overflow-wrap": "anywhere" }}>
96
132
  {headline(rows().title, ink()[rows().tone])}
97
133
  </h2>
98
- <ctx.ui.Tooltip value="刷新用量" placement="bottom">
99
- <ctx.ui.IconButton icon="refresh" label="刷新用量" size="small" onClick={props.onRefresh} />
134
+ <ctx.ui.Tooltip value={t(language(), "panel.tooltip.refresh")} placement="bottom">
135
+ <ctx.ui.IconButton icon="refresh" label={t(language(), "panel.tooltip.refresh")} size="small" onClick={() => props.onRefresh(true)} />
100
136
  </ctx.ui.Tooltip>
101
137
  </header>
102
138
 
@@ -106,36 +142,71 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
106
142
  ) : (
107
143
  <div style={{ display: "flex", "align-items": "center", gap: "8px", "margin-top": "14px", opacity: 0.7 }}>
108
144
  <ctx.ui.Loader size="small" />
109
- <span style={{ "font-size": "12px" }}>正在读取用量…</span>
145
+ <span style={{ "font-size": "12px" }}>{t(language(), "panel.loading")}</span>
110
146
  </div>
111
147
  )
112
148
  ) : (
113
149
  <dl style={{ margin: "16px 0 0", "border-top": `1px solid ${ink().rule}`, "border-bottom": `1px solid ${ink().rule}` }}>
114
- <For each={rows().lines}>
150
+ <For each={balance() !== undefined ? rows().lines.slice(0, -1) : rows().lines}>
115
151
  {(line, index) => {
116
152
  const parts = splitLine(line)
117
153
  return (
118
154
  <div style={{ display: "flex", "align-items": "baseline", gap: "12px", padding: "10px 0", "border-top": index() > 0 ? `1px solid ${ink().rule}` : undefined }}>
119
- <dt style={{ "flex-shrink": 0, "font-size": "11px", "letter-spacing": "0.08em", "line-height": "1.9", opacity: 0.62 }}>
155
+ <dt style={{ "flex-shrink": 0, "font-size": "11px", "letter-spacing": /^[\x20-\x7E]+$/.test(parts.label) ? "0.08em" : undefined, "line-height": "1.9", opacity: 0.62 }}>
120
156
  {parts.label}
121
157
  </dt>
122
158
  <dd style={{
123
159
  margin: 0, "margin-left": "auto", "min-width": 0, display: "flex", "flex-wrap": "wrap", "justify-content": "flex-end",
124
- "column-gap": "6px", "row-gap": "2px", "font-family": mono(), "font-size": "13px", "line-height": "1.55",
160
+ "column-gap": "10px", "row-gap": "2px", "font-family": mono(), "font-size": "13px", "line-height": "1.55",
125
161
  "font-variant-numeric": "tabular-nums", "text-align": "right",
126
162
  }}>
127
163
  <For each={parts.chunks}>
128
- {(chunk, i) => (
129
- <span style={{ "min-width": 0, "overflow-wrap": "anywhere", "font-weight": chunk.startsWith("¥") ? 500 : 400, opacity: chunk.startsWith("¥") ? 1 : 0.8 }}>
130
- {chunk}{i() < parts.chunks.length - 1 ? " ·" : ""}
131
- </span>
132
- )}
164
+ {(chunk) => {
165
+ // A money chunk must never wrap apart from its digits, and
166
+ // it is the only chunk that reads at full weight.
167
+ const money = /^[¥$]/.test(chunk)
168
+ return (
169
+ <span style={{ "min-width": 0, "overflow-wrap": "anywhere", "font-weight": money ? 500 : 400, opacity: money ? 1 : 0.8, "flex-shrink": money ? 0 : undefined }}>
170
+ {chunk}
171
+ </span>
172
+ )
173
+ }}
133
174
  </For>
134
175
  </dd>
135
176
  </div>
136
177
  )
137
178
  }}
138
179
  </For>
180
+ {/* The account block closes the table like a receipt: the usage rows
181
+ above are local estimates for this machine, the balance under them
182
+ is the authoritative amount still on the account. */}
183
+ <Show when={balance()} keyed>
184
+ {(account) => {
185
+ const label = t(language(), "balance.label")
186
+ const tip = balanceTip(account, language())
187
+ const amount = (
188
+ <dd style={{
189
+ margin: 0, "margin-left": "auto", "font-family": mono(), "font-size": "14px", "line-height": "1.55",
190
+ "font-variant-numeric": "tabular-nums", "text-align": "right", "font-weight": 500, "flex-shrink": 0,
191
+ color: account.isAvailable ? undefined : ink().warn,
192
+ }}>
193
+ {CURRENCY_SYMBOL[account.currency] ?? ""}{account.totalBalance}
194
+ </dd>
195
+ )
196
+ return (
197
+ <div style={{ display: "flex", "align-items": "baseline", gap: "12px", padding: "10px 0 4px", "border-top": `1px solid ${ink().rule}` }}>
198
+ <dt style={{ "flex-shrink": 0, "font-size": "11px", "letter-spacing": /^[\x20-\x7E]+$/.test(label) ? "0.08em" : undefined, "line-height": "1.9", opacity: 0.62 }}>
199
+ {label}
200
+ </dt>
201
+ {tip !== undefined ? (
202
+ <ctx.ui.Tooltip value={tip} placement="bottom">{amount}</ctx.ui.Tooltip>
203
+ ) : (
204
+ amount
205
+ )}
206
+ </div>
207
+ )
208
+ }}
209
+ </Show>
139
210
  </dl>
140
211
  )}
141
212
 
@@ -145,7 +216,7 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
145
216
  <Show when={rows().clickable && rows().tone !== "idle"}>
146
217
  <div style={{ "margin-top": "18px" }}>
147
218
  <ctx.ui.Button variant={rows().tone === "off" || rows().tone === "error" ? "contrast" : "neutral"} icon={ICON[rows().tone]} disabled={props.busy} busy={props.busy} onClick={props.onToggle}>
148
- {LABEL[rows().tone]}
219
+ {t(language(), LABEL_KEY[rows().tone])}
149
220
  </ctx.ui.Button>
150
221
  </div>
151
222
  </Show>