@qthinker/opencode-deepseek-search 0.1.1 → 0.2.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 +53 -0
- package/README.md +64 -11
- package/gui-experimental.cjs +1 -0
- package/package.json +38 -9
- package/src/gui/index.tsx +137 -0
- package/src/gui/panel.tsx +154 -0
- package/src/index.ts +79 -21
- package/src/rpc.ts +160 -0
- package/src/status-view.ts +368 -0
- package/src/tui/card.ts +186 -0
- package/src/tui/main.ts +143 -0
- package/src/usage.ts +19 -0
- package/tui.ts +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
|
+
and the version numbers follow [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.2.0] - 2026-11-02
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **TUI sidebar card** (`sidebar.content`): takeover state, today / month / lifetime usage, and a one-click
|
|
11
|
+
enable/disable at the right edge of the title. Everything degrades with the sidebar's *measured* width — full
|
|
12
|
+
`Search` / `Fetch` words when they fit, abbreviations only when they do not, never a wrapped row. `/deepseek`
|
|
13
|
+
opens the same actions as a menu, and the card also appears when the takeover is disabled.
|
|
14
|
+
- **Desktop side panel**: a DeepSeek tab in a session's side region, listed in the side region's *New tab* page and
|
|
15
|
+
its `+` menu, plus the `DeepSeek: 打开统计面板` command. Ships as the prebuilt CommonJS `gui-experimental.cjs`
|
|
16
|
+
bundle the desktop app requires.
|
|
17
|
+
- **RPC contract between the halves** (`status`, `setMode`, a `changed` event), so both sidebars read the same ledger
|
|
18
|
+
and the same stored switch as the tools. There is no second source of truth.
|
|
19
|
+
- **`counts` in the `status` payload** (optional): structured today / month / lifetime counters, so each surface can
|
|
20
|
+
lay the numbers out for its own width. Older clients keep working off the pre-formatted strings.
|
|
21
|
+
- **Packaging regression test**: `package.json` must stay free of `exports`, and the root entries (`index.ts`,
|
|
22
|
+
`tui.ts`, `gui-experimental.cjs`) must keep resolving for both directory and installed-package resolution.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `peerDependencies["@opencode/plugin"]` is now `>=2.2.5`; `@opentui/core`, `@opentui/solid`, `solid-js` and
|
|
27
|
+
`@opencode/theme` are declared as optional peers, because the host provides them.
|
|
28
|
+
- `npm run typecheck` covers the GUI project as well; `prepublishOnly` builds the GUI bundle; CI builds and checks
|
|
29
|
+
the bundle before running the tests, and the release workflow does the same before publishing.
|
|
30
|
+
- Docs: which file enables which half (`opencode.json(c)` for server + desktop, `cli.json` for the TUI), the reload
|
|
31
|
+
rules for each half, and troubleshooting entries for the three “nothing shows up” cases.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- The TUI card no longer wraps its usage rows in a narrow sidebar, and its action row can no longer be clipped.
|
|
36
|
+
- `status` RPC calls must send an empty object; an omitted input was rejected as `rpc.invalid_input` (400).
|
|
37
|
+
- The desktop panel now registers a `session.panel` menu item, so it appears in the side region's launcher instead of
|
|
38
|
+
being reachable only from the command palette.
|
|
39
|
+
- Removed the UTF-8 BOM from `package.json`, which made Vite's JSON parse fail while building the GUI bundle.
|
|
40
|
+
|
|
41
|
+
## [0.1.1] - 2026-11-01
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- npm landing page uses the English README, plus badges; repository URLs aligned with the public repo.
|
|
46
|
+
|
|
47
|
+
## [0.1.0] - 2026-11-01
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- First release: the built-in `websearch` tool routed to DeepSeek Search, `deepseek_fetch` (clean Markdown),
|
|
52
|
+
`deepseek_usage` (local counts and estimated cost), `deepseek_mode` (persisted backend switch), and the
|
|
53
|
+
`tools/switch-mode.ps1` helper.
|
package/README.md
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
[](https://opencode.ai)
|
|
7
7
|
|
|
8
8
|
DeepSeek Search & Fetch as an OpenCode V2 plugin: **the built-in `websearch` tool routes to DeepSeek**, plus a clean
|
|
9
|
-
Markdown fetch tool, local usage/cost stats,
|
|
9
|
+
Markdown fetch tool, local usage/cost stats, a **persisted** backend switch, and **TUI + desktop sidebars** that show
|
|
10
|
+
the state and the numbers and flip the takeover with one click.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
Ways back to native search: the sidebar switch, the `mode` option, a `deepseek_mode` call, or
|
|
12
13
|
`tools/switch-mode.ps1` from the repo.
|
|
13
14
|
中文说明见 [docs/README.zh-CN.md](https://github.com/QThinkerJR/opencode-deepseek-search/blob/main/docs/README.zh-CN.md)。
|
|
14
15
|
|
|
@@ -23,6 +24,8 @@ runtime**, and reports **local usage and estimated cost**.
|
|
|
23
24
|
- `opencode >= 2.2.5` (V2 plugin API)
|
|
24
25
|
- Node.js `>= 22.18` (the plugin ships TypeScript sources)
|
|
25
26
|
- A DeepSeek API key with Search/Fetch access
|
|
27
|
+
- For the sidebars: nothing extra — the TUI and the desktop app provide the renderer (`@opentui/*`, `solid-js`) and
|
|
28
|
+
load the plugin's two halves from the same package
|
|
26
29
|
|
|
27
30
|
## Install
|
|
28
31
|
|
|
@@ -42,6 +45,8 @@ Then add the plugin to `opencode.jsonc`:
|
|
|
42
45
|
}
|
|
43
46
|
```
|
|
44
47
|
|
|
48
|
+
> **Which file loads which half.** The server plugin and the desktop panel are read from `opencode.json(c)` — the desktop app reads only that file, and a **global** `~/.config/opencode/opencode.jsonc` entry is what makes the panel appear in every window. The TUI half is read from `cli.json` (`"plugins": [...]`). So a plugin is enabled per half: list it in each file whose half you want, whether it comes from npm (by package name) or from a local directory (by path).
|
|
49
|
+
|
|
45
50
|
With options (recommended — this is where `mode` and friends live):
|
|
46
51
|
|
|
47
52
|
```jsonc
|
|
@@ -64,13 +69,17 @@ Local development points at the repository directory (a config entry must be a *
|
|
|
64
69
|
```jsonc
|
|
65
70
|
{
|
|
66
71
|
"plugins": [
|
|
67
|
-
{ "package": "D:/
|
|
72
|
+
{ "package": "D:/repos/opencode-deepseek-search", "options": { "mode": "deepseek" } }
|
|
68
73
|
]
|
|
69
74
|
}
|
|
70
75
|
```
|
|
71
76
|
|
|
72
|
-
`opencode plugin
|
|
73
|
-
|
|
77
|
+
`opencode plugin add <package-or-directory>` does the same edit for you (it writes the **global** config), and
|
|
78
|
+
`opencode plugin list` should then show `deepseek-search` with source `local` or the package name.
|
|
79
|
+
|
|
80
|
+
Reload rules: the **server half** re-runs in place when you save a source file — no `/reload`, no service restart.
|
|
81
|
+
The other two halves are clients: restart the **TUI**, and reload the **desktop window**, to pick up a change to
|
|
82
|
+
`tui.ts` / `gui-experimental.cjs` (and run `npm run build:gui` first when working from a clone).
|
|
74
83
|
|
|
75
84
|
## Options
|
|
76
85
|
|
|
@@ -97,10 +106,11 @@ and `need_content` is never requested.
|
|
|
97
106
|
|
|
98
107
|
## Switching backends
|
|
99
108
|
|
|
100
|
-
###
|
|
109
|
+
### Ways to switch
|
|
101
110
|
|
|
102
111
|
| Way | How | Persisted | Use it for |
|
|
103
112
|
|---|---|---|---|
|
|
113
|
+
| Sidebar | Click the action on the TUI card (`[停用]` / `[启用]`), or the panel's button | Yes (plugin storage `mode/v1`) | While you are working |
|
|
104
114
|
| Config | Edit `options.mode`; applies on save | Yes (in the config file) | The long-term default |
|
|
105
115
|
| Tool | Ask the model to call `deepseek_mode`, or call it yourself | Yes (plugin storage `mode/v1`) | Switching mid-session without touching files |
|
|
106
116
|
| Script | `.\tools\switch-mode.ps1 native` | Yes (the script edits the config) | Quick switch from a terminal, no editor |
|
|
@@ -139,6 +149,33 @@ explicitly with `providerID: "deepseek"` or config `"websearch": { "provider": "
|
|
|
139
149
|
> provider" form never appears in deepseek mode. If a version does not expose that tool, `deepseek_mode` says it cannot
|
|
140
150
|
> switch at runtime — use `websearch.provider` in the config instead.
|
|
141
151
|
|
|
152
|
+
## TUI and desktop sidebars
|
|
153
|
+
|
|
154
|
+
The plugin also shows up where you work, and can be switched without the model or the config file:
|
|
155
|
+
|
|
156
|
+
| Surface | What you get | How to switch |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| TUI | A DeepSeek block appended to the session sidebar: state, then 今日 / 本月 / 累计 rows (`Search` · `Fetch` · `¥` estimate); it degrades to abbreviations and then to fewer columns as the sidebar narrows | Click the action at the right of the title, or run the `/deepseek` slash command for a menu |
|
|
159
|
+
| Desktop | A **DeepSeek** tab in a session's side region, listed in the side region's *New tab* page and its `+` menu, and openable with the `DeepSeek: 打开统计面板` command | The panel's button, plus a refresh control |
|
|
160
|
+
|
|
161
|
+
- Both surfaces read the same ledger and the same stored switch as `deepseek_usage` / `deepseek_mode`: a single
|
|
162
|
+
server-side source, reached over the plugin's own RPC, so the numbers never diverge between TUI and desktop.
|
|
163
|
+
- **"Stop" means hand the built-in `websearch` back to your configured backend** (exactly `deepseek_mode({"mode": "native"})`).
|
|
164
|
+
It is not an uninstall: `deepseek_fetch`, `deepseek_usage` and `deepseek_mode` stay registered, and the state is
|
|
165
|
+
persisted in `mode/v1` like any other switch.
|
|
166
|
+
- The action row always says what a click does. When no API key is configured there is no action to take, so it shows
|
|
167
|
+
the fix instead (`DEEPSEEK_API_KEY`); when the built-in tool could not be patched it reports 未接管 rather than
|
|
168
|
+
pretending to be enabled.
|
|
169
|
+
- Stats refresh on a `changed` event emitted after every tally and every switch, with a 10-second reread as a
|
|
170
|
+
fallback in both surfaces.
|
|
171
|
+
|
|
172
|
+
The desktop half ships as a prebuilt CommonJS bundle (`gui-experimental.cjs`). Published packages include it; if you
|
|
173
|
+
run the plugin from a clone, build it once — the TUI half needs no build and loads from TypeScript like the server half:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm install && npm run build:gui
|
|
177
|
+
```
|
|
178
|
+
|
|
142
179
|
## Usage and cost
|
|
143
180
|
|
|
144
181
|
| Counter | Counted when | Billed |
|
|
@@ -181,16 +218,21 @@ contain text that tries to steer the model.
|
|
|
181
218
|
| `429` rate limited | Backed off once already; retry later |
|
|
182
219
|
| `403` / `404` on fetch | The site blocks extraction or the page has no readable text; try the built-in `webfetch` |
|
|
183
220
|
| Search still hits the native backend | `mode` or the persisted switch is `native`; if both are `deepseek`, the built-in `websearch` tool was not found — use config `"websearch": { "provider": "deepseek" }` |
|
|
221
|
+
| TUI sidebar has no DeepSeek card | The TUI half is enabled in `cli.json` (`"plugins": [...]`), not in `opencode.jsonc`; add the path/package there and restart the TUI |
|
|
222
|
+
| Desktop has no DeepSeek entry in the *New tab* list | The desktop reads plugins from the **global** `opencode.jsonc`, and its bundle must exist — run `npm run build:gui` when working from a clone, then reload the window |
|
|
223
|
+
| Sidebar says `读取失败` | The half cannot reach the RPC: check `opencode plugin list` reports `deepseek-search` as active, then look for `/api/rpc/deepseek-search/status` in `~/.local/share/opencode/log/opencode.log`. A `400 rpc.invalid_input` means a half and the server disagree on the contract — reload that client |
|
|
184
224
|
|
|
185
225
|
## Local development
|
|
186
226
|
|
|
187
227
|
```powershell
|
|
188
|
-
cd D:\
|
|
228
|
+
cd D:\repos\opencode-deepseek-search
|
|
189
229
|
npm install
|
|
190
|
-
npm run typecheck # tsc --noEmit
|
|
230
|
+
npm run typecheck # tsc --noEmit, both the server/TUI config and the GUI config
|
|
191
231
|
npm test # node --test, pure functions, zero cost
|
|
232
|
+
npm run build:gui # src/gui -> gui-experimental.cjs (the desktop half's bundle)
|
|
233
|
+
node --experimental-strip-types scripts/check-gui-bundle.ts # bundle shape check, run in CI
|
|
192
234
|
npm run smoke # real API smoke; skipped without DEEPSEEK_API_KEY
|
|
193
|
-
npm pack --dry-run # audit the publish artifacts
|
|
235
|
+
npm pack --dry-run # audit the publish artifacts — run build:gui first, `pack` does not build the bundle
|
|
194
236
|
```
|
|
195
237
|
|
|
196
238
|
One-command switch (repo-only helper; edits `options.mode` in `opencode.jsonc`, backing it up to `opencode.jsonc.bak-switch`):
|
|
@@ -210,20 +252,27 @@ powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\switch-mode.ps1 nati
|
|
|
210
252
|
Shorter, via an alias in `$PROFILE`:
|
|
211
253
|
|
|
212
254
|
```powershell
|
|
213
|
-
function dsmode { & "D:\
|
|
255
|
+
function dsmode { & "D:\repos\opencode-deepseek-search\tools\switch-mode.ps1" @args }
|
|
214
256
|
```
|
|
215
257
|
|
|
216
258
|
Layout:
|
|
217
259
|
|
|
218
260
|
```
|
|
219
261
|
index.ts # directory-plugin entry, re-exports src/index.ts
|
|
262
|
+
tui.ts # TUI entry, re-exports src/tui/main.ts
|
|
220
263
|
src/index.ts # plugin: options, registration, websearch patch, three tools
|
|
221
264
|
src/deepseek.ts # HTTP core: timeout/cancel, single backoff retry, Chinese errors, mapping
|
|
222
265
|
src/usage.ts # pure ledger: micro-CNY pricing, 60-day prune, defensive parse, formatting
|
|
266
|
+
src/rpc.ts # RPC contract shared by the server, TUI and desktop halves
|
|
267
|
+
src/status-view.ts # pure view model both sidebars derive their copy from
|
|
268
|
+
src/tui/ # TUI half: card element tree + plugin wiring
|
|
269
|
+
src/gui/ # desktop half: side panel component + plugin wiring
|
|
270
|
+
vite.config.ts # builds src/gui into the root gui-experimental.cjs bundle
|
|
223
271
|
test/*.test.ts # pure-function tests
|
|
224
272
|
scripts/smoke.ts # real-API smoke (5 steps)
|
|
273
|
+
scripts/check-gui-bundle.ts # bundle shape check, run in CI after build:gui
|
|
225
274
|
tools/switch-mode.ps1 # terminal backend switch
|
|
226
|
-
.github/workflows/ # ci (typecheck + test) / release
|
|
275
|
+
.github/workflows/ # ci (build + typecheck + test + bundle check) / release
|
|
227
276
|
```
|
|
228
277
|
|
|
229
278
|
Two packaging traps (already avoided — do not reintroduce them):
|
|
@@ -233,6 +282,10 @@ Two packaging traps (already avoided — do not reintroduce them):
|
|
|
233
282
|
name, and when the resolved entrypoint falls outside the directory it is **silently dropped** (no log line,
|
|
234
283
|
it just disappears from `plugin list`).
|
|
235
284
|
|
|
285
|
+
Both hold for every half of the plugin: `index.ts`, `tui.ts` and `gui-experimental.cjs` sit at the repository root,
|
|
286
|
+
which is what both the directory and the installed-package resolution paths find. `test/packaging.test.ts` fails if
|
|
287
|
+
`exports` comes back or a root entry stops resolving.
|
|
288
|
+
|
|
236
289
|
Source edits hot-reload on save; verify with `opencode plugin list` plus one real search.
|
|
237
290
|
|
|
238
291
|
## License
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const o=require("solid-js/web"),_=require("@opencode/plugin/gui"),y=require("solid-js"),Y="deepseek-search",k={type:"object",properties:{search:{type:"integer"},fetch:{type:"integer"},empty:{type:"integer"},failed:{type:"integer"},costMicro:{type:"integer"}},required:["search","fetch","empty","failed","costMicro"],additionalProperties:!1},j={id:Y,methods:{status:{input:{type:"object",properties:{},additionalProperties:!1},output:{type:"object",properties:{mode:{type:"string",enum:["deepseek","native"]},source:{type:"string",enum:["override","config"]},patched:{type:"boolean"},hasKey:{type:"boolean"},pricePerThousand:{type:"number"},today:{type:"string"},month:{type:"string"},total:{type:"string"},counts:{type:"object",properties:{today:k,month:k,total:k},required:["today","month","total"],additionalProperties:!1}},required:["mode","source","patched","hasKey","pricePerThousand","today","month","total"],additionalProperties:!1}},setMode:{input:{type:"object",properties:{mode:{type:"string",enum:["deepseek","native","config"]}},required:["mode"],additionalProperties:!1},output:{type:"object",properties:{ok:{type:"boolean"},mode:{type:"string"},message:{type:"string"}},required:["ok","mode","message"],additionalProperties:!1}}},events:{changed:{schema:{type:"object",properties:{}}}}};function E(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}function z(t){return t==="deepseek"||t==="native"}function x(t){if(!E(t))return;const{search:e,fetch:r,empty:n,failed:i,costMicro:s}=t;for(const d of[e,r,n,i])if(typeof d!="number"||!Number.isInteger(d)||d<0)return;if(!(typeof s!="number"||!Number.isFinite(s)||s<0))return{search:e,fetch:r,empty:n,failed:i,costMicro:s}}function B(t){if(!E(t))return;const{mode:e,source:r,patched:n,hasKey:i,pricePerThousand:s,today:d,month:u,total:p,counts:a}=t;if(!z(e)||r!=="override"&&r!=="config"||typeof n!="boolean"||typeof i!="boolean"||typeof s!="number"||!Number.isFinite(s)||typeof d!="string"||typeof u!="string"||typeof p!="string")return;const c={mode:e,source:r,patched:n,hasKey:i,pricePerThousand:s,today:d,month:u,total:p};if(E(a)){const f=x(a.today),m=x(a.month),h=x(a.total);f&&m&&h&&(c.counts={today:f,month:m,total:h})}return c}function q(t){if(!E(t))return;const{ok:e,mode:r,message:n}=t;if(!(typeof e!="boolean"||typeof r!="string"||typeof n!="string"))return{ok:e,mode:r,message:n}}const U="DeepSeek search/fetch",D="DeepSeek",H=2,V=1,W=24,N=["today","month","total"],O={today:"今日",month:"本月",total:"累计"};function R(t){const e=t.codePointAt(0)??0;return e===0?0:e>=4352&&e<=4447||e>=11904&&e<=42191||e>=44032&&e<=55203||e>=63744&&e<=64255||e>=65040&&e<=65049||e>=65072&&e<=65135||e>=65280&&e<=65376||e>=65504&&e<=65510||e>=127744&&e<=129791||e>=131072&&e<=262141?2:1}function b(t){let e=0;for(const r of t)e+=R(r);return e}function S(t,e){if(e<1)return"";if(b(t)<=e)return t;let r="",n=0;for(const i of t){const s=R(i);if(n+s>e-1)return`${r}…`;r+=i,n+=s}return r}function G(t,e){const r=t.replace(/\s+/g," ").trim();return S(r||"读取失败",e)}function L(t){let r=(Number.isFinite(t)&&t>0?t/1e6:0).toFixed(3);for(;r.endsWith("0")&&r.length>4;)r=r.slice(0,-1);return`¥${r}`}function J(t){const e=N.map(r=>t[r]);return{search:Math.max(...e.map(r=>String(r.search).length)),fetch:Math.max(...e.map(r=>String(r.fetch).length)),cost:Math.max(...e.map(r=>L(r.costMicro).length))}}function X(t,e,r,n){const i=O[t],s=String(e.search).padStart(r.search," "),d=String(e.fetch).padStart(r.fetch," "),u=L(e.costMicro).padStart(r.cost," "),p=[`${i} Search ${s} Fetch ${d} ${u}`,`${i} Search ${s} Fetch ${d} ${u}`,`${i} Search ${s} Fetch ${d}`,`${i} Search ${s} Fetch ${d}`,`${i} ${s}S ${d}F ${u}`,`${i} ${s}S ${d}F`,`${i} ${s}S`];for(const a of p)if(b(a)<=n)return a;return b(i)<=n?i:S(i,n)}function Q(t,e,r){const n=O[t],i=`${n} ${e}`;if(b(i)<=r)return i;const s=e.split(" · ").filter(u=>!u.startsWith("¥")&&!u.startsWith("空/失")),d=[`${n} ${s.join(" ")}`.trimEnd(),`${n} ${s[0]??""}`.trimEnd(),n];for(const u of d)if(b(u)<=r)return u;return S(n,r)}function Z(t,e){if(e===Number.POSITIVE_INFINITY)return[`今日 ${t.today}`,`本月 ${t.month}`,`累计 ${t.total}`];const r=t.counts;if(r){const n=J(r);return N.map(i=>X(i,r[i],n,e))}return N.map(n=>Q(n,t[n],e))}function ee(t,e){const r=`[${t}]`;if(e===Number.POSITIVE_INFINITY||b(r)<=e)return r;const n=e-2;return n>=1?`[${S(t,n)}]`:S(r,e)}function C(t,e,r,n){const i=(p,a)=>{if(r===Number.POSITIVE_INFINITY)return Number.POSITIVE_INFINITY;const c=p?H:0,f=n===void 0||a?0:n+V;return Math.max(0,r-c-f)},s=[`${U} · ${t}`,`${D} · ${t}`,`${D} · ${e}`,t,e],d=n===void 0?[!1]:[!1,!0];for(const p of s)for(const a of d)for(const c of[!0,!1])if(b(p)<=i(c,a))return{title:p,glyph:c,actionBelow:a};const u=n!==void 0;return{title:S(e,i(!1,u)),glyph:!1,actionBelow:u}}function te(t){const e=["设置 DEEPSEEK_API_KEY 后重启","设置 API KEY 后重启","设置 API KEY","设置 KEY"];if(t===Number.POSITIVE_INFINITY)return e[0];for(const r of e)if(b(r)<=t)return r;return S(e[e.length-1],t)}function P(t,e,r,n){const i=ee(r,n);return{...C(t,e,n,b(i)),action:i}}function re(t){const{status:e,error:r}=t,n=t.maxColumns??W,i=e===void 0?[]:Z(e,n);if(e===void 0)return r===void 0||r.trim()===""?{...P("正在读取…","正在读取…","重试",n),hint:void 0,clickable:!0,lines:i,tone:"idle"}:{...P("读取失败","读取失败","重试",n),hint:void 0,clickable:!0,lines:[G(r,n)],tone:"error"};if(e.patched&&e.hasKey)return{...P("已启用","已启用","停用",n),hint:void 0,clickable:!0,lines:i,tone:"on"};if(!e.patched&&e.mode==="deepseek")return{...P("未接管内置 websearch","未接管","交还",n),hint:void 0,clickable:!0,lines:i,tone:"warn"};if(e.mode==="native")return{...P("已停用","已停用","启用",n),hint:void 0,clickable:!0,lines:i,tone:"off"};const s=te(n);return{...C("未配置 API Key","缺 API Key",n,void 0),action:s,hint:s,clickable:!1,lines:i,tone:"error"}}var F=o.template("<span>"),ne=o.template("<span style=opacity:0.6;font-weight:400>"),oe=o.template("<span style=opacity:0.3;font-weight:400> · "),ie=o.template("<code style=font-size:0.94em>DEEPSEEK_API_KEY"),se=o.template('<p style="margin:16px 0 0;font-size:12px;line-height:1.6;opacity:0.68;overflow-wrap:anywhere">'),ae=o.template("<div style=margin-top:18px>"),le=o.template('<div style="display:flex;flex-direction:column;height:100%;min-width:0;box-sizing:border-box;padding:16px 16px 24px;overflow-y:auto;overflow-x:hidden"><header style=display:flex;align-items:flex-start;gap:8px><span aria-hidden=true style=flex-shrink:0;font-size:10px;line-height:20px;font-weight:700></span><h2 aria-live=polite style=flex:1;min-width:0;margin:0;font-size:13px;font-weight:600;line-height:20px;overflow-wrap:anywhere>'),ce=o.template('<p style="margin:14px 0 0;font-size:12px;line-height:1.6;opacity:0.7;overflow-wrap:anywhere">'),de=o.template("<div style=display:flex;align-items:center;gap:8px;margin-top:14px;opacity:0.7><span style=font-size:12px>正在读取用量…"),ue=o.template('<dl style="margin:16px 0 0">'),fe=o.template('<div style="display:flex;align-items:baseline;gap:12px;padding:10px 0"><dt style=flex-shrink:0;font-size:11px;letter-spacing:0.08em;line-height:1.9;opacity:0.62></dt><dd style=margin:0;margin-left:auto;min-width:0;display:flex;flex-wrap:wrap;justify-content:flex-end;column-gap:6px;row-gap:2px;font-size:13px;line-height:1.55;font-variant-numeric:tabular-nums;text-align:right>'),pe=o.template("<span style=min-width:0;overflow-wrap:anywhere>");const he={on:"●",off:"○",warn:"◐",error:"!",idle:"…"},ye={dark:{on:"#63d19a",warn:"#e0ad4f",error:"#ee7b74",off:"#8b9198",idle:"#8b9198",rule:"rgba(255,255,255,0.14)"},light:{on:"#1e7a4c",warn:"#8a5f08",error:"#b03931",off:"#6b7076",idle:"#6b7076",rule:"rgba(0,0,0,0.13)"}},me={on:"停用(交还原生搜索)",off:"启用(由 DeepSeek 接管)",warn:"交还原生搜索",error:"重试",idle:"重试"},ge={on:"arrow-undo-down",off:"globe",warn:"arrow-undo-down",error:"refresh",idle:"refresh"};function ve(t,e){const r=t.indexOf(" · ");return r<0?(()=>{var n=F();return o.setStyleProperty(n,"color",e),o.insert(n,t),n})():[(()=>{var n=ne();return o.insert(n,()=>t.slice(0,r)),n})(),oe(),(()=>{var n=F();return o.setStyleProperty(n,"color",e),o.insert(n,()=>t.slice(r+3)),n})()]}function be(t){const e=t.indexOf(" ");return e<0?{label:t,chunks:[]}:{label:t.slice(0,e),chunks:t.slice(e+1).split(" · ")}}function $e(t,e){const r="DEEPSEEK_API_KEY",n=t.indexOf(r);return n<0?t:[o.memo(()=>t.slice(0,n)),(()=>{var i=ie();return o.setStyleProperty(i,"font-family",e),i})(),o.memo(()=>t.slice(n+r.length))]}function Se(t){const e=_.usePlugin(),r=y.createMemo(()=>re({status:t.status,error:t.error,maxColumns:1/0})),n=y.createMemo(()=>ye[e.appearance.mode()]),i=()=>e.appearance.font("mono");return y.onMount(()=>{const s=setInterval(()=>t.onRefresh(),1e4);y.onCleanup(()=>clearInterval(s))}),(()=>{var s=le(),d=s.firstChild,u=d.firstChild,p=u.nextSibling;return o.insert(u,()=>he[r().tone]),o.insert(p,()=>ve(r().title,n()[r().tone])),o.insert(d,o.createComponent(e.ui.Tooltip,{value:"刷新用量",placement:"bottom",get children(){return o.createComponent(e.ui.IconButton,{icon:"refresh",label:"刷新用量",size:"small",get onClick(){return t.onRefresh}})}}),null),o.insert(s,(()=>{var a=o.memo(()=>t.status===void 0);return()=>a()?o.memo(()=>r().lines.length>0)()?(()=>{var c=ce();return o.insert(c,()=>r().lines[0]),c})():(()=>{var c=de(),f=c.firstChild;return o.insert(c,o.createComponent(e.ui.Loader,{size:"small"}),f),c})():(()=>{var c=ue();return o.insert(c,o.createComponent(y.For,{get each(){return r().lines},children:(f,m)=>{const h=be(f);return(()=>{var l=fe(),g=l.firstChild,T=g.nextSibling;return o.insert(g,()=>h.label),o.insert(T,o.createComponent(y.For,{get each(){return h.chunks},children:(v,I)=>(()=>{var $=pe();return o.insert($,v,null),o.insert($,()=>I()<h.chunks.length-1?" ·":"",null),o.effect(w=>{var M=v.startsWith("¥")?500:400,A=v.startsWith("¥")?1:.8;return M!==w.e&&o.setStyleProperty($,"font-weight",w.e=M),A!==w.t&&o.setStyleProperty($,"opacity",w.t=A),w},{e:void 0,t:void 0}),$})()})),o.effect(v=>{var I=m()>0?`1px solid ${n().rule}`:void 0,$=i();return I!==v.e&&o.setStyleProperty(l,"border-top",v.e=I),$!==v.t&&o.setStyleProperty(T,"font-family",v.t=$),v},{e:void 0,t:void 0}),l})()}})),o.effect(f=>{var m=`1px solid ${n().rule}`,h=`1px solid ${n().rule}`;return m!==f.e&&o.setStyleProperty(c,"border-top",f.e=m),h!==f.t&&o.setStyleProperty(c,"border-bottom",f.t=h),f},{e:void 0,t:void 0}),c})()})(),null),o.insert(s,o.createComponent(y.Show,{get when(){return!r().clickable},get children(){var a=se();return o.insert(a,()=>$e(r().action,i())),a}}),null),o.insert(s,o.createComponent(y.Show,{get when(){return o.memo(()=>!!r().clickable)()&&r().tone!=="idle"},get children(){var a=ae();return o.insert(a,o.createComponent(e.ui.Button,{get variant(){return r().tone==="off"||r().tone==="error"?"contrast":"neutral"},get icon(){return ge[r().tone]},get disabled(){return t.busy},get busy(){return t.busy},get onClick(){return t.onToggle},get children(){return me[r().tone]}})),a}}),null),o.effect(a=>{var c=t.busy||void 0,f=n()[r().tone];return c!==a.e&&o.setAttribute(s,"aria-busy",a.e=c),f!==a.t&&o.setStyleProperty(u,"color",a.t=f),a},{e:void 0,t:void 0}),s})()}const K={id:"main",title:"DeepSeek"},we=80,Pe=_.Plugin.define({setup(t){const e=t.client.rpc(j),[r,n]=y.createSignal(),[i,s]=y.createSignal(),[d,u]=y.createSignal(!1),p=l=>(l instanceof Error?l.message:String(l)).slice(0,we);async function a(){try{const l=B(await e.status({}));l?(n(l),s(void 0)):s("状态数据格式不符")}catch(l){s(p(l))}}async function c(l){u(!0);try{const g=q(await e.setMode({mode:l}));g?g.ok||t.toast.show({title:"DeepSeek",description:g.message,variant:"error"}):t.toast.show({title:"DeepSeek",description:"模式切换结果格式不符",variant:"error"})}catch(g){t.toast.show({title:"DeepSeek",description:p(g),variant:"error"})}finally{await a(),u(!1)}}function f(){const l=r();if(!l||i()){a();return}c(l.mode==="deepseek"?"native":"deepseek")}t.add(_.Panel,{id:"main",region:"side",mobile:{title:"DeepSeek",order:50,kind:"menu",icon:"globe"},list:l=>l.open.includes(K.id)?[K]:[],render:()=>o.createComponent(Se,{get status(){return r()},get error(){return i()},get busy(){return d()},onToggle:f,onRefresh:()=>{a()}})});const m=()=>{const l=t.sessions.current();if(!l){t.toast.show({title:"DeepSeek",description:"请先打开一个会话"});return}t.layout.open(`${t.id}:main`,l)};t.add(_.Command,{id:"open",title:"DeepSeek: 打开统计面板",section:"session",run:m}),t.add(_.MenuItem,()=>{const l=t.sessions.current();return{menu:"session.panel",id:"open",title:"DeepSeek",description:"搜索用量与接管开关",icon:"globe",order:60,opened:l!==void 0&&t.layout.stored(l).includes("main"),run:m}});const h=e.events.on("changed",()=>{a()});return a(),h}});exports.default=Pe;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
{
|
|
2
2
|
"name": "@qthinker/opencode-deepseek-search",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DeepSeek Search \u0026 Fetch for OpenCode V2: a switchable websearch backend, clean Markdown fetch,
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DeepSeek Search \u0026 Fetch for OpenCode V2: a switchable websearch backend, clean Markdown fetch, local usage/cost stats, and TUI + desktop sidebars",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "QThinker",
|
|
@@ -21,35 +21,64 @@
|
|
|
21
21
|
"search",
|
|
22
22
|
"fetch",
|
|
23
23
|
"usage",
|
|
24
|
-
"cost"
|
|
24
|
+
"cost",
|
|
25
|
+
"tui",
|
|
26
|
+
"desktop",
|
|
27
|
+
"sidebar"
|
|
25
28
|
],
|
|
26
29
|
"files": [
|
|
27
30
|
"index.ts",
|
|
31
|
+
"tui.ts",
|
|
32
|
+
"gui-experimental.cjs",
|
|
28
33
|
"src",
|
|
29
34
|
"README.md",
|
|
35
|
+
"CHANGELOG.md",
|
|
30
36
|
"LICENSE"
|
|
31
37
|
],
|
|
32
38
|
"engines": {
|
|
33
39
|
"node": "\u003e=22.18"
|
|
34
40
|
},
|
|
35
41
|
"scripts": {
|
|
36
|
-
"typecheck": "tsc --noEmit",
|
|
42
|
+
"typecheck": "tsc --noEmit \u0026\u0026 tsc --noEmit -p tsconfig.gui.json",
|
|
37
43
|
"test": "node --test test/*.test.ts",
|
|
44
|
+
"build:gui": "vite build",
|
|
38
45
|
"smoke": "node --experimental-strip-types scripts/smoke.ts",
|
|
39
|
-
"prepublishOnly": "npm run typecheck \u0026\u0026 npm test"
|
|
46
|
+
"prepublishOnly": "npm run typecheck \u0026\u0026 npm test \u0026\u0026 npm run build:gui"
|
|
40
47
|
},
|
|
41
48
|
"peerDependencies": {
|
|
42
|
-
"@opencode/plugin": "\u003e=2.2.
|
|
49
|
+
"@opencode/plugin": "\u003e=2.2.5",
|
|
50
|
+
"@opencode/theme": "\u003e=2.2.5",
|
|
51
|
+
"@opentui/core": "\u003e=0.8.0",
|
|
52
|
+
"@opentui/solid": "\u003e=0.8.0",
|
|
53
|
+
"solid-js": "\u003e=1.9.0"
|
|
43
54
|
},
|
|
44
55
|
"peerDependenciesMeta": {
|
|
45
56
|
"@opencode/plugin": {
|
|
46
57
|
"optional": true
|
|
47
|
-
}
|
|
58
|
+
},
|
|
59
|
+
"@opencode/theme": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"@opentui/core": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@opentui/solid": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"solid-js": {
|
|
69
|
+
"optional": true
|
|
70
|
+
}
|
|
48
71
|
},
|
|
49
72
|
"devDependencies": {
|
|
50
73
|
"@opencode/plugin": "2.2.5",
|
|
74
|
+
"@opencode/theme": "2.2.5",
|
|
75
|
+
"@opentui/core": "^0.8.0",
|
|
76
|
+
"@opentui/solid": "^0.8.0",
|
|
51
77
|
"@types/node": "^24.0.0",
|
|
52
|
-
"
|
|
78
|
+
"solid-js": "^1.9.0",
|
|
79
|
+
"typescript": "^5.9.0",
|
|
80
|
+
"vite": "^7.3.7",
|
|
81
|
+
"vite-plugin-solid": "^2.11.10"
|
|
53
82
|
},
|
|
54
83
|
"main": "./index.ts",
|
|
55
84
|
"publishConfig": {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Desktop half of the plugin: a DeepSeek tab in a session's side region, plus a
|
|
2
|
+
// command that opens it.
|
|
3
|
+
//
|
|
4
|
+
// It reaches the server half only through the shared RPC contract, so the
|
|
5
|
+
// numbers and the switch match the TUI sidebar. The host loads it from the
|
|
6
|
+
// prebuilt CommonJS bundle `gui-experimental.cjs` (see vite.config.ts).
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
Command,
|
|
10
|
+
MenuItem,
|
|
11
|
+
Panel,
|
|
12
|
+
Plugin,
|
|
13
|
+
type PanelTab,
|
|
14
|
+
type SessionPanelMenuItem,
|
|
15
|
+
} from "@opencode/plugin/gui"
|
|
16
|
+
import { createSignal } from "solid-js"
|
|
17
|
+
import { DeepSeekRpc, readModeResult, readStatus, type SearchStatus } from "../rpc.ts"
|
|
18
|
+
import { DeepSeekPanel } from "./panel.tsx"
|
|
19
|
+
|
|
20
|
+
// `Panel.list` must return the same tab object while it is unchanged: a new
|
|
21
|
+
// object renders the tab label again.
|
|
22
|
+
const TAB: PanelTab = { id: "main", title: "DeepSeek" }
|
|
23
|
+
|
|
24
|
+
const ERROR_MAX = 80
|
|
25
|
+
|
|
26
|
+
export default Plugin.define({
|
|
27
|
+
setup(ctx) {
|
|
28
|
+
const rpc = ctx.client.rpc(DeepSeekRpc)
|
|
29
|
+
const [status, setStatus] = createSignal<SearchStatus | undefined>()
|
|
30
|
+
const [error, setError] = createSignal<string | undefined>()
|
|
31
|
+
const [busy, setBusy] = createSignal(false)
|
|
32
|
+
|
|
33
|
+
const describeError = (e: unknown) => (e instanceof Error ? e.message : String(e)).slice(0, ERROR_MAX)
|
|
34
|
+
|
|
35
|
+
// `status` takes a required first argument: plain JSON Schema degrades the
|
|
36
|
+
// input type to `unknown`, and the host validates the payload against the
|
|
37
|
+
// method's object schema, so an omitted input is rejected as invalid.
|
|
38
|
+
async function refresh() {
|
|
39
|
+
try {
|
|
40
|
+
const next = readStatus(await rpc.status({}))
|
|
41
|
+
if (next) {
|
|
42
|
+
setStatus(next)
|
|
43
|
+
setError(undefined)
|
|
44
|
+
} else {
|
|
45
|
+
setError("状态数据格式不符")
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
setError(describeError(e))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function applyMode(next: "deepseek" | "native") {
|
|
53
|
+
setBusy(true)
|
|
54
|
+
try {
|
|
55
|
+
const result = readModeResult(await rpc.setMode({ mode: next }))
|
|
56
|
+
if (!result) ctx.toast.show({ title: "DeepSeek", description: "模式切换结果格式不符", variant: "error" })
|
|
57
|
+
else if (!result.ok) ctx.toast.show({ title: "DeepSeek", description: result.message, variant: "error" })
|
|
58
|
+
} catch (e) {
|
|
59
|
+
ctx.toast.show({ title: "DeepSeek", description: describeError(e), variant: "error" })
|
|
60
|
+
} finally {
|
|
61
|
+
await refresh()
|
|
62
|
+
setBusy(false)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Mirrors the TUI card: no status or a stale error rereads, otherwise
|
|
67
|
+
// switch. A missing key still allows releasing the takeover (the shared
|
|
68
|
+
// model hides the button only where there is genuinely nothing to do).
|
|
69
|
+
function toggle() {
|
|
70
|
+
const current = status()
|
|
71
|
+
if (!current || error()) {
|
|
72
|
+
void refresh()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
void applyMode(current.mode === "deepseek" ? "native" : "deepseek")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ctx.add(Panel, {
|
|
79
|
+
id: "main",
|
|
80
|
+
region: "side",
|
|
81
|
+
mobile: { title: "DeepSeek", order: 50, kind: "menu", icon: "globe" },
|
|
82
|
+
list: (input) => (input.open.includes(TAB.id) ? [TAB] : []),
|
|
83
|
+
render: () => (
|
|
84
|
+
<DeepSeekPanel
|
|
85
|
+
status={status()}
|
|
86
|
+
error={error()}
|
|
87
|
+
busy={busy()}
|
|
88
|
+
onToggle={toggle}
|
|
89
|
+
onRefresh={() => void refresh()}
|
|
90
|
+
/>
|
|
91
|
+
),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// One entry point, used by both the palette command and the side panel's
|
|
95
|
+
// launcher item.
|
|
96
|
+
const openPanel = () => {
|
|
97
|
+
const session = ctx.sessions.current()
|
|
98
|
+
if (!session) {
|
|
99
|
+
ctx.toast.show({ title: "DeepSeek", description: "请先打开一个会话" })
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
ctx.layout.open(`${ctx.id}:main`, session)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ctx.add(Command, {
|
|
106
|
+
id: "open",
|
|
107
|
+
title: "DeepSeek: 打开统计面板",
|
|
108
|
+
section: "session",
|
|
109
|
+
run: openPanel,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// The new-tab launcher and the + menu list `session.panel` items, not panel
|
|
113
|
+
// tabs, so without this the panel is reachable only by typing the command.
|
|
114
|
+
// The item hides itself while its tab is already open.
|
|
115
|
+
ctx.add(MenuItem, (): SessionPanelMenuItem => {
|
|
116
|
+
const session = ctx.sessions.current()
|
|
117
|
+
const opened = session !== undefined && ctx.layout.stored(session).includes("main")
|
|
118
|
+
return {
|
|
119
|
+
menu: "session.panel",
|
|
120
|
+
id: "open",
|
|
121
|
+
title: "DeepSeek",
|
|
122
|
+
description: "搜索用量与接管开关",
|
|
123
|
+
icon: "globe",
|
|
124
|
+
order: 60,
|
|
125
|
+
opened,
|
|
126
|
+
run: openPanel,
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// The `changed` event is the primary refresh path; a panel that mounts
|
|
131
|
+
// later renders the latest signals, and its refresh button rereads.
|
|
132
|
+
const off = rpc.events.on("changed", () => void refresh())
|
|
133
|
+
void refresh()
|
|
134
|
+
|
|
135
|
+
return off
|
|
136
|
+
},
|
|
137
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The desktop side-panel tab: the same states and copy as the TUI card
|
|
2
|
+
// (status-view.ts), composed for a resizable panel instead of a terminal column.
|
|
3
|
+
import type { JSX } from "solid-js"
|
|
4
|
+
import { For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
|
5
|
+
import { usePlugin } from "@opencode/plugin/gui"
|
|
6
|
+
import type { SearchStatus } from "../rpc.ts"
|
|
7
|
+
import { sidebarRows, type SidebarRows } from "../status-view.ts"
|
|
8
|
+
|
|
9
|
+
export interface DeepSeekPanelProps {
|
|
10
|
+
readonly status: SearchStatus | undefined
|
|
11
|
+
readonly error: string | undefined
|
|
12
|
+
readonly busy: boolean
|
|
13
|
+
readonly onToggle: () => void
|
|
14
|
+
readonly onRefresh: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Tone = SidebarRows["tone"]
|
|
18
|
+
|
|
19
|
+
/** Text-presentation symbols, never emoji. */
|
|
20
|
+
const GLYPH: Readonly<Record<Tone, string>> = { on: "●", off: "○", warn: "◐", error: "!", idle: "…" }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* No theme tokens exist on the desktop side, so the tone is a minimal palette
|
|
24
|
+
* per mode; text and rules otherwise inherit the host's color.
|
|
25
|
+
*/
|
|
26
|
+
const INK = {
|
|
27
|
+
dark: { on: "#63d19a", warn: "#e0ad4f", error: "#ee7b74", off: "#8b9198", idle: "#8b9198", rule: "rgba(255,255,255,0.14)" },
|
|
28
|
+
light: { on: "#1e7a4c", warn: "#8a5f08", error: "#b03931", off: "#6b7076", idle: "#6b7076", rule: "rgba(0,0,0,0.13)" },
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
const LABEL: Readonly<Record<Tone, string>> = {
|
|
32
|
+
on: "停用(交还原生搜索)", off: "启用(由 DeepSeek 接管)",
|
|
33
|
+
warn: "交还原生搜索", error: "重试", idle: "重试",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ICON = { on: "arrow-undo-down", off: "globe", warn: "arrow-undo-down", error: "refresh", idle: "refresh" } as const
|
|
37
|
+
|
|
38
|
+
/** The product name is chrome; the state after ` · ` is the message. */
|
|
39
|
+
function headline(title: string, accent: string): JSX.Element {
|
|
40
|
+
const at = title.indexOf(" · ")
|
|
41
|
+
if (at < 0) return <span style={{ color: accent }}>{title}</span>
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<span style={{ opacity: 0.6, "font-weight": 400 }}>{title.slice(0, at)}</span>
|
|
45
|
+
<span style={{ opacity: 0.3, "font-weight": 400 }}>{" · "}</span>
|
|
46
|
+
<span style={{ color: accent }}>{title.slice(at + 3)}</span>
|
|
47
|
+
</>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `今日 搜索 3 · 抓取 5 · …` → its label and ` · ` chunks, so values wrap as whole stats. */
|
|
52
|
+
function splitLine(line: string): { readonly label: string; readonly chunks: readonly string[] } {
|
|
53
|
+
const at = line.indexOf(" ")
|
|
54
|
+
return at < 0 ? { label: line, chunks: [] } : { label: line.slice(0, at), chunks: line.slice(at + 1).split(" · ") }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The fix-it row: the env var name is an identifier, so give it the mono font. */
|
|
58
|
+
function help(action: string, mono: string): JSX.Element {
|
|
59
|
+
const key = "DEEPSEEK_API_KEY"
|
|
60
|
+
const at = action.indexOf(key)
|
|
61
|
+
if (at < 0) return <>{action}</>
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
{action.slice(0, at)}<code style={{ "font-family": mono, "font-size": "0.94em" }}>{key}</code>{action.slice(at + key.length)}
|
|
65
|
+
</>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
|
|
70
|
+
const ctx = usePlugin()
|
|
71
|
+
const rows = createMemo(() => sidebarRows({ status: props.status, error: props.error, maxColumns: Infinity }))
|
|
72
|
+
const ink = createMemo(() => INK[ctx.appearance.mode()])
|
|
73
|
+
const mono = () => ctx.appearance.font("mono")
|
|
74
|
+
|
|
75
|
+
// Cross-half `changed` delivery is the one runtime behaviour this repo cannot
|
|
76
|
+
// verify, so the panel rereads on its own timer while it is mounted — the same
|
|
77
|
+
// fallback the TUI sidebar uses.
|
|
78
|
+
onMount(() => {
|
|
79
|
+
const timer = setInterval(() => props.onRefresh(), 10_000)
|
|
80
|
+
onCleanup(() => clearInterval(timer))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
aria-busy={props.busy || undefined}
|
|
86
|
+
style={{
|
|
87
|
+
display: "flex", "flex-direction": "column", height: "100%", "min-width": 0,
|
|
88
|
+
"box-sizing": "border-box", padding: "16px 16px 24px", "overflow-y": "auto", "overflow-x": "hidden",
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<header style={{ display: "flex", "align-items": "flex-start", gap: "8px" }}>
|
|
92
|
+
<span aria-hidden="true" style={{ "flex-shrink": 0, "font-size": "10px", "line-height": "20px", "font-weight": 700, color: ink()[rows().tone] }}>
|
|
93
|
+
{GLYPH[rows().tone]}
|
|
94
|
+
</span>
|
|
95
|
+
<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
|
+
{headline(rows().title, ink()[rows().tone])}
|
|
97
|
+
</h2>
|
|
98
|
+
<ctx.ui.Tooltip value="刷新用量" placement="bottom">
|
|
99
|
+
<ctx.ui.IconButton icon="refresh" label="刷新用量" size="small" onClick={props.onRefresh} />
|
|
100
|
+
</ctx.ui.Tooltip>
|
|
101
|
+
</header>
|
|
102
|
+
|
|
103
|
+
{props.status === undefined ? (
|
|
104
|
+
rows().lines.length > 0 ? (
|
|
105
|
+
<p style={{ margin: "14px 0 0", "font-size": "12px", "line-height": 1.6, opacity: 0.7, "overflow-wrap": "anywhere" }}>{rows().lines[0]}</p>
|
|
106
|
+
) : (
|
|
107
|
+
<div style={{ display: "flex", "align-items": "center", gap: "8px", "margin-top": "14px", opacity: 0.7 }}>
|
|
108
|
+
<ctx.ui.Loader size="small" />
|
|
109
|
+
<span style={{ "font-size": "12px" }}>正在读取用量…</span>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
) : (
|
|
113
|
+
<dl style={{ margin: "16px 0 0", "border-top": `1px solid ${ink().rule}`, "border-bottom": `1px solid ${ink().rule}` }}>
|
|
114
|
+
<For each={rows().lines}>
|
|
115
|
+
{(line, index) => {
|
|
116
|
+
const parts = splitLine(line)
|
|
117
|
+
return (
|
|
118
|
+
<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 }}>
|
|
120
|
+
{parts.label}
|
|
121
|
+
</dt>
|
|
122
|
+
<dd style={{
|
|
123
|
+
margin: 0, "margin-left": "auto", "min-width": 0, display: "flex", "flex-wrap": "wrap", "justify-content": "flex-end",
|
|
124
|
+
"column-gap": "6px", "row-gap": "2px", "font-family": mono(), "font-size": "13px", "line-height": "1.55",
|
|
125
|
+
"font-variant-numeric": "tabular-nums", "text-align": "right",
|
|
126
|
+
}}>
|
|
127
|
+
<For each={parts.chunks}>
|
|
128
|
+
{(chunk, i) => (
|
|
129
|
+
<span style={{ "min-width": 0, "overflow-wrap": "anywhere", "font-weight": chunk.startsWith("¥") ? 500 : 400, opacity: chunk.startsWith("¥") ? 1 : 0.8 }}>
|
|
130
|
+
{chunk}{i() < parts.chunks.length - 1 ? " ·" : ""}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
</For>
|
|
134
|
+
</dd>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}}
|
|
138
|
+
</For>
|
|
139
|
+
</dl>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
<Show when={!rows().clickable}>
|
|
143
|
+
<p style={{ margin: "16px 0 0", "font-size": "12px", "line-height": 1.6, opacity: 0.68, "overflow-wrap": "anywhere" }}>{help(rows().action, mono())}</p>
|
|
144
|
+
</Show>
|
|
145
|
+
<Show when={rows().clickable && rows().tone !== "idle"}>
|
|
146
|
+
<div style={{ "margin-top": "18px" }}>
|
|
147
|
+
<ctx.ui.Button variant={rows().tone === "off" || rows().tone === "error" ? "contrast" : "neutral"} icon={ICON[rows().tone]} disabled={props.busy} busy={props.busy} onClick={props.onToggle}>
|
|
148
|
+
{LABEL[rows().tone]}
|
|
149
|
+
</ctx.ui.Button>
|
|
150
|
+
</div>
|
|
151
|
+
</Show>
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|