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