@qthinker/opencode-deepseek-search 0.3.0 → 0.4.0

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
@@ -6,19 +6,31 @@
6
6
  // network access.
7
7
 
8
8
  import { t } from "./i18n/index.ts"
9
- import type { Language } from "./rpc.ts"
9
+ import type { BalanceRead, Language } from "./rpc.ts"
10
10
 
11
11
  export const API_BASE = "https://api.deepseek.com"
12
12
  export const SEARCH_PATH = "/v1/tools/search"
13
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"
14
17
 
15
18
  export const SEARCH_TIMEOUT_MS = 15_000
16
19
  export const FETCH_TIMEOUT_MS = 30_000
20
+ export const BALANCE_TIMEOUT_MS = 10_000
17
21
  const RETRY_DELAY_MS = 1_000
18
22
  const RETRY_DELAY_MAX_MS = 5_000
19
23
  export const MAX_QUERY_CHARS = 128
24
+ export const MAX_ORI_CHARS = 128
20
25
  export const MAX_URL_CHARS = 8192
21
26
 
27
+ // Optional request hints forwarded verbatim to the Search API. They are omitted
28
+ // from the body when unset, so the documented API defaults still apply.
29
+ export interface SearchExtras {
30
+ oriQuestion?: string
31
+ localization?: string
32
+ }
33
+
22
34
  // Docs: back off and retry on 408/429/5xx only; other 4xx must not be retried.
23
35
  const RETRYABLE = new Set([408, 429, 500, 502, 503, 504])
24
36
 
@@ -27,6 +39,10 @@ export interface SearchResult {
27
39
  title?: string
28
40
  content?: string
29
41
  time: { published?: number }
42
+ // Internal quality signals: only formatSearchResults consumes them. They are
43
+ // stripped from the tool output before it leaves the plugin.
44
+ authority?: number
45
+ rerankScore?: number
30
46
  }
31
47
 
32
48
  export interface FetchResult {
@@ -128,7 +144,8 @@ function retryAfterMs(headers: Headers): number {
128
144
 
129
145
  // ---------- shared HTTP core: timeout, signal passthrough, single retry ----------
130
146
 
131
- export async function postJson(
147
+ async function requestJson(
148
+ method: "POST" | "GET",
132
149
  path: string,
133
150
  body: unknown,
134
151
  apiKey: string,
@@ -140,10 +157,15 @@ export async function postJson(
140
157
  for (let attempt = 1; ; attempt++) {
141
158
  const merged = mergeAbort(signal, timeoutMs)
142
159
  try {
160
+ // GET reads a resource, so it sends no body and no content-type header.
161
+ const headers: Record<string, string> =
162
+ method === "POST"
163
+ ? { "content-type": "application/json", authorization: `Bearer ${apiKey}` }
164
+ : { authorization: `Bearer ${apiKey}` }
143
165
  const res = await fetch(`${API_BASE}${path}`, {
144
- method: "POST",
145
- headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
146
- body: JSON.stringify(body),
166
+ method,
167
+ headers,
168
+ body: method === "POST" ? JSON.stringify(body) : undefined,
147
169
  signal: merged.signal,
148
170
  })
149
171
  if (res.ok) {
@@ -179,6 +201,27 @@ export async function postJson(
179
201
  }
180
202
  }
181
203
 
204
+ export function postJson(
205
+ path: string,
206
+ body: unknown,
207
+ apiKey: string,
208
+ timeoutMs: number,
209
+ language: Language,
210
+ signal?: AbortSignal,
211
+ ): Promise<unknown> {
212
+ return requestJson("POST", path, body, apiKey, timeoutMs, language, signal)
213
+ }
214
+
215
+ export function getJson(
216
+ path: string,
217
+ apiKey: string,
218
+ timeoutMs: number,
219
+ language: Language,
220
+ signal?: AbortSignal,
221
+ ): Promise<unknown> {
222
+ return requestJson("GET", path, undefined, apiKey, timeoutMs, language, signal)
223
+ }
224
+
182
225
  // ---------- DeepSeek Search -> WebSearch.Result[] ----------
183
226
 
184
227
  interface SearchRow {
@@ -187,6 +230,8 @@ interface SearchRow {
187
230
  snippet?: unknown
188
231
  summary?: unknown
189
232
  date?: unknown
233
+ authority?: unknown
234
+ rerank_score?: unknown
190
235
  }
191
236
 
192
237
  export function mapSearchResults(data: unknown): SearchResult[] {
@@ -205,19 +250,52 @@ export function mapSearchResults(data: unknown): SearchResult[] {
205
250
  ? r.snippet
206
251
  : undefined
207
252
  const published = typeof r.date === "string" ? Date.parse(r.date) : Number.NaN
208
- out.push({
253
+ const result: SearchResult = {
209
254
  url: r.url,
210
255
  title: typeof r.title === "string" && r.title.length > 0 ? r.title : undefined,
211
256
  content,
212
257
  time: Number.isFinite(published) ? { published } : {},
213
- })
258
+ }
259
+ // `authority` is an integer in -1..4 (-1 means unknown) and `rerank_score`
260
+ // a finite number in 0..3. Anything else is dropped, and absent keys stay
261
+ // absent so the output object keeps its exact shape.
262
+ if (
263
+ typeof r.authority === "number" &&
264
+ Number.isInteger(r.authority) &&
265
+ r.authority >= -1 &&
266
+ r.authority <= 4
267
+ ) {
268
+ result.authority = r.authority
269
+ }
270
+ if (
271
+ typeof r.rerank_score === "number" &&
272
+ Number.isFinite(r.rerank_score) &&
273
+ r.rerank_score >= 0 &&
274
+ r.rerank_score <= 3
275
+ ) {
276
+ result.rerankScore = r.rerank_score
277
+ }
278
+ out.push(result)
214
279
  }
215
280
  return out
216
281
  }
217
282
 
218
- export function buildSearchBody(query: string, count: number): { query: string; count: number } {
283
+ export function buildSearchBody(
284
+ query: string,
285
+ count: number,
286
+ extras?: SearchExtras,
287
+ ): { query: string; count: number; ori_question?: string; localization?: string } {
219
288
  // need_content stays false: full page text must never enter search results.
220
- return { query, count }
289
+ // ori_question/localization are omitted entirely when unset or blank, so the
290
+ // request keeps the documented API defaults.
291
+ const ori = extras?.oriQuestion?.trim()
292
+ const localization = extras?.localization?.trim()
293
+ return {
294
+ query,
295
+ count,
296
+ ...(ori ? { ori_question: ori.slice(0, MAX_ORI_CHARS) } : {}),
297
+ ...(localization ? { localization } : {}),
298
+ }
221
299
  }
222
300
 
223
301
  export async function deepseekSearch(
@@ -226,10 +304,11 @@ export async function deepseekSearch(
226
304
  count: number,
227
305
  language: Language,
228
306
  signal?: AbortSignal,
307
+ extras?: SearchExtras,
229
308
  ): Promise<SearchResult[]> {
230
309
  const data = await postJson(
231
310
  SEARCH_PATH,
232
- buildSearchBody(query, count),
311
+ buildSearchBody(query, count, extras),
233
312
  apiKey,
234
313
  SEARCH_TIMEOUT_MS,
235
314
  language,
@@ -286,6 +365,52 @@ export async function deepseekFetch(
286
365
  return mapFetchResponse(data, url, maxChars, language)
287
366
  }
288
367
 
368
+ // ---------- DeepSeek Balance -> BalanceRead ----------
369
+
370
+ interface BalanceInfo {
371
+ currency?: unknown
372
+ total_balance?: unknown
373
+ granted_balance?: unknown
374
+ topped_up_balance?: unknown
375
+ }
376
+
377
+ function balanceText(value: unknown): string | undefined {
378
+ return typeof value === "string" && value.length > 0 ? value : undefined
379
+ }
380
+
381
+ // `/user/balance` lists one entry per currency. The plugin bills in CNY, so that
382
+ // entry wins when present; anything else is accepted only as a fallback.
383
+ export function mapBalance(data: unknown): BalanceRead | undefined {
384
+ if (typeof data !== "object" || data === null) return undefined
385
+ const raw = data as { is_available?: unknown; balance_infos?: unknown }
386
+ const isAvailable = raw.is_available
387
+ if (typeof isAvailable !== "boolean") return undefined
388
+ const infos = raw.balance_infos
389
+ if (!Array.isArray(infos) || infos.length === 0) return undefined
390
+ const rows = infos as Array<BalanceInfo | null | undefined>
391
+ const entry = rows.find((row) => row?.currency === "CNY") ?? rows[0]
392
+ const currency = entry?.currency
393
+ if (currency !== "CNY" && currency !== "USD") return undefined
394
+ const totalBalance = balanceText(entry?.total_balance)
395
+ const grantedBalance = balanceText(entry?.granted_balance)
396
+ const toppedUpBalance = balanceText(entry?.topped_up_balance)
397
+ if (!totalBalance || !grantedBalance || !toppedUpBalance) return undefined
398
+ return { isAvailable, currency, totalBalance, grantedBalance, toppedUpBalance }
399
+ }
400
+
401
+ export async function deepseekBalance(
402
+ apiKey: string,
403
+ language: Language,
404
+ signal?: AbortSignal,
405
+ ): Promise<BalanceRead> {
406
+ const data = await getJson(BALANCE_PATH, apiKey, BALANCE_TIMEOUT_MS, language, signal)
407
+ const balance = mapBalance(data)
408
+ // No localizable message on purpose: the balance cache swallows this error, so
409
+ // it is a diagnostic string rather than user-facing copy.
410
+ if (!balance) throw new Error("DeepSeek balance response is missing required fields")
411
+ return balance
412
+ }
413
+
289
414
  export function validateQuery(query: string, language: Language): string | undefined {
290
415
  if (query.length === 0) return t(language, "error.validate.queryEmpty")
291
416
  if (query.length > MAX_QUERY_CHARS)
@@ -302,10 +427,11 @@ export function formatSearchResults(results: readonly SearchResult[]): string {
302
427
  return results
303
428
  .map((result) => {
304
429
  const title = result.title ?? result.url
430
+ const authoritative = result.authority !== undefined && result.authority >= 3 ? " [authoritative]" : ""
305
431
  const published = result.time.published
306
432
  ? `\nPublished: ${new Date(result.time.published).toISOString()}`
307
433
  : ""
308
- return `## [${title}](${result.url})${published}${result.content ? `\n\n${result.content}` : ""}`
434
+ return `## [${title}](${result.url})${authoritative}${published}${result.content ? `\n\n${result.content}` : ""}`
309
435
  })
310
436
  .join("\n\n")
311
437
  }
package/src/gui/index.tsx CHANGED
@@ -40,9 +40,14 @@ export default Plugin.define({
40
40
  // `status` takes a required first argument: plain JSON Schema degrades the
41
41
  // input type to `unknown`, and the host validates the payload against the
42
42
  // method's object schema, so an omitted input is rejected as invalid.
43
- 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) {
44
49
  try {
45
- const next = readStatus(await rpc.status({}))
50
+ const next = readStatus(await rpc.status(force ? { force: true } : {}))
46
51
  if (next) {
47
52
  setStatus(next)
48
53
  setError(undefined)
@@ -68,13 +73,14 @@ export default Plugin.define({
68
73
  }
69
74
  }
70
75
 
71
- // Mirrors the TUI card: no status or a stale error rereads, otherwise
72
- // switch. A missing key still allows releasing the takeover (the shared
73
- // 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).
74
80
  function toggle() {
75
81
  const current = status()
76
82
  if (!current || error()) {
77
- void refresh()
83
+ void refresh(true)
78
84
  return
79
85
  }
80
86
  void applyMode(current.mode === "deepseek" ? "native" : "deepseek")
@@ -91,7 +97,7 @@ export default Plugin.define({
91
97
  error={error()}
92
98
  busy={busy()}
93
99
  onToggle={toggle}
94
- onRefresh={() => void refresh()}
100
+ onRefresh={(force) => void refresh(force)}
95
101
  />
96
102
  ),
97
103
  })
package/src/gui/panel.tsx CHANGED
@@ -4,15 +4,20 @@ 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
6
  import { t, type Key } from "../i18n/index.ts"
7
- import type { SearchStatus } from "../rpc.ts"
8
- import { sidebarRows, type SidebarRows } from "../status-view.ts"
7
+ import type { BalanceStatus, Language, SearchStatus } from "../rpc.ts"
8
+ import { CURRENCY_SYMBOL, sidebarRows, type SidebarRows } from "../status-view.ts"
9
9
 
10
10
  export interface DeepSeekPanelProps {
11
11
  readonly status: SearchStatus | undefined
12
12
  readonly error: string | undefined
13
13
  readonly busy: boolean
14
14
  readonly onToggle: () => void
15
- 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
16
21
  }
17
22
 
18
23
  type Tone = SidebarRows["tone"]
@@ -67,6 +72,20 @@ function help(action: string, mono: string): JSX.Element {
67
72
  )
68
73
  }
69
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
+
70
89
  export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
71
90
  const ctx = usePlugin()
72
91
  // The desktop plugin has no forced locale: the server resolves one and carries
@@ -77,6 +96,18 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
77
96
  const ink = createMemo(() => INK[ctx.appearance.mode()])
78
97
  const mono = () => ctx.appearance.font("mono")
79
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
+
80
111
  // Cross-half `changed` delivery is the one runtime behaviour this repo cannot
81
112
  // verify, so the panel rereads on its own timer while it is mounted — the same
82
113
  // fallback the TUI sidebar uses.
@@ -94,14 +125,14 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
94
125
  }}
95
126
  >
96
127
  <header style={{ display: "flex", "align-items": "flex-start", gap: "8px" }}>
97
- <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] }}>
98
129
  {GLYPH[rows().tone]}
99
130
  </span>
100
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" }}>
101
132
  {headline(rows().title, ink()[rows().tone])}
102
133
  </h2>
103
134
  <ctx.ui.Tooltip value={t(language(), "panel.tooltip.refresh")} placement="bottom">
104
- <ctx.ui.IconButton icon="refresh" label={t(language(), "panel.tooltip.refresh")} size="small" onClick={props.onRefresh} />
135
+ <ctx.ui.IconButton icon="refresh" label={t(language(), "panel.tooltip.refresh")} size="small" onClick={() => props.onRefresh(true)} />
105
136
  </ctx.ui.Tooltip>
106
137
  </header>
107
138
 
@@ -116,31 +147,66 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
116
147
  )
117
148
  ) : (
118
149
  <dl style={{ margin: "16px 0 0", "border-top": `1px solid ${ink().rule}`, "border-bottom": `1px solid ${ink().rule}` }}>
119
- <For each={rows().lines}>
150
+ <For each={balance() !== undefined ? rows().lines.slice(0, -1) : rows().lines}>
120
151
  {(line, index) => {
121
152
  const parts = splitLine(line)
122
153
  return (
123
154
  <div style={{ display: "flex", "align-items": "baseline", gap: "12px", padding: "10px 0", "border-top": index() > 0 ? `1px solid ${ink().rule}` : undefined }}>
124
- <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 }}>
125
156
  {parts.label}
126
157
  </dt>
127
158
  <dd style={{
128
159
  margin: 0, "margin-left": "auto", "min-width": 0, display: "flex", "flex-wrap": "wrap", "justify-content": "flex-end",
129
- "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",
130
161
  "font-variant-numeric": "tabular-nums", "text-align": "right",
131
162
  }}>
132
163
  <For each={parts.chunks}>
133
- {(chunk, i) => (
134
- <span style={{ "min-width": 0, "overflow-wrap": "anywhere", "font-weight": chunk.startsWith("¥") ? 500 : 400, opacity: chunk.startsWith("¥") ? 1 : 0.8 }}>
135
- {chunk}{i() < parts.chunks.length - 1 ? " ·" : ""}
136
- </span>
137
- )}
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
+ }}
138
174
  </For>
139
175
  </dd>
140
176
  </div>
141
177
  )
142
178
  }}
143
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>
144
210
  </dl>
145
211
  )}
146
212
 
package/src/i18n/en.ts CHANGED
@@ -85,6 +85,10 @@ export const en = {
85
85
  "usage.label.total": "Total",
86
86
  "usage.summary.line": "Search {search} · Fetch {fetch} · empty/failed {skipped} · {cost}",
87
87
 
88
+ // ---------- account balance (the card row and the desktop tooltip) ----------
89
+ "balance.label": "Balance",
90
+ "balance.breakdown": "Granted {granted} · Topped up {topped}",
91
+
88
92
  // ---------- errors ----------
89
93
  "error.missingKey":
90
94
  "DeepSeek API key is not configured. Set the DEEPSEEK_API_KEY environment variable " +
@@ -131,17 +135,32 @@ export type Messages = Record<Key, string>
131
135
  * Usage-row ladder, richest first. Each entry is a complete row, so nothing is
132
136
  * ever left dangling and no number is ever cut from its unit. `{label}` is the
133
137
  * localized period label; `Search`/`Fetch` stay English in every language.
138
+ *
139
+ * Order is by what the row is worth, then by width, so it runs strictly
140
+ * descending (36/33/25/17/11 columns for a typical fixture). The amount
141
+ * outranks the words and the counters: the old ladder kept a wider words-only
142
+ * row and hid the cost until 36 columns, which is the number this card exists
143
+ * for, so those two rungs are gone. The amount-only row (which carries no count
144
+ * at all) is composed in `status-view.ts` and tried just before the last rung —
145
+ * an amount is worth more than a lone counter, but the ladder itself only holds
146
+ * rows that carry counts.
134
147
  */
135
148
  export const enUsageForms: readonly string[] = [
136
149
  "{label} Search {search} Fetch {fetch} {cost}",
137
150
  "{label} Search {search} Fetch {fetch} {cost}",
138
- "{label} Search {search} Fetch {fetch}",
139
- "{label} Search {search} Fetch {fetch}",
140
151
  "{label} {search}S {fetch}F {cost}",
141
152
  "{label} {search}S {fetch}F",
142
- "{label} {search}S",
153
+ "{label} {search}S",
143
154
  ]
144
155
 
156
+ /**
157
+ * Balance-row ladder, verbose first: the terminal takes the compact form when
158
+ * the verbose one does not fit, a surface with an unbounded budget (the desktop)
159
+ * always reads the first. `{amount}` already carries the currency symbol — the
160
+ * view model composes it, because only it knows the currency code.
161
+ */
162
+ export const enBalanceForms: readonly string[] = ["Balance {amount}", "Bal. {amount}"]
163
+
145
164
  /** Missing-key hint ladder, richest first. The env var name is always present. */
146
165
  export const enHintForms: readonly string[] = [
147
166
  "Set DEEPSEEK_API_KEY and restart",
package/src/i18n/index.ts CHANGED
@@ -6,9 +6,9 @@
6
6
  // plugin or host imports, so every runtime can load this module.
7
7
 
8
8
  import { isLanguage, type Language } from "../rpc.ts"
9
- import { en, enHintForms, enUsageForms, type Key, type Messages } from "./en.ts"
10
- import { ja, jaHintForms, jaUsageForms } from "./ja.ts"
11
- import { zh, zhHintForms, zhUsageForms } from "./zh.ts"
9
+ import { en, enBalanceForms, enHintForms, enUsageForms, type Key, type Messages } from "./en.ts"
10
+ import { ja, jaBalanceForms, jaHintForms, jaUsageForms } from "./ja.ts"
11
+ import { zh, zhBalanceForms, zhHintForms, zhUsageForms } from "./zh.ts"
12
12
 
13
13
  export type { Key, Messages }
14
14
 
@@ -19,12 +19,14 @@ export interface LocalePack {
19
19
  readonly usageForms: readonly string[]
20
20
  /** Missing-key hint ladder, richest first. */
21
21
  readonly hintForms: readonly string[]
22
+ /** Balance-row ladder, verbose first; `{amount}` carries the currency symbol. */
23
+ readonly balanceForms: readonly string[]
22
24
  }
23
25
 
24
26
  const PACKS: Readonly<Record<Language, LocalePack>> = {
25
- en: { messages: en, usageForms: enUsageForms, hintForms: enHintForms },
26
- ja: { messages: ja, usageForms: jaUsageForms, hintForms: jaHintForms },
27
- "zh-CN": { messages: zh, usageForms: zhUsageForms, hintForms: zhHintForms },
27
+ en: { messages: en, usageForms: enUsageForms, hintForms: enHintForms, balanceForms: enBalanceForms },
28
+ ja: { messages: ja, usageForms: jaUsageForms, hintForms: jaHintForms, balanceForms: jaBalanceForms },
29
+ "zh-CN": { messages: zh, usageForms: zhUsageForms, hintForms: zhHintForms, balanceForms: zhBalanceForms },
28
30
  }
29
31
 
30
32
  export function pack(language: Language): LocalePack {
package/src/i18n/ja.ts CHANGED
@@ -83,6 +83,10 @@ export const ja = {
83
83
  "usage.label.total": "合計",
84
84
  "usage.summary.line": "検索 {search} · 取得 {fetch} · 空/失敗 {skipped} · {cost}",
85
85
 
86
+ // ---------- アカウント残高(カードの行とデスクトップのツールチップ) ----------
87
+ "balance.label": "残高",
88
+ "balance.breakdown": "付与 {granted} · チャージ {topped}",
89
+
86
90
  // ---------- エラー ----------
87
91
  "error.missingKey":
88
92
  "DeepSeek APIキーが未設定です。環境変数 DEEPSEEK_API_KEY を設定してください" +
@@ -118,17 +122,22 @@ export const ja = {
118
122
  "error.fetch.truncated": "[DeepSeek Fetch 切り詰め] 元の本文は {raw} 文字で、上限 {max} 文字を超えています。先頭 {max} 文字のみ保持しました。",
119
123
  } satisfies Messages
120
124
 
121
- /** 使用量行の階段:完全な行から最も短い行まで。各段は単体で完結する。 */
125
+ /**
126
+ * 使用量行の階段:価値の高い順、同じ価値なら幅の広い順。各段は単体で完結する。
127
+ * 金額は語や回数より上位なので、金額を含まない「語だけ」の段は廃止した。金額だけの
128
+ * 行は件数を持たないため status-view.ts 側で組み立て、最後の 1 段の直前に試す。
129
+ */
122
130
  export const jaUsageForms: readonly string[] = [
123
131
  "{label} Search {search} Fetch {fetch} {cost}",
124
132
  "{label} Search {search} Fetch {fetch} {cost}",
125
- "{label} Search {search} Fetch {fetch}",
126
- "{label} Search {search} Fetch {fetch}",
127
133
  "{label} {search}S {fetch}F {cost}",
128
134
  "{label} {search}S {fetch}F",
129
- "{label} {search}S",
135
+ "{label} {search}S",
130
136
  ]
131
137
 
138
+ /** 残高行の階段:完全な形が先。`{amount}` は通貨記号込み。 */
139
+ export const jaBalanceForms: readonly string[] = ["残高 {amount}"]
140
+
132
141
  /** APIキー未設定のヒント階段:最後の段でも何を設定するかは残る。 */
133
142
  export const jaHintForms: readonly string[] = [
134
143
  "DEEPSEEK_API_KEY を設定して再起動",
package/src/i18n/zh.ts CHANGED
@@ -81,6 +81,10 @@ export const zh = {
81
81
  "usage.label.total": "累计",
82
82
  "usage.summary.line": "搜索 {search} · 抓取 {fetch} · 空/失 {skipped} · {cost}",
83
83
 
84
+ // ---------- 账户余额(卡片行与桌面端浮动提示) ----------
85
+ "balance.label": "余额",
86
+ "balance.breakdown": "赠金 {granted} · 充值 {topped}",
87
+
84
88
  // ---------- 错误信息 ----------
85
89
  "error.missingKey":
86
90
  "未配置 DeepSeek API Key:请设置环境变量 DEEPSEEK_API_KEY" +
@@ -113,17 +117,23 @@ export const zh = {
113
117
  "error.fetch.truncated": "[DeepSeek Fetch 截断提示] 原文共 {raw} 字符,超出上限 {max},此处仅保留前 {max} 字符。",
114
118
  } satisfies Messages
115
119
 
116
- /** 用量行阶梯:从最完整到最紧凑;每一条都是完整的一行。 */
120
+ /**
121
+ * 用量行阶梯:按价值从高到低排列,同等价值再按宽度从宽到窄;每一条都是完整的一行。
122
+ * 金额优先于文字与计数,因此删掉了不带金额的“纯文字”档——旧阶梯会为了更宽的
123
+ * 纯文字行而把金额藏到 36 列。只带金额的一行不含计数,改由 status-view.ts 组装,
124
+ * 并在最后一档之前尝试。
125
+ */
117
126
  export const zhUsageForms: readonly string[] = [
118
127
  "{label} Search {search} Fetch {fetch} {cost}",
119
128
  "{label} Search {search} Fetch {fetch} {cost}",
120
- "{label} Search {search} Fetch {fetch}",
121
- "{label} Search {search} Fetch {fetch}",
122
129
  "{label} {search}S {fetch}F {cost}",
123
130
  "{label} {search}S {fetch}F",
124
- "{label} {search}S",
131
+ "{label} {search}S",
125
132
  ]
126
133
 
134
+ /** 余额行阶梯:完整写法在前;`{amount}` 已含货币符号。 */
135
+ export const zhBalanceForms: readonly string[] = ["余额 {amount}"]
136
+
127
137
  /** 缺 key 提示阶梯:最后一档仍然指明要设置什么。 */
128
138
  export const zhHintForms: readonly string[] = [
129
139
  "设置 DEEPSEEK_API_KEY 后重启",