@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.
- package/CHANGELOG.md +60 -0
- package/LICENSE +21 -21
- package/README.md +64 -11
- package/gui-experimental.cjs +1 -0
- package/package.json +87 -58
- 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
|
@@ -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
|
-
|
|
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
|
+
}
|