@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.
package/src/deepseek.ts CHANGED
@@ -1,8 +1,12 @@
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 { 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"
@@ -32,12 +36,8 @@ export interface FetchResult {
32
36
  truncated: boolean
33
37
  }
34
38
 
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
- )
39
+ export function missingKeyMessage(language: Language): string {
40
+ return t(language, "error.missingKey")
41
41
  }
42
42
 
43
43
  // ---------- abort plumbing: combine the caller signal with a timeout ----------
@@ -80,42 +80,43 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
80
80
 
81
81
  // ---------- error mapping ----------
82
82
 
83
- function upstreamMessage(body: string): string {
83
+ function upstreamMessage(body: string, language: Language): string {
84
84
  try {
85
85
  const parsed = JSON.parse(body) as { error?: { message?: unknown } }
86
86
  const message = parsed?.error?.message
87
- return typeof message === "string" && message.trim() ? `:${message.trim()}` : ""
87
+ if (typeof message !== "string" || !message.trim()) return ""
88
+ // The provider text is kept verbatim; only the separator is localized.
89
+ const separator = language === "en" ? ": " : ":"
90
+ return `${separator}${message.trim()}`
88
91
  } catch {
89
92
  return ""
90
93
  }
91
94
  }
92
95
 
93
- export function httpError(status: number, body: string, isFetch: boolean): Error {
94
- const upstream = upstreamMessage(body)
95
- const fetchHint = isFetch ? " 可改用内置 webfetch 工具抓取该页面。" : ""
96
+ export function httpError(status: number, body: string, isFetch: boolean, language: Language): Error {
97
+ const upstream = upstreamMessage(body, language)
98
+ const hint = isFetch ? t(language, "error.http.fetchHint") : ""
99
+ const params = { upstream, hint, status }
96
100
  switch (status) {
97
101
  case 400:
98
- return new Error(`DeepSeek 请求参数无效(400)${upstream}。搜索词需 1~128 字符;Fetch 的 URL 需为合法的 http(s) 绝对地址。`)
102
+ return new Error(t(language, "error.http.400", params))
99
103
  case 401:
100
- return new Error(`DeepSeek API Key 无效(401)${upstream}。请检查 DEEPSEEK_API_KEY 环境变量或 options.apiKey。`)
104
+ return new Error(t(language, "error.http.401", params))
101
105
  case 402:
102
- return new Error("DeepSeek 账户余额不足(402),失败请求不计费。请充值后重试。")
106
+ return new Error(t(language, "error.http.402", params))
103
107
  case 403:
104
- return new Error(`DeepSeek Fetch 被目标站点安全策略拒绝(403)${upstream}。${fetchHint}`)
108
+ return new Error(t(language, "error.http.403", params))
105
109
  case 404:
106
- return new Error(`DeepSeek 无法提取该链接的正文(404)${upstream}。请确认链接可访问。${fetchHint}`)
110
+ return new Error(t(language, "error.http.404", params))
107
111
  case 408:
108
- return new Error("DeepSeek 请求超时(408),已退避重试一次仍失败。请稍后重试。")
112
+ return new Error(t(language, "error.http.408", params))
109
113
  case 413:
110
- return new Error("DeepSeek 请求体过大(413)。请缩短输入。")
114
+ return new Error(t(language, "error.http.413", params))
111
115
  case 429:
112
- return new Error("DeepSeek 触发限速(429,账号级 Search 100 QPS / Fetch 25 QPS),已退避重试一次仍失败。请稍后再试。")
116
+ return new Error(t(language, "error.http.429", params))
113
117
  default:
114
- if (status >= 500)
115
- return new Error(
116
- `DeepSeek 服务端错误(${status}),已退避重试一次仍失败。可把插件 options.mode 改为 "native" 暂用原生搜索。`,
117
- )
118
- return new Error(`DeepSeek 请求失败(HTTP ${status})${upstream}`)
118
+ if (status >= 500) return new Error(t(language, "error.http.5xx", params))
119
+ return new Error(t(language, "error.http.other", params))
119
120
  }
120
121
  }
121
122
 
@@ -132,9 +133,10 @@ export async function postJson(
132
133
  body: unknown,
133
134
  apiKey: string,
134
135
  timeoutMs: number,
136
+ language: Language,
135
137
  signal?: AbortSignal,
136
138
  ): Promise<unknown> {
137
- if (!apiKey) throw new Error(missingKeyMessage())
139
+ if (!apiKey) throw new Error(missingKeyMessage(language))
138
140
  for (let attempt = 1; ; attempt++) {
139
141
  const merged = mergeAbort(signal, timeoutMs)
140
142
  try {
@@ -149,7 +151,7 @@ export async function postJson(
149
151
  try {
150
152
  return JSON.parse(text) as unknown
151
153
  } catch {
152
- throw new Error(`DeepSeek 返回了无法解析的响应(HTTP ${res.status})。请稍后重试。`)
154
+ throw new Error(t(language, "error.parse", { status: res.status }))
153
155
  }
154
156
  }
155
157
  const text = await res.text().catch(() => "")
@@ -157,18 +159,18 @@ export async function postJson(
157
159
  await sleep(retryAfterMs(res.headers), signal)
158
160
  continue
159
161
  }
160
- throw httpError(res.status, text, path === FETCH_PATH)
162
+ throw httpError(res.status, text, path === FETCH_PATH, language)
161
163
  } catch (e) {
162
164
  if (e instanceof Error && e.name === "AbortError") {
163
165
  throw new Error(
164
166
  signal?.aborted
165
- ? "DeepSeek 请求已取消。"
166
- : `DeepSeek 请求超时(${Math.round(timeoutMs / 1000)} 秒),请重试。`,
167
+ ? t(language, "error.aborted")
168
+ : t(language, "error.timeout", { seconds: Math.round(timeoutMs / 1000) }),
167
169
  )
168
170
  }
169
171
  // fetch() rejects with TypeError on DNS/TLS/proxy failures.
170
172
  if (e instanceof TypeError) {
171
- throw new Error(`无法连接 ${API_BASE}(网络错误:${e.message})。请检查网络或代理设置。`)
173
+ throw new Error(t(language, "error.network", { base: API_BASE, message: e.message }))
172
174
  }
173
175
  throw e
174
176
  } finally {
@@ -222,39 +224,53 @@ export async function deepseekSearch(
222
224
  query: string,
223
225
  apiKey: string,
224
226
  count: number,
227
+ language: Language,
225
228
  signal?: AbortSignal,
226
229
  ): Promise<SearchResult[]> {
227
- const data = await postJson(SEARCH_PATH, buildSearchBody(query, count), apiKey, SEARCH_TIMEOUT_MS, signal)
230
+ const data = await postJson(
231
+ SEARCH_PATH,
232
+ buildSearchBody(query, count),
233
+ apiKey,
234
+ SEARCH_TIMEOUT_MS,
235
+ language,
236
+ signal,
237
+ )
228
238
  return mapSearchResults(data)
229
239
  }
230
240
 
231
241
  // ---------- DeepSeek Fetch -> Markdown with truncation ----------
232
242
 
233
- export function mapFetchResponse(data: unknown, url: string, maxChars: number): FetchResult {
243
+ export function mapFetchResponse(
244
+ data: unknown,
245
+ url: string,
246
+ maxChars: number,
247
+ language: Language,
248
+ ): FetchResult {
234
249
  const title = typeof (data as { title?: unknown })?.title === "string" ? (data as { title: string }).title : ""
235
250
  const raw = typeof (data as { content?: unknown })?.content === "string" ? (data as { content: string }).content : ""
236
251
  const truncated = raw.length > maxChars
237
252
  let body = truncated ? raw.slice(0, maxChars) : raw
238
253
  if (truncated) {
239
- body += `\n\n[DeepSeek Fetch 截断提示] 原文共 ${raw.length} 字符,超出上限 ${maxChars},此处仅保留前 ${maxChars} 字符。`
254
+ body += `\n\n${t(language, "error.fetch.truncated", { raw: raw.length, max: maxChars })}`
240
255
  }
241
256
  return {
242
- markdown: `# ${title || url}\n来源: ${url}\n\n${body}`,
257
+ markdown: `# ${title || url}\n${t(language, "fetch.source", { url })}\n\n${body}`,
243
258
  chars: raw.length,
244
259
  empty: raw.length === 0,
245
260
  truncated,
246
261
  }
247
262
  }
248
263
 
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} 字符)。`
264
+ export function validateUrl(url: string, language: Language): string | undefined {
265
+ if (!/^https?:\/\//i.test(url)) return t(language, "error.validate.urlScheme", { url })
266
+ if (url.length > MAX_URL_CHARS)
267
+ return t(language, "error.validate.urlLong", { length: url.length, max: MAX_URL_CHARS })
252
268
  try {
253
269
  const parsed = new URL(url)
254
- if (!parsed.host) return `URL 缺少主机名:「${url}」。`
255
- if (parsed.username || parsed.password) return "URL 不能包含用户名/密码(userinfo)。"
270
+ if (!parsed.host) return t(language, "error.validate.urlHost", { url })
271
+ if (parsed.username || parsed.password) return t(language, "error.validate.urlUserinfo")
256
272
  } catch {
257
- return `无法解析 URL:「${url}」。`
273
+ return t(language, "error.validate.urlParse", { url })
258
274
  }
259
275
  return undefined
260
276
  }
@@ -263,15 +279,17 @@ export async function deepseekFetch(
263
279
  url: string,
264
280
  apiKey: string,
265
281
  maxChars: number,
282
+ language: Language,
266
283
  signal?: AbortSignal,
267
284
  ): Promise<FetchResult> {
268
- const data = await postJson(FETCH_PATH, { url }, apiKey, FETCH_TIMEOUT_MS, signal)
269
- return mapFetchResponse(data, url, maxChars)
285
+ const data = await postJson(FETCH_PATH, { url }, apiKey, FETCH_TIMEOUT_MS, language, signal)
286
+ return mapFetchResponse(data, url, maxChars, language)
270
287
  }
271
288
 
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}),请精简后重试。`
289
+ export function validateQuery(query: string, language: Language): string | undefined {
290
+ if (query.length === 0) return t(language, "error.validate.queryEmpty")
291
+ if (query.length > MAX_QUERY_CHARS)
292
+ return t(language, "error.validate.queryLong", { length: query.length, max: MAX_QUERY_CHARS })
275
293
  return undefined
276
294
  }
277
295
 
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,6 +31,10 @@ 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
@@ -42,7 +47,7 @@ export default Plugin.define({
42
47
  setStatus(next)
43
48
  setError(undefined)
44
49
  } else {
45
- setError("状态数据格式不符")
50
+ setError(t(language(), "toast.statusMalformed"))
46
51
  }
47
52
  } catch (e) {
48
53
  setError(describeError(e))
@@ -53,7 +58,7 @@ export default Plugin.define({
53
58
  setBusy(true)
54
59
  try {
55
60
  const result = readModeResult(await rpc.setMode({ mode: next }))
56
- if (!result) ctx.toast.show({ title: "DeepSeek", description: "模式切换结果格式不符", variant: "error" })
61
+ if (!result) ctx.toast.show({ title: "DeepSeek", description: t(language(), "toast.modeMalformed"), variant: "error" })
57
62
  else if (!result.ok) ctx.toast.show({ title: "DeepSeek", description: result.message, variant: "error" })
58
63
  } catch (e) {
59
64
  ctx.toast.show({ title: "DeepSeek", description: describeError(e), variant: "error" })
@@ -96,18 +101,20 @@ export default Plugin.define({
96
101
  const openPanel = () => {
97
102
  const session = ctx.sessions.current()
98
103
  if (!session) {
99
- ctx.toast.show({ title: "DeepSeek", description: "请先打开一个会话" })
104
+ ctx.toast.show({ title: "DeepSeek", description: t(language(), "toast.openSessionFirst") })
100
105
  return
101
106
  }
102
107
  ctx.layout.open(`${ctx.id}:main`, session)
103
108
  }
104
109
 
105
- ctx.add(Command, {
110
+ // A factory, because the palette label has to follow the language that
111
+ // arrives with the status signal.
112
+ ctx.add(Command, (): Command => ({
106
113
  id: "open",
107
- title: "DeepSeek: 打开统计面板",
114
+ title: t(language(), "command.open"),
108
115
  section: "session",
109
116
  run: openPanel,
110
- })
117
+ }))
111
118
 
112
119
  // The new-tab launcher and the + menu list `session.panel` items, not panel
113
120
  // tabs, so without this the panel is reachable only by typing the command.
@@ -119,7 +126,7 @@ export default Plugin.define({
119
126
  menu: "session.panel",
120
127
  id: "open",
121
128
  title: "DeepSeek",
122
- description: "搜索用量与接管开关",
129
+ description: t(language(), "menu.description"),
123
130
  icon: "globe",
124
131
  order: 60,
125
132
  opened,
package/src/gui/panel.tsx CHANGED
@@ -3,6 +3,7 @@
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 { t, type Key } from "../i18n/index.ts"
6
7
  import type { SearchStatus } from "../rpc.ts"
7
8
  import { sidebarRows, type SidebarRows } from "../status-view.ts"
8
9
 
@@ -28,9 +29,9 @@ const INK = {
28
29
  light: { on: "#1e7a4c", warn: "#8a5f08", error: "#b03931", off: "#6b7076", idle: "#6b7076", rule: "rgba(0,0,0,0.13)" },
29
30
  } as const
30
31
 
31
- const LABEL: Readonly<Record<Tone, string>> = {
32
- on: "停用(交还原生搜索)", off: "启用(由 DeepSeek 接管)",
33
- warn: "交还原生搜索", error: "重试", idle: "重试",
32
+ const LABEL_KEY: Readonly<Record<Tone, Key>> = {
33
+ on: "panel.label.on", off: "panel.label.off",
34
+ warn: "panel.label.warn", error: "panel.label.error", idle: "panel.label.idle",
34
35
  }
35
36
 
36
37
  const ICON = { on: "arrow-undo-down", off: "globe", warn: "arrow-undo-down", error: "refresh", idle: "refresh" } as const
@@ -68,7 +69,11 @@ function help(action: string, mono: string): JSX.Element {
68
69
 
69
70
  export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
70
71
  const ctx = usePlugin()
71
- const rows = createMemo(() => sidebarRows({ status: props.status, error: props.error, maxColumns: Infinity }))
72
+ // The desktop plugin has no forced locale: the server resolves one and carries
73
+ // it in `status`. The first frame has no status and renders the loader, so the
74
+ // fallback only ever shows that.
75
+ const language = () => props.status?.language ?? "en"
76
+ const rows = createMemo(() => sidebarRows({ status: props.status, error: props.error, maxColumns: Infinity, language: language() }))
72
77
  const ink = createMemo(() => INK[ctx.appearance.mode()])
73
78
  const mono = () => ctx.appearance.font("mono")
74
79
 
@@ -95,8 +100,8 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
95
100
  <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
101
  {headline(rows().title, ink()[rows().tone])}
97
102
  </h2>
98
- <ctx.ui.Tooltip value="刷新用量" placement="bottom">
99
- <ctx.ui.IconButton icon="refresh" label="刷新用量" size="small" onClick={props.onRefresh} />
103
+ <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} />
100
105
  </ctx.ui.Tooltip>
101
106
  </header>
102
107
 
@@ -106,7 +111,7 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
106
111
  ) : (
107
112
  <div style={{ display: "flex", "align-items": "center", gap: "8px", "margin-top": "14px", opacity: 0.7 }}>
108
113
  <ctx.ui.Loader size="small" />
109
- <span style={{ "font-size": "12px" }}>正在读取用量…</span>
114
+ <span style={{ "font-size": "12px" }}>{t(language(), "panel.loading")}</span>
110
115
  </div>
111
116
  )
112
117
  ) : (
@@ -145,7 +150,7 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
145
150
  <Show when={rows().clickable && rows().tone !== "idle"}>
146
151
  <div style={{ "margin-top": "18px" }}>
147
152
  <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]}
153
+ {t(language(), LABEL_KEY[rows().tone])}
149
154
  </ctx.ui.Button>
150
155
  </div>
151
156
  </Show>
package/src/i18n/en.ts ADDED
@@ -0,0 +1,151 @@
1
+ // English copy: the source language and the fallback for every key.
2
+ //
3
+ // Every other locale is typed against `Key` (the keys of this table), so a
4
+ // missing translation is a compile error rather than a silent English string.
5
+ // Templates interpolate `{name}` through `format()` in ./index.ts; a brace group
6
+ // that is not a known parameter (e.g. `{env:DEEPSEEK_API_KEY}`) is left alone.
7
+
8
+ export const en = {
9
+ // ---------- sidebar states: full wording, then the short form used when the
10
+ // row cannot hold the full one ----------
11
+ "state.loading": "Loading…",
12
+ "state.loadingShort": "Loading…",
13
+ "state.failed": "Read failed",
14
+ "state.failedShort": "Failed",
15
+ "state.enabled": "Enabled",
16
+ "state.enabledShort": "On",
17
+ "state.disabled": "Disabled",
18
+ "state.disabledShort": "Off",
19
+ "state.unpatched": "websearch not taken over",
20
+ "state.unpatchedShort": "Not taken over",
21
+ "state.missingKey": "API key missing",
22
+ "state.missingKeyShort": "No API key",
23
+
24
+ // ---------- the clickable action on the card ----------
25
+ "action.disable": "Disable",
26
+ "action.enable": "Enable",
27
+ "action.release": "Release",
28
+ "action.retry": "Retry",
29
+
30
+ // ---------- desktop panel ----------
31
+ "panel.tooltip.refresh": "Refresh usage",
32
+ "panel.loading": "Reading usage…",
33
+ "panel.label.on": "Disable (release to native search)",
34
+ "panel.label.off": "Enable (take over with DeepSeek)",
35
+ "panel.label.warn": "Release to native search",
36
+ "panel.label.error": "Retry",
37
+ "panel.label.idle": "Retry",
38
+
39
+ // ---------- host chrome: commands, menus, toasts, dialogs ----------
40
+ "command.open": "DeepSeek: Open usage panel",
41
+ "command.toggle": "DeepSeek: Enable/disable search takeover",
42
+ "menu.description": "Search usage and the takeover switch",
43
+ "toast.openSessionFirst": "Open a session first",
44
+ "toast.statusMalformed": "Status payload has the wrong shape",
45
+ "toast.modeMalformed": "Mode switch result has the wrong shape",
46
+ "dialog.title": "DeepSeek search",
47
+ "dialog.enable": "Enable",
48
+ "dialog.enableDesc": "Take over the built-in websearch",
49
+ "dialog.disable": "Disable",
50
+ "dialog.disableDesc": "Return the built-in websearch to the host",
51
+ "dialog.refresh": "Refresh",
52
+ "dialog.refreshDesc": "Re-read status and usage",
53
+
54
+ // ---------- deepseek_mode output ----------
55
+ "mode.report": "Current search backend: {mode} (source: {source}){suffix}",
56
+ "mode.source.override": "deepseek_mode switch, persisted",
57
+ "mode.source.config": "config file options.mode",
58
+ "mode.suffix.patched": " · built-in websearch is served by DeepSeek",
59
+ "mode.suffix.notPatched": " · built-in websearch was not found, so nothing is taken over",
60
+ "mode.suffix.native": " · built-in websearch stays native",
61
+ "mode.notFound":
62
+ "Built-in {tool} tool not found; it cannot be switched at runtime. " +
63
+ 'Set options.mode to "{mode}" in the config file instead.',
64
+ "mode.configCleared": "Persisted switch cleared; back to the config file ({mode}).",
65
+ "mode.persisted":
66
+ "Persisted: still {mode} after restarting OpenCode. " +
67
+ 'To return to the config file, call deepseek_mode with mode: "config".',
68
+ "mode.statusOnly": "status only reports the current mode; it does not switch.",
69
+ "mode.invalid": "mode must be status / deepseek / native / config, got: {raw}",
70
+
71
+ // ---------- tool progress / bookkeeping ----------
72
+ "progress.fetch": "DeepSeek Fetch: fetching {url}",
73
+ "fetch.source": "Source: {url}",
74
+ "usage.reset": "Local stats cleared.",
75
+
76
+ // ---------- deepseek_usage report ----------
77
+ "usage.report.title":
78
+ "DeepSeek usage (local counts, estimated per successful request; current mode {mode}; ¥{price}/1000 requests)",
79
+ "usage.report.line":
80
+ "{label}: searches {search} · fetches {fetch} · empty/failed {skipped} (empty {empty} / failed {failed}) · est. {cost}",
81
+ "usage.report.note":
82
+ "Note: empty and failed requests are not billed; DeepSeek's account-side bill is authoritative, and these counts exclude other machines or tools.",
83
+ "usage.label.today": "Today",
84
+ "usage.label.month": "Month",
85
+ "usage.label.total": "Total",
86
+ "usage.summary.line": "Search {search} · Fetch {fetch} · empty/failed {skipped} · {cost}",
87
+
88
+ // ---------- errors ----------
89
+ "error.missingKey":
90
+ "DeepSeek API key is not configured. Set the DEEPSEEK_API_KEY environment variable " +
91
+ '(PowerShell: [Environment]::SetEnvironmentVariable("DEEPSEEK_API_KEY","sk-...","User")), ' +
92
+ 'or write "apiKey": "{env:DEEPSEEK_API_KEY}" in the plugin options, then restart opencode.',
93
+ "error.http.400":
94
+ "Invalid DeepSeek request parameters (400){upstream}. Queries must be 1–128 characters; " +
95
+ "Fetch URLs must be valid absolute http(s) URLs.",
96
+ "error.http.401":
97
+ "Invalid DeepSeek API key (401){upstream}. Check the DEEPSEEK_API_KEY environment variable or options.apiKey.",
98
+ "error.http.402": "Insufficient DeepSeek account balance (402); failed requests are not billed. Top up and try again.",
99
+ "error.http.403": "DeepSeek Fetch was blocked by the target site's security policy (403){upstream}.{hint}",
100
+ "error.http.404": "DeepSeek could not extract the page body (404){upstream}. Check that the link is reachable.{hint}",
101
+ "error.http.408": "DeepSeek request timed out (408); one backoff retry also failed. Try again later.",
102
+ "error.http.413": "DeepSeek request body too large (413). Shorten the input.",
103
+ "error.http.429":
104
+ "DeepSeek rate limit hit (429; account limits: Search 100 QPS / Fetch 25 QPS); " +
105
+ "one backoff retry also failed. Try again later.",
106
+ "error.http.5xx":
107
+ "DeepSeek server error ({status}); one backoff retry also failed. " +
108
+ 'You can set options.mode to "native" to use native search for now.',
109
+ "error.http.other": "DeepSeek request failed (HTTP {status}){upstream}",
110
+ "error.http.fetchHint": " You can use the built-in webfetch tool for this page.",
111
+ "error.parse": "DeepSeek returned an unparseable response (HTTP {status}). Try again later.",
112
+ "error.aborted": "DeepSeek request cancelled.",
113
+ "error.timeout": "DeepSeek request timed out ({seconds}s). Try again.",
114
+ "error.network": "Cannot reach {base} (network error: {message}). Check your network or proxy settings.",
115
+ "error.validate.queryEmpty": "Empty query: DeepSeek Search needs a non-empty query (1–128 characters).",
116
+ "error.validate.queryLong": "Query too long ({length} characters, limit {max}); shorten it and retry.",
117
+ "error.validate.urlScheme": 'Invalid URL: "{url}". DeepSeek Fetch only supports absolute http/https URLs.',
118
+ "error.validate.urlLong": "URL too long ({length} > {max} characters).",
119
+ "error.validate.urlHost": 'URL has no host: "{url}".',
120
+ "error.validate.urlUserinfo": "URL must not contain a username/password (userinfo).",
121
+ "error.validate.urlParse": 'Cannot parse URL: "{url}".',
122
+ "error.fetch.truncated":
123
+ "[DeepSeek Fetch truncated] The page is {raw} characters; the limit is {max}, so only the first {max} characters are kept.",
124
+ } as const
125
+
126
+ export type Key = keyof typeof en
127
+ /** One locale's complete table: every key, no extras. */
128
+ export type Messages = Record<Key, string>
129
+
130
+ /**
131
+ * Usage-row ladder, richest first. Each entry is a complete row, so nothing is
132
+ * ever left dangling and no number is ever cut from its unit. `{label}` is the
133
+ * localized period label; `Search`/`Fetch` stay English in every language.
134
+ */
135
+ export const enUsageForms: readonly string[] = [
136
+ "{label} Search {search} Fetch {fetch} {cost}",
137
+ "{label} Search {search} Fetch {fetch} {cost}",
138
+ "{label} Search {search} Fetch {fetch}",
139
+ "{label} Search {search} Fetch {fetch}",
140
+ "{label} {search}S {fetch}F {cost}",
141
+ "{label} {search}S {fetch}F",
142
+ "{label} {search}S",
143
+ ]
144
+
145
+ /** Missing-key hint ladder, richest first. The env var name is always present. */
146
+ export const enHintForms: readonly string[] = [
147
+ "Set DEEPSEEK_API_KEY and restart",
148
+ "Set DEEPSEEK_API_KEY",
149
+ "Set API KEY",
150
+ "Set KEY",
151
+ ]
@@ -0,0 +1,87 @@
1
+ // The plugin's only copy source and the language resolver.
2
+ //
3
+ // The server resolves one `Language` at setup, puts it in the RPC `status`
4
+ // payload, and the tools and both sidebars render with it — so the chat output
5
+ // and the two sidebars can never disagree. Pure data and pure functions: no
6
+ // plugin or host imports, so every runtime can load this module.
7
+
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"
12
+
13
+ export type { Key, Messages }
14
+
15
+ /** One language's complete copy: strings plus the ladders that take widths. */
16
+ export interface LocalePack {
17
+ readonly messages: Messages
18
+ /** Usage-row ladder, richest first; see `en.ts`. */
19
+ readonly usageForms: readonly string[]
20
+ /** Missing-key hint ladder, richest first. */
21
+ readonly hintForms: readonly string[]
22
+ }
23
+
24
+ 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 },
28
+ }
29
+
30
+ export function pack(language: Language): LocalePack {
31
+ return PACKS[language]
32
+ }
33
+
34
+ export function messages(language: Language): Messages {
35
+ return PACKS[language].messages
36
+ }
37
+
38
+ export type Params = Readonly<Record<string, string | number>>
39
+
40
+ /**
41
+ * Fill `{name}` placeholders. A brace group that is not a supplied parameter is
42
+ * left verbatim, which keeps literals such as `{env:DEEPSEEK_API_KEY}` intact.
43
+ */
44
+ export function format(template: string, params: Params = {}): string {
45
+ return template.replace(/\{(\w+)\}/g, (match, name: string) => (name in params ? String(params[name]) : match))
46
+ }
47
+
48
+ /** One message. An unknown key falls back to the key itself, never to a crash. */
49
+ export function t(language: Language, key: Key, params?: Params): string {
50
+ const table: Readonly<Record<string, string>> = messages(language)
51
+ const template = table[key]
52
+ return template === undefined ? key : format(template, params)
53
+ }
54
+
55
+ // ---------- language resolution ----------
56
+
57
+ /** The only environment the resolver looks at; both are POSIX locale names. */
58
+ export interface LanguageEnv {
59
+ readonly LC_ALL?: string
60
+ readonly LANG?: string
61
+ }
62
+
63
+ export interface LanguagePick {
64
+ readonly language: Language
65
+ /** The raw option value when it was not a known language; the caller warns. */
66
+ readonly problem?: string
67
+ }
68
+
69
+ /**
70
+ * `options.language` wins; otherwise `LC_ALL` (which overrides `LANG` per
71
+ * POSIX), then `LANG`, then English. Prefix matching keeps `ja_JP.UTF-8` and
72
+ * `zh_CN.UTF-8` working without an alias table; anything else is English.
73
+ */
74
+ export function resolveLanguage(option: unknown, env: LanguageEnv = {}): LanguagePick {
75
+ if (option !== undefined && option !== null && option !== "") {
76
+ if (isLanguage(option)) return { language: option }
77
+ return { language: fromEnv(env), problem: String(option) }
78
+ }
79
+ return { language: fromEnv(env) }
80
+ }
81
+
82
+ function fromEnv(env: LanguageEnv): Language {
83
+ const raw = (env.LC_ALL?.trim() || env.LANG?.trim() || "").toLowerCase()
84
+ if (raw.startsWith("ja")) return "ja"
85
+ if (raw.startsWith("zh")) return "zh-CN"
86
+ return "en"
87
+ }