@qthinker/opencode-deepseek-search 0.2.0 → 0.3.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.
@@ -1,16 +1,20 @@
1
1
  // Pure view model for the sidebar card. No renderer, no runtime dependency:
2
2
  // the card and the tests both derive their text from here.
3
- import type { SearchStatus, UsageCounts } from "./rpc.ts"
3
+ import { format, pack, t } from "./i18n/index.ts"
4
+ import type { SearchStatus, UsageCounts, Language } from "./rpc.ts"
4
5
 
5
6
  export interface SidebarInput {
6
7
  readonly status: SearchStatus | undefined
7
- readonly error: string | undefined
8
+ /** The last read failure, if any; undefined while the first read is in flight. */
9
+ readonly error?: string | undefined
8
10
  /**
9
11
  * Columns one row may occupy. Defaults to the narrow sidebar budget; a host
10
12
  * that has measured its width may pass it, and the desktop panel passes
11
13
  * `Infinity` because it has room for the verbose copy.
12
14
  */
13
15
  readonly maxColumns?: number
16
+ /** Resolved by the server and carried in `status`; both sidebars read it there. */
17
+ readonly language: Language
14
18
  }
15
19
 
16
20
  export interface SidebarRows {
@@ -58,7 +62,11 @@ export const DEFAULT_COLUMNS = 24
58
62
 
59
63
  const PERIODS = ["today", "month", "total"] as const
60
64
  type Period = (typeof PERIODS)[number]
61
- const LABELS: Readonly<Record<Period, string>> = { today: "今日", month: "本月", total: "累计" }
65
+ const LABEL_KEY = {
66
+ today: "usage.label.today",
67
+ month: "usage.label.month",
68
+ total: "usage.label.total",
69
+ } as const
62
70
 
63
71
  type Counts = NonNullable<SearchStatus["counts"]>
64
72
 
@@ -106,9 +114,9 @@ function clip(text: string, maxColumns: number): string {
106
114
  }
107
115
 
108
116
  /** One short body line: collapse whitespace, then clip to the available columns. */
109
- function shortMessage(message: string, maxColumns: number): string {
117
+ function shortMessage(message: string, maxColumns: number, language: Language): string {
110
118
  const text = message.replace(/\s+/g, " ").trim()
111
- return clip(text || "读取失败", maxColumns)
119
+ return clip(text || t(language, "state.failed"), maxColumns)
112
120
  }
113
121
 
114
122
  /** Micro-currency as `¥0.015`: three decimals, trailing zeros trimmed to cents. */
@@ -142,46 +150,41 @@ function usageLayout(counts: Counts): UsageLayout {
142
150
 
143
151
  /**
144
152
  * One usage row, e.g. `今日 Search 0 Fetch 3 ¥0.015` when the surface is
145
- * wide, otherwise shorter forms down to `今日`. The words are the product's
146
- * copy, so they survive as long as any shape can hold them: the amount goes
147
- * first, then the tighter grouping, and only then `0S` / `3F`. Counts are
148
- * right-aligned in their shared width and every row picks the same form, so
149
- * the three rows read as a table.
153
+ * wide, otherwise shorter forms down to `今日`. The ladder is per language:
154
+ * each locale owns the shapes that read well at its widths, so no language is
155
+ * squeezed into another one's budget. Counts are right-aligned in their shared
156
+ * width and every row picks the same form, so the three rows read as a table.
150
157
  */
151
158
  function compactUsageRow(
152
159
  period: Period,
153
160
  counts: UsageCounts,
154
161
  layout: UsageLayout,
155
162
  maxColumns: number,
163
+ language: Language,
156
164
  ): string {
157
- const label = LABELS[period]
165
+ const label = t(language, LABEL_KEY[period])
158
166
  const search = String(counts.search).padStart(layout.search, " ")
159
167
  const fetch = String(counts.fetch).padStart(layout.fetch, " ")
160
168
  const cost = money(counts.costMicro).padStart(layout.cost, " ")
169
+ const params = { label, search, fetch, cost }
161
170
  // Each candidate is a complete row, so nothing is ever left dangling and no
162
171
  // number is ever cut from its unit.
163
- const candidates = [
164
- `${label} Search ${search} Fetch ${fetch} ${cost}`,
165
- `${label} Search ${search} Fetch ${fetch} ${cost}`,
166
- `${label} Search ${search} Fetch ${fetch}`,
167
- `${label} Search ${search} Fetch ${fetch}`,
168
- `${label} ${search}S ${fetch}F ${cost}`,
169
- `${label} ${search}S ${fetch}F`,
170
- `${label} ${search}S`,
171
- ]
172
- for (const row of candidates) {
172
+ for (const form of pack(language).usageForms) {
173
+ const row = format(form, params)
173
174
  if (textColumns(row) <= maxColumns) return row
174
175
  }
175
176
  return textColumns(label) <= maxColumns ? label : clip(label, maxColumns)
176
177
  }
177
178
 
178
179
  /**
179
- * An older server only sends pre-formatted strings. Keep the full string when
180
- * it fits, otherwise drop its trailing segments whole: the amount and the
181
- * diagnostic counters go first, then the fetch count.
180
+ * An older server only sends pre-formatted strings, and those are always the
181
+ * Chinese ones this plugin used to send: the filter below is deliberately
182
+ * Chinese-only and stays correct while the UI language changes. Keep the full
183
+ * string when it fits, otherwise drop its trailing segments whole: the amount
184
+ * and the diagnostic counters go first, then the fetch count.
182
185
  */
183
- function legacyUsageRow(period: Period, text: string, maxColumns: number): string {
184
- const label = LABELS[period]
186
+ function legacyUsageRow(period: Period, text: string, maxColumns: number, language: Language): string {
187
+ const label = t(language, LABEL_KEY[period])
185
188
  const full = `${label} ${text}`
186
189
  if (textColumns(full) <= maxColumns) return full
187
190
  const stats = text.split(" · ").filter((part) => !part.startsWith("¥") && !part.startsWith("空/失"))
@@ -196,18 +199,22 @@ function legacyUsageRow(period: Period, text: string, maxColumns: number): strin
196
199
  return clip(label, maxColumns)
197
200
  }
198
201
 
199
- function usageLines(status: SearchStatus, maxColumns: number): readonly string[] {
202
+ function usageLines(status: SearchStatus, maxColumns: number, language: Language): readonly string[] {
200
203
  // The desktop panel lays the original verbose copy out as ` · ` chunks of its
201
204
  // own table; only the terminal needs the compact form.
202
205
  if (maxColumns === Number.POSITIVE_INFINITY) {
203
- return [`今日 ${status.today}`, `本月 ${status.month}`, `累计 ${status.total}`]
206
+ return [
207
+ `${t(language, "usage.label.today")} ${status.today}`,
208
+ `${t(language, "usage.label.month")} ${status.month}`,
209
+ `${t(language, "usage.label.total")} ${status.total}`,
210
+ ]
204
211
  }
205
212
  const counts = status.counts
206
213
  if (counts) {
207
214
  const layout = usageLayout(counts)
208
- return PERIODS.map((period) => compactUsageRow(period, counts[period], layout, maxColumns))
215
+ return PERIODS.map((period) => compactUsageRow(period, counts[period], layout, maxColumns, language))
209
216
  }
210
- return PERIODS.map((period) => legacyUsageRow(period, status[period], maxColumns))
217
+ return PERIODS.map((period) => legacyUsageRow(period, status[period], maxColumns, language))
211
218
  }
212
219
 
213
220
  /**
@@ -275,18 +282,13 @@ function fitTitle(
275
282
  }
276
283
 
277
284
  /** The fix hint for a missing key; it gets its own row because no click can fix it. */
278
- function fitHint(maxColumns: number): string {
279
- const candidates = [
280
- "设置 DEEPSEEK_API_KEY 后重启",
281
- "设置 API KEY 后重启",
282
- "设置 API KEY",
283
- "设置 KEY",
284
- ]
285
- if (maxColumns === Number.POSITIVE_INFINITY) return candidates[0]
286
- for (const candidate of candidates) {
287
- if (textColumns(candidate) <= maxColumns) return candidate
285
+ function fitHint(maxColumns: number, language: Language): string {
286
+ const forms = pack(language).hintForms
287
+ if (maxColumns === Number.POSITIVE_INFINITY) return forms[0] ?? ""
288
+ for (const form of forms) {
289
+ if (textColumns(form) <= maxColumns) return form
288
290
  }
289
- return clip(candidates[candidates.length - 1], maxColumns)
291
+ return clip(forms[forms.length - 1] ?? "", maxColumns)
290
292
  }
291
293
 
292
294
  interface ActionRows extends TitleFit {
@@ -300,33 +302,34 @@ function actionRows(state: string, shortState: string, verb: string, maxColumns:
300
302
  }
301
303
 
302
304
  export function sidebarRows(input: SidebarInput): SidebarRows {
303
- const { status, error } = input
305
+ const { status, error, language } = input
304
306
  const maxColumns = input.maxColumns ?? DEFAULT_COLUMNS
305
- const lines = status === undefined ? [] : usageLines(status, maxColumns)
307
+ const lines = status === undefined ? [] : usageLines(status, maxColumns, language)
306
308
 
307
309
  // No status yet: the body has no numbers to show, only the reason (or that
308
310
  // the first read is still in flight).
309
311
  if (status === undefined) {
312
+ const retry = t(language, "action.retry")
310
313
  return error === undefined || error.trim() === ""
311
314
  ? {
312
- ...actionRows("正在读取…", "正在读取…", "重试", maxColumns),
315
+ ...actionRows(t(language, "state.loading"), t(language, "state.loadingShort"), retry, maxColumns),
313
316
  hint: undefined,
314
317
  clickable: true,
315
318
  lines,
316
319
  tone: "idle",
317
320
  }
318
321
  : {
319
- ...actionRows("读取失败", "读取失败", "重试", maxColumns),
322
+ ...actionRows(t(language, "state.failed"), t(language, "state.failedShort"), retry, maxColumns),
320
323
  hint: undefined,
321
324
  clickable: true,
322
- lines: [shortMessage(error, maxColumns)],
325
+ lines: [shortMessage(error, maxColumns, language)],
323
326
  tone: "error",
324
327
  }
325
328
  }
326
329
 
327
330
  if (status.patched && status.hasKey) {
328
331
  return {
329
- ...actionRows("已启用", "已启用", "停用", maxColumns),
332
+ ...actionRows(t(language, "state.enabled"), t(language, "state.enabledShort"), t(language, "action.disable"), maxColumns),
330
333
  hint: undefined,
331
334
  clickable: true,
332
335
  lines,
@@ -337,7 +340,12 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
337
340
  // DeepSeek was requested but the built-in tool was not found: nothing is
338
341
  // actually taken over, so the only honest action is to release the mode.
339
342
  return {
340
- ...actionRows("未接管内置 websearch", "未接管", "交还", maxColumns),
343
+ ...actionRows(
344
+ t(language, "state.unpatched"),
345
+ t(language, "state.unpatchedShort"),
346
+ t(language, "action.release"),
347
+ maxColumns,
348
+ ),
341
349
  hint: undefined,
342
350
  clickable: true,
343
351
  lines,
@@ -346,7 +354,7 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
346
354
  }
347
355
  if (status.mode === "native") {
348
356
  return {
349
- ...actionRows("已停用", "已停用", "启用", maxColumns),
357
+ ...actionRows(t(language, "state.disabled"), t(language, "state.disabledShort"), t(language, "action.enable"), maxColumns),
350
358
  hint: undefined,
351
359
  clickable: true,
352
360
  lines,
@@ -356,9 +364,9 @@ export function sidebarRows(input: SidebarInput): SidebarRows {
356
364
  // A missing key is not something a click can fix, so the title row stands
357
365
  // alone and the hint gets its own row directly under it. The env var name
358
366
  // only survives on surfaces wide enough for it.
359
- const hint = fitHint(maxColumns)
367
+ const hint = fitHint(maxColumns, language)
360
368
  return {
361
- ...fitTitle("未配置 API Key", "缺 API Key", maxColumns, undefined),
369
+ ...fitTitle(t(language, "state.missingKey"), t(language, "state.missingKeyShort"), maxColumns, undefined),
362
370
  action: hint,
363
371
  hint,
364
372
  clickable: false,
package/src/tui/card.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import type { JSX } from "@opentui/solid"
4
4
  import type { ResolvedTheme } from "@opencode/theme/tui"
5
5
  import { createElement, insert, setProp } from "@opentui/solid"
6
- import type { SearchStatus } from "../rpc.ts"
6
+ import type { Language, SearchStatus } from "../rpc.ts"
7
7
  import { DEFAULT_COLUMNS, sidebarRows } from "../status-view.ts"
8
8
  import type { SidebarRows } from "../status-view.ts"
9
9
 
@@ -72,6 +72,8 @@ function element(tag: string, props: Props | undefined, children: readonly Child
72
72
  export function sidebarCard(input: {
73
73
  readonly status: SearchStatus | undefined
74
74
  readonly error: string | undefined
75
+ /** UI language resolved by the server; the card never picks one itself. */
76
+ readonly language: Language
75
77
  readonly theme: ResolvedTheme
76
78
  readonly onToggle: () => void
77
79
  /**
@@ -87,6 +89,7 @@ export function sidebarCard(input: {
87
89
  status: input.status,
88
90
  error: input.error,
89
91
  maxColumns: cardColumns(input.maxColumns),
92
+ language: input.language,
90
93
  })
91
94
  const theme = input.theme
92
95
  const state = toneColor(theme, rows.tone)
package/src/tui/main.ts CHANGED
@@ -9,7 +9,8 @@ import { Plugin } from "@opencode/plugin/tui"
9
9
  import { createElement, insert, setProp, type JSX } from "@opentui/solid"
10
10
  import type { Renderable } from "@opentui/core"
11
11
  import { createSignal } from "solid-js"
12
- import { DeepSeekRpc, readModeResult, readStatus, type SearchStatus } from "../rpc.ts"
12
+ import { DeepSeekRpc, readModeResult, readStatus, type Language, type SearchStatus } from "../rpc.ts"
13
+ import { t } from "../i18n/index.ts"
13
14
  import { sidebarCard } from "./card.ts"
14
15
 
15
16
  const REFRESH_MS = 10_000
@@ -25,6 +26,12 @@ export default Plugin.define({
25
26
 
26
27
  const describeError = (e: unknown) => (e instanceof Error ? e.message : String(e)).slice(0, ERROR_MAX)
27
28
 
29
+ // One language for every string this half renders. Before the first status
30
+ // arrives there is nothing to read it from, so the first frame is English;
31
+ // the reactive card and the host-re-run keymap factory pick up the real one
32
+ // as soon as the server reports it.
33
+ const language = (): Language => status()?.language ?? "en"
34
+
28
35
  // `status` takes a required first argument: plain JSON Schema degrades the
29
36
  // input type to `unknown`, and the host validates the payload against the
30
37
  // method's object schema, so an omitted input is rejected as invalid.
@@ -35,7 +42,7 @@ export default Plugin.define({
35
42
  setStatus(next)
36
43
  setError(undefined)
37
44
  } else {
38
- setError("状态数据格式不符")
45
+ setError(t(language(), "toast.statusMalformed"))
39
46
  }
40
47
  } catch (e) {
41
48
  setError(describeError(e))
@@ -46,7 +53,7 @@ export default Plugin.define({
46
53
  async function applyMode(mode: "deepseek" | "native") {
47
54
  try {
48
55
  const result = readModeResult(await rpc.setMode({ mode }))
49
- if (!result) ctx.ui.toast.show({ message: "模式切换结果格式不符", variant: "error" })
56
+ if (!result) ctx.ui.toast.show({ message: t(language(), "toast.modeMalformed"), variant: "error" })
50
57
  else if (!result.ok) ctx.ui.toast.show({ message: result.message, variant: "error" })
51
58
  } catch (e) {
52
59
  ctx.ui.toast.show({ message: describeError(e), variant: "error" })
@@ -72,11 +79,11 @@ export default Plugin.define({
72
79
  // entry point that always works (keymap command + `/deepseek` slash).
73
80
  async function openMenu() {
74
81
  const choice = await ctx.ui.dialog.select<"deepseek" | "native" | "refresh">({
75
- title: "DeepSeek 搜索",
82
+ title: t(language(), "dialog.title"),
76
83
  options: [
77
- { title: "启用", value: "deepseek", description: "接管内置 websearch" },
78
- { title: "停用", value: "native", description: "把内置 websearch 交还宿主" },
79
- { title: "刷新", value: "refresh", description: "重新读取状态与统计" },
84
+ { title: t(language(), "dialog.enable"), value: "deepseek", description: t(language(), "dialog.enableDesc") },
85
+ { title: t(language(), "dialog.disable"), value: "native", description: t(language(), "dialog.disableDesc") },
86
+ { title: t(language(), "dialog.refresh"), value: "refresh", description: t(language(), "dialog.refreshDesc") },
80
87
  ],
81
88
  current: status()?.mode,
82
89
  })
@@ -106,6 +113,7 @@ export default Plugin.define({
106
113
  sidebarCard({
107
114
  status: status(),
108
115
  error: error(),
116
+ language: language(),
109
117
  theme: ctx.theme,
110
118
  onToggle: toggle,
111
119
  maxColumns: width(),
@@ -121,7 +129,7 @@ export default Plugin.define({
121
129
  commands: [
122
130
  {
123
131
  id: "deepseek-search.toggle",
124
- title: "DeepSeek: 启用/停用搜索接管",
132
+ title: t(language(), "command.toggle"),
125
133
  slash: { name: "deepseek" },
126
134
  run: () => void openMenu(),
127
135
  },
package/src/usage.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  //
7
7
  // Everything here is pure; persistence is the caller's job (ctx.storage).
8
8
 
9
+ import { t } from "./i18n/index.ts"
10
+ import type { Language } from "./rpc.ts"
11
+
9
12
  export const USAGE_KEY = "usage/v1"
10
13
  export const KEEP_DAYS = 60
11
14
  export const MICRO = 1_000_000
@@ -102,31 +105,51 @@ export function formatYuan(costMicro: number): string {
102
105
  return `¥${(costMicro / MICRO).toFixed(4)}`
103
106
  }
104
107
 
105
- export function formatUsage(ledger: UsageLedger, pricePerThousand: number, mode: string, today = dayKey()): string {
108
+ export function formatUsage(
109
+ ledger: UsageLedger,
110
+ pricePerThousand: number,
111
+ mode: string,
112
+ language: Language,
113
+ today = dayKey(),
114
+ ): string {
106
115
  const month = today.slice(0, 7)
107
116
  const line = (label: string, d: UsageDay) =>
108
- `${label}: 搜索 ${d.search} · 抓取 ${d.fetch} · 未计费 ${d.empty + d.failed}(空结果 ${d.empty} / 失败 ${d.failed})· 估算 ${formatYuan(d.costMicro)}`
117
+ t(language, "usage.report.line", {
118
+ label,
119
+ search: d.search,
120
+ fetch: d.fetch,
121
+ skipped: d.empty + d.failed,
122
+ empty: d.empty,
123
+ failed: d.failed,
124
+ cost: formatYuan(d.costMicro),
125
+ })
109
126
  return [
110
- `DeepSeek 用量(本地统计,按成功请求估算;当前模式 ${mode};单价 ¥${pricePerThousand}/1000 次)`,
111
- line(`今日 ${today}`, sumDays(ledger, (k) => k === today)),
112
- line(`本月 ${month}`, sumDays(ledger, (k) => k.startsWith(month))),
113
- line("累计", sumDays(ledger, () => true)),
114
- "说明:空结果与失败请求不计费;DeepSeek 以账号侧账单为准,本统计不含其他机器或其他工具的调用。",
127
+ t(language, "usage.report.title", { mode, price: pricePerThousand }),
128
+ line(`${t(language, "usage.label.today")} ${today}`, sumDays(ledger, (k) => k === today)),
129
+ line(`${t(language, "usage.label.month")} ${month}`, sumDays(ledger, (k) => k.startsWith(month))),
130
+ line(t(language, "usage.label.total"), sumDays(ledger, () => true)),
131
+ t(language, "usage.report.note"),
115
132
  ].join("\n")
116
133
  }
117
134
 
118
135
  /**
119
136
  * Compact one-line summaries for a status display, keyed by period. Callers add
120
- * the 今日/本月/累计 labels themselves. Costs are read from the ledger, which
137
+ * the localized period label themselves. Costs are read from the ledger, which
121
138
  * already accumulated them at tally time.
122
139
  */
123
140
  export function usageSummaryLines(
124
141
  ledger: UsageLedger,
142
+ language: Language,
125
143
  today = dayKey(),
126
144
  ): { today: string; month: string; total: string } {
127
145
  const month = today.slice(0, 7)
128
146
  const line = (d: UsageDay) =>
129
- `搜索 ${d.search} · 抓取 ${d.fetch} · 空/失 ${d.empty + d.failed} · ${formatYuan(d.costMicro)}`
147
+ t(language, "usage.summary.line", {
148
+ search: d.search,
149
+ fetch: d.fetch,
150
+ skipped: d.empty + d.failed,
151
+ cost: formatYuan(d.costMicro),
152
+ })
130
153
  return {
131
154
  today: line(sumDays(ledger, (k) => k === today)),
132
155
  month: line(sumDays(ledger, (k) => k.startsWith(month))),