@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/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
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Pure view model for the sidebar card. No renderer, no runtime dependency:
|
|
2
|
+
// the card and the tests both derive their text from here.
|
|
3
|
+
import type { SearchStatus, UsageCounts } from "./rpc.ts"
|
|
4
|
+
|
|
5
|
+
export interface SidebarInput {
|
|
6
|
+
readonly status: SearchStatus | undefined
|
|
7
|
+
readonly error: string | undefined
|
|
8
|
+
/**
|
|
9
|
+
* Columns one row may occupy. Defaults to the narrow sidebar budget; a host
|
|
10
|
+
* that has measured its width may pass it, and the desktop panel passes
|
|
11
|
+
* `Infinity` because it has room for the verbose copy.
|
|
12
|
+
*/
|
|
13
|
+
readonly maxColumns?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SidebarRows {
|
|
17
|
+
/** State headline, e.g. `已启用`; the full name only survives on wide surfaces. */
|
|
18
|
+
readonly title: string
|
|
19
|
+
/**
|
|
20
|
+
* Whether the title row starts with the tone glyph. False only when even the
|
|
21
|
+
* state alone cannot share the row with the action.
|
|
22
|
+
*/
|
|
23
|
+
readonly glyph: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Clickable label including its hit-area brackets, e.g. `[停用]`. A state
|
|
26
|
+
* without an action carries its fix copy here instead, where the desktop
|
|
27
|
+
* panel reads it; the terminal renders that copy as `hint`.
|
|
28
|
+
*/
|
|
29
|
+
readonly action: string
|
|
30
|
+
/**
|
|
31
|
+
* Whether the action must take a row of its own under the title. True only
|
|
32
|
+
* when the title row cannot hold it: shortening the title is the worse trade,
|
|
33
|
+
* because the full wording is what the user asked for.
|
|
34
|
+
*/
|
|
35
|
+
readonly actionBelow: boolean
|
|
36
|
+
/** Non-interactive fix row under the title; only states without an action have one. */
|
|
37
|
+
readonly hint: string | undefined
|
|
38
|
+
/** Whether a click on `action` really performs it (some states have no action). */
|
|
39
|
+
readonly clickable: boolean
|
|
40
|
+
/** Body rows: the three usage rows, or the short error reason. */
|
|
41
|
+
readonly lines: readonly string[]
|
|
42
|
+
/** Drives the state color in card.ts. */
|
|
43
|
+
readonly tone: "on" | "off" | "warn" | "error" | "idle"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The product name in the headline: the user's wording, case included.
|
|
47
|
+
const NAME = "DeepSeek search/fetch"
|
|
48
|
+
// The narrowest headline that still identifies the card; used when the product
|
|
49
|
+
// name above would cost the row too much space.
|
|
50
|
+
const SHORT_NAME = "DeepSeek"
|
|
51
|
+
// The card renders a glyph and a space before the title.
|
|
52
|
+
const TITLE_GLYPH_COLUMNS = 2
|
|
53
|
+
// Minimum space between the title and the action so they never touch.
|
|
54
|
+
const TITLE_ACTION_GAP = 1
|
|
55
|
+
// One card row of the narrowest surface, host padding already subtracted. Also
|
|
56
|
+
// the fallback budget before the host has reported a measured width.
|
|
57
|
+
export const DEFAULT_COLUMNS = 24
|
|
58
|
+
|
|
59
|
+
const PERIODS = ["today", "month", "total"] as const
|
|
60
|
+
type Period = (typeof PERIODS)[number]
|
|
61
|
+
const LABELS: Readonly<Record<Period, string>> = { today: "今日", month: "本月", total: "累计" }
|
|
62
|
+
|
|
63
|
+
type Counts = NonNullable<SearchStatus["counts"]>
|
|
64
|
+
|
|
65
|
+
/** Terminal columns of one code point; only CJK-style wide ranges matter here. */
|
|
66
|
+
function columns(char: string): number {
|
|
67
|
+
const cp = char.codePointAt(0) ?? 0
|
|
68
|
+
if (cp === 0) return 0
|
|
69
|
+
if (
|
|
70
|
+
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
71
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals … Yi
|
|
72
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
73
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
|
|
74
|
+
(cp >= 0xfe10 && cp <= 0xfe19) ||
|
|
75
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
76
|
+
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
|
|
77
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
78
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
79
|
+
(cp >= 0x20000 && cp <= 0x3fffd)
|
|
80
|
+
) {
|
|
81
|
+
return 2
|
|
82
|
+
}
|
|
83
|
+
return 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Terminal columns of a string: the measure every card row must fit. */
|
|
87
|
+
export function textColumns(text: string): number {
|
|
88
|
+
let total = 0
|
|
89
|
+
for (const char of text) total += columns(char)
|
|
90
|
+
return total
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Clip to the budget with a single ellipsis; used only as a last resort. */
|
|
94
|
+
function clip(text: string, maxColumns: number): string {
|
|
95
|
+
if (maxColumns < 1) return ""
|
|
96
|
+
if (textColumns(text) <= maxColumns) return text
|
|
97
|
+
let out = ""
|
|
98
|
+
let used = 0
|
|
99
|
+
for (const char of text) {
|
|
100
|
+
const width = columns(char)
|
|
101
|
+
if (used + width > maxColumns - 1) return `${out}…`
|
|
102
|
+
out += char
|
|
103
|
+
used += width
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One short body line: collapse whitespace, then clip to the available columns. */
|
|
109
|
+
function shortMessage(message: string, maxColumns: number): string {
|
|
110
|
+
const text = message.replace(/\s+/g, " ").trim()
|
|
111
|
+
return clip(text || "读取失败", maxColumns)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Micro-currency as `¥0.015`: three decimals, trailing zeros trimmed to cents. */
|
|
115
|
+
function money(costMicro: number): string {
|
|
116
|
+
const value = Number.isFinite(costMicro) && costMicro > 0 ? costMicro / 1_000_000 : 0
|
|
117
|
+
let text = value.toFixed(3)
|
|
118
|
+
// `0.040` → `0.04`, `0.015` unchanged; keep two decimals so amounts stay currency-shaped.
|
|
119
|
+
while (text.endsWith("0") && text.length > 4) text = text.slice(0, -1)
|
|
120
|
+
return `¥${text}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface UsageLayout {
|
|
124
|
+
readonly search: number
|
|
125
|
+
readonly fetch: number
|
|
126
|
+
readonly cost: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Column widths shared by the three usage rows. Counting digits is enough:
|
|
131
|
+
* every count is a non-negative integer, so its width is its digit count. The
|
|
132
|
+
* amount is padded too, so all three rows end on the same column.
|
|
133
|
+
*/
|
|
134
|
+
function usageLayout(counts: Counts): UsageLayout {
|
|
135
|
+
const rows = PERIODS.map((period) => counts[period])
|
|
136
|
+
return {
|
|
137
|
+
search: Math.max(...rows.map((row) => String(row.search).length)),
|
|
138
|
+
fetch: Math.max(...rows.map((row) => String(row.fetch).length)),
|
|
139
|
+
cost: Math.max(...rows.map((row) => money(row.costMicro).length)),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One usage row, e.g. `今日 Search 0 Fetch 3 ¥0.015` when the surface is
|
|
145
|
+
* wide, otherwise shorter forms down to `今日`. The words are the product's
|
|
146
|
+
* copy, so they survive as long as any shape can hold them: the amount goes
|
|
147
|
+
* first, then the tighter grouping, and only then `0S` / `3F`. Counts are
|
|
148
|
+
* right-aligned in their shared width and every row picks the same form, so
|
|
149
|
+
* the three rows read as a table.
|
|
150
|
+
*/
|
|
151
|
+
function compactUsageRow(
|
|
152
|
+
period: Period,
|
|
153
|
+
counts: UsageCounts,
|
|
154
|
+
layout: UsageLayout,
|
|
155
|
+
maxColumns: number,
|
|
156
|
+
): string {
|
|
157
|
+
const label = LABELS[period]
|
|
158
|
+
const search = String(counts.search).padStart(layout.search, " ")
|
|
159
|
+
const fetch = String(counts.fetch).padStart(layout.fetch, " ")
|
|
160
|
+
const cost = money(counts.costMicro).padStart(layout.cost, " ")
|
|
161
|
+
// Each candidate is a complete row, so nothing is ever left dangling and no
|
|
162
|
+
// number is ever cut from its unit.
|
|
163
|
+
const candidates = [
|
|
164
|
+
`${label} Search ${search} Fetch ${fetch} ${cost}`,
|
|
165
|
+
`${label} Search ${search} Fetch ${fetch} ${cost}`,
|
|
166
|
+
`${label} Search ${search} Fetch ${fetch}`,
|
|
167
|
+
`${label} Search ${search} Fetch ${fetch}`,
|
|
168
|
+
`${label} ${search}S ${fetch}F ${cost}`,
|
|
169
|
+
`${label} ${search}S ${fetch}F`,
|
|
170
|
+
`${label} ${search}S`,
|
|
171
|
+
]
|
|
172
|
+
for (const row of candidates) {
|
|
173
|
+
if (textColumns(row) <= maxColumns) return row
|
|
174
|
+
}
|
|
175
|
+
return textColumns(label) <= maxColumns ? label : clip(label, maxColumns)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* An older server only sends pre-formatted strings. Keep the full string when
|
|
180
|
+
* it fits, otherwise drop its trailing segments whole: the amount and the
|
|
181
|
+
* diagnostic counters go first, then the fetch count.
|
|
182
|
+
*/
|
|
183
|
+
function legacyUsageRow(period: Period, text: string, maxColumns: number): string {
|
|
184
|
+
const label = LABELS[period]
|
|
185
|
+
const full = `${label} ${text}`
|
|
186
|
+
if (textColumns(full) <= maxColumns) return full
|
|
187
|
+
const stats = text.split(" · ").filter((part) => !part.startsWith("¥") && !part.startsWith("空/失"))
|
|
188
|
+
const candidates = [
|
|
189
|
+
`${label} ${stats.join(" ")}`.trimEnd(),
|
|
190
|
+
`${label} ${stats[0] ?? ""}`.trimEnd(),
|
|
191
|
+
label,
|
|
192
|
+
]
|
|
193
|
+
for (const row of candidates) {
|
|
194
|
+
if (textColumns(row) <= maxColumns) return row
|
|
195
|
+
}
|
|
196
|
+
return clip(label, maxColumns)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function usageLines(status: SearchStatus, maxColumns: number): readonly string[] {
|
|
200
|
+
// The desktop panel lays the original verbose copy out as ` · ` chunks of its
|
|
201
|
+
// own table; only the terminal needs the compact form.
|
|
202
|
+
if (maxColumns === Number.POSITIVE_INFINITY) {
|
|
203
|
+
return [`今日 ${status.today}`, `本月 ${status.month}`, `累计 ${status.total}`]
|
|
204
|
+
}
|
|
205
|
+
const counts = status.counts
|
|
206
|
+
if (counts) {
|
|
207
|
+
const layout = usageLayout(counts)
|
|
208
|
+
return PERIODS.map((period) => compactUsageRow(period, counts[period], layout, maxColumns))
|
|
209
|
+
}
|
|
210
|
+
return PERIODS.map((period) => legacyUsageRow(period, status[period], maxColumns))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The clickable label, always bracketed: a bare two-character verb is an easy
|
|
215
|
+
* target to miss. At pathological widths the verb is clipped but the brackets
|
|
216
|
+
* stay, so the hit area never turns into plain text.
|
|
217
|
+
*/
|
|
218
|
+
function fitAction(verb: string, maxColumns: number): string {
|
|
219
|
+
const label = `[${verb}]`
|
|
220
|
+
if (maxColumns === Number.POSITIVE_INFINITY || textColumns(label) <= maxColumns) return label
|
|
221
|
+
const inner = maxColumns - 2
|
|
222
|
+
return inner >= 1 ? `[${clip(verb, inner)}]` : clip(label, maxColumns)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface TitleFit {
|
|
226
|
+
readonly title: string
|
|
227
|
+
readonly glyph: boolean
|
|
228
|
+
/** The action takes its own row, right-aligned, instead of sharing this one. */
|
|
229
|
+
readonly actionBelow: boolean
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The headline and where the action goes. The wording is the user's, so it is
|
|
234
|
+
* only shortened once no placement can hold it: first the title and the action
|
|
235
|
+
* share a row (the preferred layout), then the action moves to a row of its own
|
|
236
|
+
* and the title keeps the full width, and only then do the product name and the
|
|
237
|
+
* wordy state shrink. Within one wording the glyph is the first thing to go,
|
|
238
|
+
* because it costs two columns and the state word is right beside it.
|
|
239
|
+
*/
|
|
240
|
+
function fitTitle(
|
|
241
|
+
state: string,
|
|
242
|
+
shortState: string,
|
|
243
|
+
maxColumns: number,
|
|
244
|
+
actionColumns: number | undefined,
|
|
245
|
+
): TitleFit {
|
|
246
|
+
const budget = (glyph: boolean, actionBelow: boolean): number => {
|
|
247
|
+
if (maxColumns === Number.POSITIVE_INFINITY) return Number.POSITIVE_INFINITY
|
|
248
|
+
const prefix = glyph ? TITLE_GLYPH_COLUMNS : 0
|
|
249
|
+
const tail = actionColumns === undefined || actionBelow ? 0 : actionColumns + TITLE_ACTION_GAP
|
|
250
|
+
return Math.max(0, maxColumns - prefix - tail)
|
|
251
|
+
}
|
|
252
|
+
const candidates = [
|
|
253
|
+
`${NAME} · ${state}`,
|
|
254
|
+
`${SHORT_NAME} · ${state}`,
|
|
255
|
+
`${SHORT_NAME} · ${shortState}`,
|
|
256
|
+
state,
|
|
257
|
+
shortState,
|
|
258
|
+
]
|
|
259
|
+
// Inline first, then the action below it; a wording is never traded for a
|
|
260
|
+
// shorter one that happens to fit inline, because the wording is the point.
|
|
261
|
+
const placements = actionColumns === undefined ? [false] : [false, true]
|
|
262
|
+
for (const candidate of candidates) {
|
|
263
|
+
for (const actionBelow of placements) {
|
|
264
|
+
for (const glyph of [true, false]) {
|
|
265
|
+
if (textColumns(candidate) <= budget(glyph, actionBelow)) {
|
|
266
|
+
return { title: candidate, glyph, actionBelow }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Nothing fits whole at a pathological width: clip the state and give the
|
|
272
|
+
// action its own row so the hit area never disappears.
|
|
273
|
+
const actionBelow = actionColumns !== undefined
|
|
274
|
+
return { title: clip(shortState, budget(false, actionBelow)), glyph: false, actionBelow }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The fix hint for a missing key; it gets its own row because no click can fix it. */
|
|
278
|
+
function fitHint(maxColumns: number): string {
|
|
279
|
+
const candidates = [
|
|
280
|
+
"设置 DEEPSEEK_API_KEY 后重启",
|
|
281
|
+
"设置 API KEY 后重启",
|
|
282
|
+
"设置 API KEY",
|
|
283
|
+
"设置 KEY",
|
|
284
|
+
]
|
|
285
|
+
if (maxColumns === Number.POSITIVE_INFINITY) return candidates[0]
|
|
286
|
+
for (const candidate of candidates) {
|
|
287
|
+
if (textColumns(candidate) <= maxColumns) return candidate
|
|
288
|
+
}
|
|
289
|
+
return clip(candidates[candidates.length - 1], maxColumns)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface ActionRows extends TitleFit {
|
|
293
|
+
readonly action: string
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Fit the action first: the title can only use the columns it leaves over. */
|
|
297
|
+
function actionRows(state: string, shortState: string, verb: string, maxColumns: number): ActionRows {
|
|
298
|
+
const action = fitAction(verb, maxColumns)
|
|
299
|
+
return { ...fitTitle(state, shortState, maxColumns, textColumns(action)), action }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function sidebarRows(input: SidebarInput): SidebarRows {
|
|
303
|
+
const { status, error } = input
|
|
304
|
+
const maxColumns = input.maxColumns ?? DEFAULT_COLUMNS
|
|
305
|
+
const lines = status === undefined ? [] : usageLines(status, maxColumns)
|
|
306
|
+
|
|
307
|
+
// No status yet: the body has no numbers to show, only the reason (or that
|
|
308
|
+
// the first read is still in flight).
|
|
309
|
+
if (status === undefined) {
|
|
310
|
+
return error === undefined || error.trim() === ""
|
|
311
|
+
? {
|
|
312
|
+
...actionRows("正在读取…", "正在读取…", "重试", maxColumns),
|
|
313
|
+
hint: undefined,
|
|
314
|
+
clickable: true,
|
|
315
|
+
lines,
|
|
316
|
+
tone: "idle",
|
|
317
|
+
}
|
|
318
|
+
: {
|
|
319
|
+
...actionRows("读取失败", "读取失败", "重试", maxColumns),
|
|
320
|
+
hint: undefined,
|
|
321
|
+
clickable: true,
|
|
322
|
+
lines: [shortMessage(error, maxColumns)],
|
|
323
|
+
tone: "error",
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (status.patched && status.hasKey) {
|
|
328
|
+
return {
|
|
329
|
+
...actionRows("已启用", "已启用", "停用", maxColumns),
|
|
330
|
+
hint: undefined,
|
|
331
|
+
clickable: true,
|
|
332
|
+
lines,
|
|
333
|
+
tone: "on",
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!status.patched && status.mode === "deepseek") {
|
|
337
|
+
// DeepSeek was requested but the built-in tool was not found: nothing is
|
|
338
|
+
// actually taken over, so the only honest action is to release the mode.
|
|
339
|
+
return {
|
|
340
|
+
...actionRows("未接管内置 websearch", "未接管", "交还", maxColumns),
|
|
341
|
+
hint: undefined,
|
|
342
|
+
clickable: true,
|
|
343
|
+
lines,
|
|
344
|
+
tone: "warn",
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (status.mode === "native") {
|
|
348
|
+
return {
|
|
349
|
+
...actionRows("已停用", "已停用", "启用", maxColumns),
|
|
350
|
+
hint: undefined,
|
|
351
|
+
clickable: true,
|
|
352
|
+
lines,
|
|
353
|
+
tone: "off",
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// A missing key is not something a click can fix, so the title row stands
|
|
357
|
+
// alone and the hint gets its own row directly under it. The env var name
|
|
358
|
+
// only survives on surfaces wide enough for it.
|
|
359
|
+
const hint = fitHint(maxColumns)
|
|
360
|
+
return {
|
|
361
|
+
...fitTitle("未配置 API Key", "缺 API Key", maxColumns, undefined),
|
|
362
|
+
action: hint,
|
|
363
|
+
hint,
|
|
364
|
+
clickable: false,
|
|
365
|
+
lines,
|
|
366
|
+
tone: "error",
|
|
367
|
+
}
|
|
368
|
+
}
|