@qthinker/opencode-deepseek-search 0.1.1 → 0.2.1

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.
@@ -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
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { Plugin } from "@opencode/plugin"
16
16
  import type { Result as ToolResult, ToolContext } from "@opencode/plugin/promise/tool"
17
+ import type { RpcRegistration } from "@opencode/plugin/promise/rpc"
17
18
  import {
18
19
  deepseekFetch,
19
20
  deepseekSearch,
@@ -23,6 +24,7 @@ import {
23
24
  validateUrl,
24
25
  type SearchResult,
25
26
  } from "./deepseek.ts"
27
+ import { DeepSeekRpc, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
26
28
  import {
27
29
  USAGE_KEY,
28
30
  applyTally,
@@ -32,6 +34,8 @@ import {
32
34
  formatUsage,
33
35
  formatYuan,
34
36
  parseLedger,
37
+ sumDays,
38
+ usageSummaryLines,
35
39
  type Tally,
36
40
  type UsageLedger,
37
41
  } from "./usage.ts"
@@ -118,10 +122,19 @@ export default Plugin.define({
118
122
  chain = run.catch(() => {})
119
123
  return run
120
124
  }
125
+ // Filled in after the tool transforms are registered; `tally` can run before
126
+ // that, so this stays optional. `changed` tells the sidebars to re-read `status`.
127
+ let rpcRegistration: RpcRegistration<typeof DeepSeekRpc> | undefined
128
+ // Nested `changed` events are cheap, but they are also not worth failing a
129
+ // search or a mode switch over, so every caller ignores the outcome.
130
+ const notifyChanged = (): void => {
131
+ void rpcRegistration?.events.emit("changed", {}).catch(() => {})
132
+ }
121
133
  const tally = (kind: Tally): void => {
122
134
  void withLedger(async (ledger) => {
123
135
  applyTally(ledger, kind, pricePerThousand)
124
136
  await ctx.storage.set(USAGE_KEY, ledger)
137
+ notifyChanged()
125
138
  const today = ledger.days[dayKey()] ?? emptyDay()
126
139
  console.error(
127
140
  `[deepseek-search] usage: today search=${today.search} fetch=${today.fetch} ${formatYuan(today.costMicro)}`,
@@ -212,6 +225,32 @@ export default Plugin.define({
212
225
  : " · 未找到内置 websearch 工具,实际未能接管"
213
226
  : " · 内置 websearch 保持原生")
214
227
 
228
+ // Shared by the `deepseek_mode` tool and the RPC `setMode` method. The
229
+ // returned strings are the tool's documented output, word for word.
230
+ const switchMode = async (action: Exclude<ModeAction, "status">): Promise<string> => {
231
+ if (!pristine) {
232
+ return (
233
+ `未找到内置 ${SEARCH_TOOL} 工具,无法在运行时切换。` +
234
+ `请改用配置文件把 options.mode 设为 "${action === "config" ? configMode : action}"。`
235
+ )
236
+ }
237
+ if (action === "config") {
238
+ await ctx.storage.remove(MODE_KEY)
239
+ overrideMode = undefined
240
+ await applySearchMode(configMode)
241
+ notifyChanged()
242
+ return `${describeMode()}\n\n已清除持久化开关,回到配置文件设置(${configMode})。`
243
+ }
244
+ await ctx.storage.set(MODE_KEY, action)
245
+ overrideMode = action
246
+ await applySearchMode(action)
247
+ notifyChanged()
248
+ return (
249
+ `${describeMode()}\n\n已持久化:重启 OpenCode 后仍为 ${action}。` +
250
+ '要恢复配置文件设置,调用 deepseek_mode 并传 mode: "config"。'
251
+ )
252
+ }
253
+
215
254
  // ---------- tools ----------
216
255
  await ctx.tool.transform((editor) => {
217
256
  const builtin = editor.get(SEARCH_TOOL)
@@ -314,31 +353,50 @@ export default Plugin.define({
314
353
  execute: async (input) => {
315
354
  const action = readModeAction(input)
316
355
  if (action === "status") return { content: describeMode() }
317
- if (!pristine) {
318
- return {
319
- content:
320
- `未找到内置 ${SEARCH_TOOL} 工具,无法在运行时切换。` +
321
- `请改用配置文件把 options.mode 设为 "${action === "config" ? configMode : action}"。`,
322
- }
323
- }
324
- if (action === "config") {
325
- await ctx.storage.remove(MODE_KEY)
326
- overrideMode = undefined
327
- await applySearchMode(configMode)
328
- return { content: `${describeMode()}\n\n已清除持久化开关,回到配置文件设置(${configMode})。` }
329
- }
330
- await ctx.storage.set(MODE_KEY, action)
331
- overrideMode = action
332
- await applySearchMode(action)
333
- return {
334
- content:
335
- `${describeMode()}\n\n已持久化:重启 OpenCode 后仍为 ${action}。` +
336
- '要恢复配置文件设置,调用 deepseek_mode 并传 mode: "config"。',
337
- }
356
+ return { content: await switchMode(action) }
338
357
  },
339
358
  })
340
359
  })
341
360
 
361
+ // ---------- RPC contract (TUI / desktop sidebars) ----------
362
+ // Both sidebars read `status` and call `setMode`; nothing else crosses the
363
+ // process boundary, so the plugin's state stays the single source of truth.
364
+ rpcRegistration = await ctx.rpc.register(DeepSeekRpc, {
365
+ // Reads through `withLedger` so it observes the same serialized chain.
366
+ status: async (): Promise<SearchStatus> =>
367
+ withLedger((ledger): SearchStatus => {
368
+ const todayKey = dayKey()
369
+ return {
370
+ mode,
371
+ source: overrideMode ? "override" : "config",
372
+ patched: searchToolPatched,
373
+ hasKey: Boolean(apiKey),
374
+ pricePerThousand,
375
+ ...usageSummaryLines(ledger, todayKey),
376
+ // Same ledger read as the strings above, exposed as numbers so the
377
+ // sidebars can lay them out for their own width.
378
+ counts: {
379
+ today: sumDays(ledger, (k) => k === todayKey),
380
+ month: sumDays(ledger, (k) => k.startsWith(todayKey.slice(0, 7))),
381
+ total: sumDays(ledger, () => true),
382
+ },
383
+ }
384
+ }),
385
+ // The input is plain JSON Schema, hence `unknown`: report, never throw.
386
+ setMode: async (input: unknown): Promise<ModeSwitchResult> => {
387
+ let action: ModeAction
388
+ try {
389
+ action = readModeAction(input)
390
+ } catch (e) {
391
+ return { ok: false, mode, message: e instanceof Error ? e.message : String(e) }
392
+ }
393
+ if (action === "status") return { ok: false, mode, message: "status 只读取当前模式,不做切换。" }
394
+ const message = await switchMode(action)
395
+ // `!pristine` means switchMode could not apply the change; its message says why.
396
+ return { ok: Boolean(pristine), mode, message }
397
+ },
398
+ })
399
+
342
400
  // Load marker for diagnostics; never logs the key itself.
343
401
  console.error(
344
402
  `[deepseek-search] loaded: mode=${mode} source=${overrideMode ? "override" : "config"} ` +
package/src/rpc.ts ADDED
@@ -0,0 +1,160 @@
1
+ // RPC contract shared by the server plugin (handler side) and the TUI / desktop
2
+ // sidebars (client side).
3
+ //
4
+ // Type-only imports only: this module must stay loadable from all three
5
+ // runtimes (Node type-stripping for the server and the TUI, a CJS bundle for
6
+ // the desktop app), so it carries no runtime dependency.
7
+ //
8
+ // The schemas are plain JSON Schema, so `Rpc.Input` / `Rpc.Output` degrade to
9
+ // `unknown` on the client. `readStatus` / `readModeResult` narrow that back
10
+ // defensively, in the style of `parseLedger` (src/usage.ts).
11
+
12
+ import type { Rpc } from "@opencode/plugin/rpc"
13
+
14
+ export const DEEPSEEK_RPC_ID = "deepseek-search"
15
+
16
+ // Shared by the today/month/total slots of `status.counts`.
17
+ const USAGE_COUNTS_SCHEMA = {
18
+ type: "object",
19
+ properties: {
20
+ search: { type: "integer" },
21
+ fetch: { type: "integer" },
22
+ empty: { type: "integer" },
23
+ failed: { type: "integer" },
24
+ costMicro: { type: "integer" },
25
+ },
26
+ required: ["search", "fetch", "empty", "failed", "costMicro"],
27
+ additionalProperties: false,
28
+ } as const
29
+
30
+ export const DeepSeekRpc = {
31
+ id: DEEPSEEK_RPC_ID,
32
+ methods: {
33
+ status: {
34
+ input: { type: "object", properties: {}, additionalProperties: false },
35
+ output: {
36
+ type: "object",
37
+ properties: {
38
+ mode: { type: "string", enum: ["deepseek", "native"] },
39
+ source: { type: "string", enum: ["override", "config"] },
40
+ patched: { type: "boolean" },
41
+ hasKey: { type: "boolean" },
42
+ pricePerThousand: { type: "number" },
43
+ today: { type: "string" },
44
+ month: { type: "string" },
45
+ total: { type: "string" },
46
+ counts: {
47
+ type: "object",
48
+ properties: {
49
+ today: USAGE_COUNTS_SCHEMA,
50
+ month: USAGE_COUNTS_SCHEMA,
51
+ total: USAGE_COUNTS_SCHEMA,
52
+ },
53
+ required: ["today", "month", "total"],
54
+ additionalProperties: false,
55
+ },
56
+ },
57
+ required: ["mode", "source", "patched", "hasKey", "pricePerThousand", "today", "month", "total"],
58
+ additionalProperties: false,
59
+ },
60
+ },
61
+ setMode: {
62
+ input: {
63
+ type: "object",
64
+ properties: {
65
+ mode: { type: "string", enum: ["deepseek", "native", "config"] },
66
+ },
67
+ required: ["mode"],
68
+ additionalProperties: false,
69
+ },
70
+ output: {
71
+ type: "object",
72
+ properties: {
73
+ ok: { type: "boolean" },
74
+ mode: { type: "string" },
75
+ message: { type: "string" },
76
+ },
77
+ required: ["ok", "mode", "message"],
78
+ additionalProperties: false,
79
+ },
80
+ },
81
+ },
82
+ events: {
83
+ // Emitted after the ledger or the mode changes.
84
+ changed: { schema: { type: "object" as const, properties: {} } },
85
+ },
86
+ } satisfies Rpc.PortableDefinition
87
+
88
+ export interface UsageCounts {
89
+ search: number
90
+ fetch: number
91
+ empty: number
92
+ failed: number
93
+ costMicro: number
94
+ }
95
+
96
+ export interface SearchStatus {
97
+ mode: "deepseek" | "native"
98
+ source: "override" | "config"
99
+ patched: boolean
100
+ hasKey: boolean
101
+ pricePerThousand: number
102
+ today: string
103
+ month: string
104
+ total: string
105
+ /** Structured counters, so each surface can format for its own width. Optional for older servers. */
106
+ counts?: { today: UsageCounts; month: UsageCounts; total: UsageCounts }
107
+ }
108
+
109
+ export interface ModeSwitchResult {
110
+ ok: boolean
111
+ mode: string
112
+ message: string
113
+ }
114
+
115
+ function isRecord(value: unknown): value is Record<string, unknown> {
116
+ return typeof value === "object" && value !== null && !Array.isArray(value)
117
+ }
118
+
119
+ function isMode(value: unknown): value is SearchStatus["mode"] {
120
+ return value === "deepseek" || value === "native"
121
+ }
122
+
123
+ /** Strict read of one counter bucket: all five fields, integers ≥ 0, finite cost. */
124
+ function readUsageCounts(value: unknown): UsageCounts | undefined {
125
+ if (!isRecord(value)) return undefined
126
+ const { search, fetch, empty, failed, costMicro } = value
127
+ for (const count of [search, fetch, empty, failed]) {
128
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 0) return undefined
129
+ }
130
+ if (typeof costMicro !== "number" || !Number.isFinite(costMicro) || costMicro < 0) return undefined
131
+ return { search, fetch, empty, failed, costMicro } as UsageCounts
132
+ }
133
+
134
+ /** Defensive read of an RPC `status` result. Never throws. */
135
+ export function readStatus(raw: unknown): SearchStatus | undefined {
136
+ if (!isRecord(raw)) return undefined
137
+ const { mode, source, patched, hasKey, pricePerThousand, today, month, total, counts } = raw
138
+ if (!isMode(mode)) return undefined
139
+ if (source !== "override" && source !== "config") return undefined
140
+ if (typeof patched !== "boolean" || typeof hasKey !== "boolean") return undefined
141
+ if (typeof pricePerThousand !== "number" || !Number.isFinite(pricePerThousand)) return undefined
142
+ if (typeof today !== "string" || typeof month !== "string" || typeof total !== "string") return undefined
143
+ const status: SearchStatus = { mode, source, patched, hasKey, pricePerThousand, today, month, total }
144
+ // Old servers omit `counts`; a malformed triple is dropped without failing the rest.
145
+ if (isRecord(counts)) {
146
+ const todayCounts = readUsageCounts(counts.today)
147
+ const monthCounts = readUsageCounts(counts.month)
148
+ const totalCounts = readUsageCounts(counts.total)
149
+ if (todayCounts && monthCounts && totalCounts) status.counts = { today: todayCounts, month: monthCounts, total: totalCounts }
150
+ }
151
+ return status
152
+ }
153
+
154
+ /** Defensive read of an RPC `setMode` result. Never throws. */
155
+ export function readModeResult(raw: unknown): ModeSwitchResult | undefined {
156
+ if (!isRecord(raw)) return undefined
157
+ const { ok, mode, message } = raw
158
+ if (typeof ok !== "boolean" || typeof mode !== "string" || typeof message !== "string") return undefined
159
+ return { ok, mode, message }
160
+ }