@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/CHANGELOG.md +25 -0
- package/LICENSE +21 -21
- package/README.md +26 -10
- package/gui-experimental.cjs +1 -1
- package/package.json +88 -87
- package/src/deepseek.ts +65 -47
- package/src/gui/index.tsx +14 -7
- package/src/gui/panel.tsx +13 -8
- package/src/i18n/en.ts +151 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/ja.ts +138 -0
- package/src/i18n/zh.ts +133 -0
- package/src/index.ts +58 -41
- package/src/rpc.ts +34 -2
- package/src/status-view.ts +59 -51
- package/src/tui/card.ts +4 -1
- package/src/tui/main.ts +16 -8
- package/src/usage.ts +32 -9
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
|
|
5
|
-
// so every mapper below is unit-testable without
|
|
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
|
-
|
|
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
|
|
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(
|
|
102
|
+
return new Error(t(language, "error.http.400", params))
|
|
99
103
|
case 401:
|
|
100
|
-
return new Error(
|
|
104
|
+
return new Error(t(language, "error.http.401", params))
|
|
101
105
|
case 402:
|
|
102
|
-
return new Error("
|
|
106
|
+
return new Error(t(language, "error.http.402", params))
|
|
103
107
|
case 403:
|
|
104
|
-
return new Error(
|
|
108
|
+
return new Error(t(language, "error.http.403", params))
|
|
105
109
|
case 404:
|
|
106
|
-
return new Error(
|
|
110
|
+
return new Error(t(language, "error.http.404", params))
|
|
107
111
|
case 408:
|
|
108
|
-
return new Error("
|
|
112
|
+
return new Error(t(language, "error.http.408", params))
|
|
109
113
|
case 413:
|
|
110
|
-
return new Error("
|
|
114
|
+
return new Error(t(language, "error.http.413", params))
|
|
111
115
|
case 429:
|
|
112
|
-
return new Error("
|
|
116
|
+
return new Error(t(language, "error.http.429", params))
|
|
113
117
|
default:
|
|
114
|
-
if (status >= 500)
|
|
115
|
-
|
|
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(
|
|
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
|
-
?
|
|
166
|
-
:
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
254
|
+
body += `\n\n${t(language, "error.fetch.truncated", { raw: raw.length, max: maxChars })}`
|
|
240
255
|
}
|
|
241
256
|
return {
|
|
242
|
-
markdown: `# ${title || url}\n
|
|
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
|
|
251
|
-
if (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
|
|
255
|
-
if (parsed.username || parsed.password) return
|
|
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
|
|
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
|
|
274
|
-
if (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: "
|
|
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
|
-
|
|
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:
|
|
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
|
|
32
|
-
on: "
|
|
33
|
-
warn: "
|
|
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
|
-
|
|
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="
|
|
99
|
-
<ctx.ui.IconButton icon="refresh" label="
|
|
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" }}
|
|
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
|
-
{
|
|
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
|
+
}
|