@qthinker/opencode-deepseek-search 0.3.0 → 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 +27 -0
- package/README.md +17 -27
- package/gui-experimental.cjs +1 -1
- package/package.json +86 -86
- package/src/deepseek.ts +82 -5
- 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 +48 -4
- 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/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 后重启",
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { Plugin } from "@opencode/plugin"
|
|
|
16
16
|
import type { Result as ToolResult, ToolContext } from "@opencode/plugin/promise/tool"
|
|
17
17
|
import type { RpcRegistration } from "@opencode/plugin/promise/rpc"
|
|
18
18
|
import {
|
|
19
|
+
deepseekBalance,
|
|
19
20
|
deepseekFetch,
|
|
20
21
|
deepseekSearch,
|
|
21
22
|
formatSearchResults,
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
type SearchResult,
|
|
26
27
|
} from "./deepseek.ts"
|
|
27
28
|
import { resolveLanguage, t } from "./i18n/index.ts"
|
|
28
|
-
import { DeepSeekRpc, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
|
|
29
|
+
import { DeepSeekRpc, type BalanceRead, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
|
|
29
30
|
import {
|
|
30
31
|
USAGE_KEY,
|
|
31
32
|
applyTally,
|
|
@@ -375,13 +376,52 @@ export default Plugin.define({
|
|
|
375
376
|
})
|
|
376
377
|
})
|
|
377
378
|
|
|
379
|
+
// ---------- account balance cache ----------
|
|
380
|
+
// The sidebars poll `status`, so the balance is cached for a minute: at most
|
|
381
|
+
// one upstream probe per TTL, shared by every caller. Failures keep the last
|
|
382
|
+
// good snapshot and back off instead of retrying on every poll.
|
|
383
|
+
const BALANCE_TTL_MS = 60_000
|
|
384
|
+
let balanceCache: { value: BalanceRead; at: number } | undefined
|
|
385
|
+
let balanceInFlight: Promise<void> | undefined
|
|
386
|
+
let lastBalanceAttemptAt = 0
|
|
387
|
+
// Never rejects: the balance is decoration on `status`, not a feature that
|
|
388
|
+
// may break it, so every failure is swallowed here.
|
|
389
|
+
const refreshBalance = (force: boolean): Promise<void> => {
|
|
390
|
+
if (!apiKey) return Promise.resolve()
|
|
391
|
+
if (balanceInFlight) return balanceInFlight
|
|
392
|
+
const startedAt = Date.now()
|
|
393
|
+
if (!force) {
|
|
394
|
+
if (balanceCache && startedAt - balanceCache.at < BALANCE_TTL_MS) return Promise.resolve()
|
|
395
|
+
if (startedAt - lastBalanceAttemptAt < BALANCE_TTL_MS) return Promise.resolve()
|
|
396
|
+
}
|
|
397
|
+
lastBalanceAttemptAt = startedAt
|
|
398
|
+
const run = (async (): Promise<void> => {
|
|
399
|
+
try {
|
|
400
|
+
const value = await deepseekBalance(apiKey, language)
|
|
401
|
+
balanceCache = { value, at: Date.now() }
|
|
402
|
+
notifyChanged()
|
|
403
|
+
} catch {
|
|
404
|
+
/* keep the previous snapshot */
|
|
405
|
+
} finally {
|
|
406
|
+
balanceInFlight = undefined
|
|
407
|
+
}
|
|
408
|
+
})()
|
|
409
|
+
balanceInFlight = run
|
|
410
|
+
return run
|
|
411
|
+
}
|
|
412
|
+
|
|
378
413
|
// ---------- RPC contract (TUI / desktop sidebars) ----------
|
|
379
414
|
// Both sidebars read `status` and call `setMode`; nothing else crosses the
|
|
380
415
|
// process boundary, so the plugin's state stays the single source of truth.
|
|
381
416
|
rpcRegistration = await ctx.rpc.register(DeepSeekRpc, {
|
|
382
417
|
// Reads through `withLedger` so it observes the same serialized chain.
|
|
383
|
-
status: async (): Promise<SearchStatus> =>
|
|
384
|
-
|
|
418
|
+
status: async (input: unknown): Promise<SearchStatus> => {
|
|
419
|
+
// A manual refresh (the sidebar's refresh button) may ask for a fresh
|
|
420
|
+
// probe; timer paths send `{}` so old servers still accept the input.
|
|
421
|
+
const force = (input as { force?: unknown } | undefined)?.force === true
|
|
422
|
+
// Kicked, not awaited: `status` must stay fast even when the probe hangs.
|
|
423
|
+
void refreshBalance(force)
|
|
424
|
+
return withLedger((ledger): SearchStatus => {
|
|
385
425
|
const todayKey = dayKey()
|
|
386
426
|
return {
|
|
387
427
|
mode,
|
|
@@ -398,8 +438,12 @@ export default Plugin.define({
|
|
|
398
438
|
month: sumDays(ledger, (k) => k.startsWith(todayKey.slice(0, 7))),
|
|
399
439
|
total: sumDays(ledger, () => true),
|
|
400
440
|
},
|
|
441
|
+
// Absent until the first successful probe; `fetchedAt` is stamped by
|
|
442
|
+
// the cache, so clients can render staleness.
|
|
443
|
+
balance: balanceCache ? { ...balanceCache.value, fetchedAt: balanceCache.at } : undefined,
|
|
401
444
|
}
|
|
402
|
-
})
|
|
445
|
+
})
|
|
446
|
+
},
|
|
403
447
|
// The input is plain JSON Schema, hence `unknown`: report, never throw.
|
|
404
448
|
setMode: async (input: unknown): Promise<ModeSwitchResult> => {
|
|
405
449
|
const parsed = readModeAction(input)
|
package/src/rpc.ts
CHANGED
|
@@ -38,11 +38,36 @@ const USAGE_COUNTS_SCHEMA = {
|
|
|
38
38
|
additionalProperties: false,
|
|
39
39
|
} as const
|
|
40
40
|
|
|
41
|
+
// Account balance snapshot in `status.balance`. `currency` deliberately has no
|
|
42
|
+
// enum: a value the server could not normalize must not fail the whole payload
|
|
43
|
+
// at output-schema validation time.
|
|
44
|
+
const BALANCE_SCHEMA = {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
isAvailable: { type: "boolean" },
|
|
48
|
+
currency: { type: "string" },
|
|
49
|
+
totalBalance: { type: "string" },
|
|
50
|
+
grantedBalance: { type: "string" },
|
|
51
|
+
toppedUpBalance: { type: "string" },
|
|
52
|
+
fetchedAt: { type: "number" },
|
|
53
|
+
},
|
|
54
|
+
required: ["isAvailable", "currency", "totalBalance", "grantedBalance", "toppedUpBalance", "fetchedAt"],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
} as const
|
|
57
|
+
|
|
41
58
|
export const DeepSeekRpc = {
|
|
42
59
|
id: DEEPSEEK_RPC_ID,
|
|
43
60
|
methods: {
|
|
44
61
|
status: {
|
|
45
|
-
input: {
|
|
62
|
+
input: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
// Only a manual refresh sends `{ force: true }`; timer paths MUST send
|
|
66
|
+
// `{}`, because older servers reject unknown input properties.
|
|
67
|
+
force: { type: "boolean" },
|
|
68
|
+
},
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
46
71
|
output: {
|
|
47
72
|
type: "object",
|
|
48
73
|
properties: {
|
|
@@ -67,6 +92,9 @@ export const DeepSeekRpc = {
|
|
|
67
92
|
required: ["today", "month", "total"],
|
|
68
93
|
additionalProperties: false,
|
|
69
94
|
},
|
|
95
|
+
// Optional like `counts`: absent while the first probe is in flight, on
|
|
96
|
+
// servers without the endpoint, or when the API key is missing.
|
|
97
|
+
balance: BALANCE_SCHEMA,
|
|
70
98
|
},
|
|
71
99
|
required: ["mode", "source", "patched", "hasKey", "pricePerThousand", "today", "month", "total"],
|
|
72
100
|
additionalProperties: false,
|
|
@@ -107,6 +135,24 @@ export interface UsageCounts {
|
|
|
107
135
|
costMicro: number
|
|
108
136
|
}
|
|
109
137
|
|
|
138
|
+
/**
|
|
139
|
+
* DeepSeek account balance as reported by `GET /user/balance`.
|
|
140
|
+
*
|
|
141
|
+
* `fetchedAt` is epoch milliseconds attached by the server when it caches the
|
|
142
|
+
* snapshot, so sidebars can show staleness without trusting client clocks.
|
|
143
|
+
*/
|
|
144
|
+
export interface BalanceStatus {
|
|
145
|
+
isAvailable: boolean
|
|
146
|
+
currency: string
|
|
147
|
+
totalBalance: string
|
|
148
|
+
grantedBalance: string
|
|
149
|
+
toppedUpBalance: string
|
|
150
|
+
fetchedAt: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The wire shape of a balance probe, before the cache stamps `fetchedAt`. */
|
|
154
|
+
export type BalanceRead = Omit<BalanceStatus, "fetchedAt">
|
|
155
|
+
|
|
110
156
|
export interface SearchStatus {
|
|
111
157
|
mode: "deepseek" | "native"
|
|
112
158
|
source: "override" | "config"
|
|
@@ -124,6 +170,8 @@ export interface SearchStatus {
|
|
|
124
170
|
language: Language
|
|
125
171
|
/** Structured counters, so each surface can format for its own width. Optional for older servers. */
|
|
126
172
|
counts?: { today: UsageCounts; month: UsageCounts; total: UsageCounts }
|
|
173
|
+
/** Last cached balance probe. Absent until the first successful fetch. */
|
|
174
|
+
balance?: BalanceStatus
|
|
127
175
|
}
|
|
128
176
|
|
|
129
177
|
export interface ModeSwitchResult {
|
|
@@ -151,10 +199,23 @@ function readUsageCounts(value: unknown): UsageCounts | undefined {
|
|
|
151
199
|
return { search, fetch, empty, failed, costMicro } as UsageCounts
|
|
152
200
|
}
|
|
153
201
|
|
|
202
|
+
/** Strict read of a cached balance snapshot: all six fields, currency normalized. */
|
|
203
|
+
function readBalance(value: unknown): BalanceStatus | undefined {
|
|
204
|
+
if (!isRecord(value)) return undefined
|
|
205
|
+
const { isAvailable, currency, totalBalance, grantedBalance, toppedUpBalance, fetchedAt } = value
|
|
206
|
+
if (typeof isAvailable !== "boolean") return undefined
|
|
207
|
+
if (currency !== "CNY" && currency !== "USD") return undefined
|
|
208
|
+
for (const text of [totalBalance, grantedBalance, toppedUpBalance]) {
|
|
209
|
+
if (typeof text !== "string" || text.length === 0) return undefined
|
|
210
|
+
}
|
|
211
|
+
if (typeof fetchedAt !== "number" || !Number.isFinite(fetchedAt) || fetchedAt < 0) return undefined
|
|
212
|
+
return { isAvailable, currency, totalBalance, grantedBalance, toppedUpBalance, fetchedAt } as BalanceStatus
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
/** Defensive read of an RPC `status` result. Never throws. */
|
|
155
216
|
export function readStatus(raw: unknown): SearchStatus | undefined {
|
|
156
217
|
if (!isRecord(raw)) return undefined
|
|
157
|
-
const { mode, source, patched, hasKey, pricePerThousand, today, month, total, language, counts } = raw
|
|
218
|
+
const { mode, source, patched, hasKey, pricePerThousand, today, month, total, language, counts, balance } = raw
|
|
158
219
|
if (!isMode(mode)) return undefined
|
|
159
220
|
if (source !== "override" && source !== "config") return undefined
|
|
160
221
|
if (typeof patched !== "boolean" || typeof hasKey !== "boolean") return undefined
|
|
@@ -180,6 +241,10 @@ export function readStatus(raw: unknown): SearchStatus | undefined {
|
|
|
180
241
|
const totalCounts = readUsageCounts(counts.total)
|
|
181
242
|
if (todayCounts && monthCounts && totalCounts) status.counts = { today: todayCounts, month: monthCounts, total: totalCounts }
|
|
182
243
|
}
|
|
244
|
+
// A missing balance stays absent (no probe yet, no key, older server); a
|
|
245
|
+
// malformed one is dropped without failing the rest of the payload.
|
|
246
|
+
const balanceStatus = readBalance(balance)
|
|
247
|
+
if (balanceStatus) status.balance = balanceStatus
|
|
183
248
|
return status
|
|
184
249
|
}
|
|
185
250
|
|