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