pi-deepseek-web-search 0.1.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/LICENSE +21 -0
- package/README.md +137 -0
- package/README.zh.md +137 -0
- package/package.json +55 -0
- package/schema/deepseek-web-search.config.schema.en.json +186 -0
- package/schema/deepseek-web-search.config.schema.zh.json +186 -0
- package/src/cache.ts +45 -0
- package/src/config-schema.ts +176 -0
- package/src/config.ts +289 -0
- package/src/deepseek.ts +272 -0
- package/src/format.ts +159 -0
- package/src/index.ts +193 -0
- package/src/provider.ts +109 -0
- package/src/render.ts +67 -0
- package/src/types.ts +21 -0
package/src/format.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// 结果格式化:组装给 LLM 的 content 与渲染用的 details,包含截断逻辑。
|
|
2
|
+
// 纯函数,便于单元测试。UI 文本一律英文。
|
|
3
|
+
import type { PriceConfig, SearchResult, SearchUsage, WebSearchAction } from "./provider.js";
|
|
4
|
+
|
|
5
|
+
export interface ToolContentOptions {
|
|
6
|
+
mode: "answer" | "results";
|
|
7
|
+
maxChars: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ToolDetails {
|
|
11
|
+
query: string;
|
|
12
|
+
mode: "answer" | "results";
|
|
13
|
+
searches: WebSearchAction[];
|
|
14
|
+
answer: string;
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
cached: boolean;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
model: string;
|
|
19
|
+
/** 本次搜索的 token 用量(缓存命中时沿用首次搜索的记录) */
|
|
20
|
+
usage?: SearchUsage;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 按字符截断文本,超出时在末尾追加截断标记 */
|
|
24
|
+
export function truncate(text: string, maxChars: number): { text: string; truncated: boolean } {
|
|
25
|
+
if (text.length <= maxChars)
|
|
26
|
+
return { text, truncated: false };
|
|
27
|
+
return { text: `${text.slice(0, maxChars)}\n\n...[truncated]`, truncated: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 搜索动作的展示摘要(过滤掉无意义的空查询) */
|
|
31
|
+
function formatActions(actions: WebSearchAction[]): string[] {
|
|
32
|
+
const lines: string[] = [];
|
|
33
|
+
for (const action of actions) {
|
|
34
|
+
if (action.type === "search" && action.queries != null && action.queries.length > 0) {
|
|
35
|
+
lines.push(`- searched: ${action.queries.join(" | ")}`);
|
|
36
|
+
} else if ((action.type === "open_page" || action.type === "find_in_page") && action.url != null) {
|
|
37
|
+
lines.push(`- ${action.type === "open_page" ? "opened" : "searched in"}: ${action.url}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 组装返回给 LLM 的 content 文本。
|
|
45
|
+
* answer 模式:完整回答 + 搜索动作记录;results 模式:仅压缩后的回答摘要。
|
|
46
|
+
*/
|
|
47
|
+
export function buildToolContent(result: SearchResult, options: ToolContentOptions): {
|
|
48
|
+
content: string;
|
|
49
|
+
truncated: boolean;
|
|
50
|
+
} {
|
|
51
|
+
if (result.answer.trim() === "") {
|
|
52
|
+
return {
|
|
53
|
+
content: `The search returned no usable answer. The search actions executed:${
|
|
54
|
+
result.actions.length > 0 ? `\n${formatActions(result.actions).join("\n")}` : " none"}`,
|
|
55
|
+
truncated: false,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (options.mode === "results") {
|
|
60
|
+
const { text, truncated } = truncate(result.answer, options.maxChars);
|
|
61
|
+
return { content: text, truncated };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { text, truncated } = truncate(result.answer, options.maxChars);
|
|
65
|
+
const actionLines = formatActions(result.actions);
|
|
66
|
+
const content = actionLines.length > 0
|
|
67
|
+
? `${text}\n\nSearch actions:\n${actionLines.join("\n")}`
|
|
68
|
+
: text;
|
|
69
|
+
return { content, truncated };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 组装 details(供 TUI 渲染层使用;answer 与 content 同源,均为截断后文本) */
|
|
73
|
+
export function buildToolDetails(
|
|
74
|
+
result: SearchResult,
|
|
75
|
+
options: { query: string; mode: "answer" | "results"; cached: boolean; durationMs: number },
|
|
76
|
+
maxChars: number,
|
|
77
|
+
): ToolDetails {
|
|
78
|
+
const { text, truncated } = truncate(result.answer, maxChars);
|
|
79
|
+
return {
|
|
80
|
+
query: options.query,
|
|
81
|
+
mode: options.mode,
|
|
82
|
+
searches: result.actions,
|
|
83
|
+
answer: text,
|
|
84
|
+
truncated,
|
|
85
|
+
cached: options.cached,
|
|
86
|
+
durationMs: options.durationMs,
|
|
87
|
+
model: result.model,
|
|
88
|
+
usage: result.usage,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 成本明细(元):input/output/cacheRead 之和为 total */
|
|
93
|
+
export interface CostBreakdown {
|
|
94
|
+
input: number;
|
|
95
|
+
output: number;
|
|
96
|
+
cacheRead: number;
|
|
97
|
+
cacheWrite: number;
|
|
98
|
+
total: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 判断 now 是否处于高峰时段(北京时间每日;与机器时区无关,官方按北京时间计费)。
|
|
103
|
+
* 半开区间 [start, end):09:00 计高峰、12:00 不计,两个时段无缝衔接。
|
|
104
|
+
*/
|
|
105
|
+
export function isPeakTime(now: Date, hours: Array<[string, string]>): boolean {
|
|
106
|
+
// Intl 输出如 "09:30"(en-US hour12:false 可能有前导空格或 "24:00" 表示午夜),归一化为 HH:MM
|
|
107
|
+
let hhmm = new Intl.DateTimeFormat("en-US", {
|
|
108
|
+
timeZone: "Asia/Shanghai",
|
|
109
|
+
hour: "2-digit",
|
|
110
|
+
minute: "2-digit",
|
|
111
|
+
hour12: false,
|
|
112
|
+
}).format(now);
|
|
113
|
+
hhmm = hhmm.replace(/\s+/g, "").padStart(5, "0");
|
|
114
|
+
if (hhmm === "24:00")
|
|
115
|
+
hhmm = "00:00";
|
|
116
|
+
return hours.some(([start, end]) => hhmm >= start && hhmm < end);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 取当前模型生效的价格:models 表命中则返回"顶层为底 + 覆盖价叠加"的结果,否则返回顶层。
|
|
121
|
+
* 返回不含 models 字段(已解析),可直接传给 computeCost。
|
|
122
|
+
*/
|
|
123
|
+
export function resolvePrices(prices: PriceConfig, model: string): PriceConfig {
|
|
124
|
+
const override = prices.models?.[model];
|
|
125
|
+
if (override == null)
|
|
126
|
+
return prices;
|
|
127
|
+
return {
|
|
128
|
+
inputPerMillion: override.inputPerMillion ?? prices.inputPerMillion,
|
|
129
|
+
cachedInputPerMillion: override.cachedInputPerMillion ?? prices.cachedInputPerMillion,
|
|
130
|
+
outputPerMillion: override.outputPerMillion ?? prices.outputPerMillion,
|
|
131
|
+
peak: override.peak ?? prices.peak,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 按价格配置计算成本(元)。
|
|
137
|
+
* cachedTokens 是 inputTokens 的子集:未命中部分 = input - cached,命中部分按缓存单价。
|
|
138
|
+
* 配置了 peak 且 now 处于高峰时段时,所有单价 × multiplier。
|
|
139
|
+
*/
|
|
140
|
+
export function computeCost(usage: SearchUsage, prices: PriceConfig, now: Date = new Date()): CostBreakdown {
|
|
141
|
+
const peakMultiplier
|
|
142
|
+
= prices.peak != null && isPeakTime(now, prices.peak.hours) ? prices.peak.multiplier : 1;
|
|
143
|
+
const uncachedInput = Math.max(usage.inputTokens - usage.cachedTokens, 0);
|
|
144
|
+
const input = (uncachedInput / 1_000_000) * prices.inputPerMillion * peakMultiplier;
|
|
145
|
+
const cacheRead = (usage.cachedTokens / 1_000_000) * prices.cachedInputPerMillion * peakMultiplier;
|
|
146
|
+
const output = (usage.outputTokens / 1_000_000) * prices.outputPerMillion * peakMultiplier;
|
|
147
|
+
return {
|
|
148
|
+
input,
|
|
149
|
+
output,
|
|
150
|
+
cacheRead,
|
|
151
|
+
cacheWrite: 0,
|
|
152
|
+
total: input + output + cacheRead,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 工具入参归一化:query 去除首尾空白 */
|
|
157
|
+
export function normalizeQuery(query: string): string {
|
|
158
|
+
return query.trim();
|
|
159
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SearchConfig } from "./config.js";
|
|
3
|
+
import type { PriceConfig, SearchProvider, SearchResult, SearchUsage } from "./provider.js";
|
|
4
|
+
// 入口文件:注册 deepseek_web_search 工具。
|
|
5
|
+
// 依赖链:config(配置)→ provider(搜索后端端口)→ deepseek(DeepSeek 适配器)→ cache(TTL 缓存)→ format(结果组装)。
|
|
6
|
+
// 组合点 createProvider 是本扩展唯一的后端接入位置:换/加搜索后端只改这里。
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { TtlCache } from "./cache.js";
|
|
11
|
+
import { CONFIG_FILE_NAME, ConfigProvider, GLOBAL_CONFIG_DIR, validateConfig } from "./config.js";
|
|
12
|
+
import { DeepSeekClient } from "./deepseek.js";
|
|
13
|
+
import { buildToolContent, buildToolDetails, computeCost, normalizeQuery, resolvePrices } from "./format.js";
|
|
14
|
+
import { SearchError } from "./provider.js";
|
|
15
|
+
import { renderCall, renderResult } from "./render.js";
|
|
16
|
+
import { deepseekWebSearchSchema } from "./types.js";
|
|
17
|
+
|
|
18
|
+
export type { ConfigFileShape, SearchConfig } from "./config.js";
|
|
19
|
+
export type { SearchError, SearchErrorCode, SearchProvider, SearchResult, WebSearchAction } from "./provider.js";
|
|
20
|
+
export type { DeepSeekWebSearchDetails, DeepSeekWebSearchInput } from "./types.js";
|
|
21
|
+
|
|
22
|
+
/** 搜索缓存单例:模块级共享,reload 后随模块重置 */
|
|
23
|
+
let sharedCache: TtlCache<SearchResult> | undefined;
|
|
24
|
+
|
|
25
|
+
/** 组合点:把配置装配成 SearchProvider。换/加后端时只改这里(未来加 provider 配置字段后改为 switch + assertNever 分发) */
|
|
26
|
+
function createProvider(config: SearchConfig): SearchProvider {
|
|
27
|
+
return new DeepSeekClient({
|
|
28
|
+
baseUrl: config.baseUrl,
|
|
29
|
+
apiKey: config.apiKey,
|
|
30
|
+
model: config.model,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** SearchUsage → pi 的 Usage(成本按价格配置实时计算,默认官方平峰价) */
|
|
35
|
+
function toPiUsage(usage: SearchUsage, prices: PriceConfig) {
|
|
36
|
+
return {
|
|
37
|
+
input: usage.inputTokens,
|
|
38
|
+
output: usage.outputTokens,
|
|
39
|
+
cacheRead: usage.cachedTokens,
|
|
40
|
+
cacheWrite: 0,
|
|
41
|
+
reasoning: usage.reasoningTokens,
|
|
42
|
+
totalTokens: usage.totalTokens,
|
|
43
|
+
cost: computeCost(usage, prices),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function (pi: ExtensionAPI): void {
|
|
48
|
+
pi.registerTool({
|
|
49
|
+
name: "deepseek_web_search",
|
|
50
|
+
label: "DeepSeek Web Search",
|
|
51
|
+
description:
|
|
52
|
+
"Search the web via the DeepSeek API. The search runs server-side and the response contains a synthesized answer with citations plus a record of the search actions. Use for up-to-date, time-sensitive, or external information.",
|
|
53
|
+
promptSnippet: "Search the web via DeepSeek for up-to-date or external information",
|
|
54
|
+
promptGuidelines: [
|
|
55
|
+
"Use deepseek_web_search when the user asks about time-sensitive or external information that is not available in the local context.",
|
|
56
|
+
"After a deepseek_web_search call, base your final answer on the returned content and cite the sources listed in it.",
|
|
57
|
+
],
|
|
58
|
+
parameters: deepseekWebSearchSchema,
|
|
59
|
+
renderCall,
|
|
60
|
+
renderResult,
|
|
61
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
62
|
+
const startedAt = Date.now();
|
|
63
|
+
const query = normalizeQuery(params.query);
|
|
64
|
+
if (query === "") {
|
|
65
|
+
throw new Error("Query must not be empty.");
|
|
66
|
+
}
|
|
67
|
+
const mode = params.mode ?? "answer";
|
|
68
|
+
|
|
69
|
+
// 每次执行读取配置(ConfigProvider 内部有进程内缓存,读取开销小)
|
|
70
|
+
const configProvider = new ConfigProvider({ cwd: ctx.cwd });
|
|
71
|
+
const config = await configProvider.get();
|
|
72
|
+
const configError = validateConfig(config);
|
|
73
|
+
if (configError != null) {
|
|
74
|
+
throw new Error(configError);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 首个使用点按配置的 TTL 创建缓存单例
|
|
78
|
+
sharedCache ??= new TtlCache<SearchResult>(config.cacheTtlMs);
|
|
79
|
+
const cache = sharedCache;
|
|
80
|
+
|
|
81
|
+
const cacheKey = `${config.model}:${mode}:${query}`;
|
|
82
|
+
const cachedResult = cache.get(cacheKey);
|
|
83
|
+
if (cachedResult != null) {
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: "text",
|
|
88
|
+
text: buildToolContent(cachedResult, { mode, maxChars: config.maxResultChars }).content,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
details: buildToolDetails(cachedResult, { query, mode, cached: true, durationMs: 0 }, config.maxResultChars),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const provider = createProvider(config);
|
|
96
|
+
|
|
97
|
+
onUpdate?.({
|
|
98
|
+
content: [{ type: "text", text: "Searching the web..." }],
|
|
99
|
+
details: { stage: "searching" },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const result = await provider.search(query, {
|
|
104
|
+
reasoningEffort: config.reasoningEffort,
|
|
105
|
+
maxOutputTokens: config.maxOutputTokens,
|
|
106
|
+
timeoutMs: config.timeoutMs,
|
|
107
|
+
signal,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
onUpdate?.({
|
|
111
|
+
content: [{ type: "text", text: "Synthesizing answer..." }],
|
|
112
|
+
details: { stage: "answering" },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
cache.set(cacheKey, result);
|
|
116
|
+
const durationMs = Date.now() - startedAt;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: "text",
|
|
122
|
+
text: buildToolContent(result, { mode, maxChars: config.maxResultChars }).content,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
details: buildToolDetails(result, { query, mode, cached: false, durationMs }, config.maxResultChars),
|
|
126
|
+
usage: result.usage ? toPiUsage(result.usage, resolvePrices(config.prices, config.model)) : undefined,
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error instanceof SearchError && error.code === "aborted") {
|
|
130
|
+
// 用户取消:不标记为错误,返回说明文本
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: "text", text: "The web search was cancelled." }],
|
|
133
|
+
details: { query, mode, cancelled: true },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// /deepseek-search 命令:查看配置状态 / 清空缓存。无 UI 模式(print/rpc)下仅依赖 notify,不调 dialog。
|
|
142
|
+
pi.registerCommand("deepseek-search", {
|
|
143
|
+
description: "Show config status or clear the search cache. Usage: /deepseek-search [status|clear-cache|config]",
|
|
144
|
+
getArgumentCompletions: prefix =>
|
|
145
|
+
["status", "clear-cache", "config"]
|
|
146
|
+
.filter(action => action.startsWith(prefix))
|
|
147
|
+
.map(action => ({ value: action, label: action })),
|
|
148
|
+
handler: async (args, ctx) => {
|
|
149
|
+
const action = args.trim() || "status";
|
|
150
|
+
|
|
151
|
+
if (action === "clear-cache") {
|
|
152
|
+
sharedCache?.clear();
|
|
153
|
+
ctx.ui.notify("deepseek-search: cache cleared", "info");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (action === "config") {
|
|
157
|
+
const globalPath = join(homedir(), GLOBAL_CONFIG_DIR, CONFIG_FILE_NAME);
|
|
158
|
+
// 持久可见输出:状态查看类信息不适合 notify(会淡出且截断),按官方最佳实践走 sendMessage(display: true)
|
|
159
|
+
pi.sendMessage({
|
|
160
|
+
customType: "deepseek-search",
|
|
161
|
+
content: `Config files:\n project: ${join(ctx.cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME)}\n global: ${globalPath}`,
|
|
162
|
+
display: true,
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (action !== "status") {
|
|
167
|
+
ctx.ui.notify(`Unknown action "${action}". Usage: /deepseek-search [status|clear-cache|config]`, "error");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const configProvider = new ConfigProvider({ cwd: ctx.cwd });
|
|
172
|
+
const config = await configProvider.get();
|
|
173
|
+
const keyMasked = config.apiKey
|
|
174
|
+
? `${config.apiKey.slice(0, 3)}...${config.apiKey.slice(-4)}`
|
|
175
|
+
: "not configured";
|
|
176
|
+
const priceSummary = `${config.prices.inputPerMillion}/${config.prices.cachedInputPerMillion}/${config.prices.outputPerMillion}`
|
|
177
|
+
+ ` CNY per 1M tokens (input miss/hit/output)${
|
|
178
|
+
config.prices.peak != null ? ", peak pricing enabled" : ""}`;
|
|
179
|
+
pi.sendMessage({
|
|
180
|
+
customType: "deepseek-search",
|
|
181
|
+
content: [
|
|
182
|
+
`API key: ${keyMasked}`,
|
|
183
|
+
`Model: ${config.model}`,
|
|
184
|
+
`Base URL: ${config.baseUrl}`,
|
|
185
|
+
`Reasoning effort: ${config.reasoningEffort}`,
|
|
186
|
+
`Cache entries: ${sharedCache?.size() ?? 0}`,
|
|
187
|
+
`Prices: ${priceSummary}`,
|
|
188
|
+
].join("\n"),
|
|
189
|
+
display: true,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// provider.ts —— 搜索后端抽象(消费者拥有:按业务需要设计,而非按厂商 API)。
|
|
2
|
+
// 纯类型 + 错误类,零 IO、零 src 内 import;未来新增后端(如 Anthropic)只实现 SearchProvider。
|
|
3
|
+
export type ReasoningEffort = "off" | "low" | "high" | "auto";
|
|
4
|
+
|
|
5
|
+
/** 搜索选项:由调用方从配置映射而来,各后端按需使用 */
|
|
6
|
+
export interface SearchOptions {
|
|
7
|
+
/** 推理强度(off 关闭思考以省 token) */
|
|
8
|
+
reasoningEffort?: ReasoningEffort;
|
|
9
|
+
/** 回答最大输出 token 数 */
|
|
10
|
+
maxOutputTokens?: number;
|
|
11
|
+
/** 单次请求超时(毫秒) */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
/** 外部取消信号(用户取消时透传) */
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 服务端执行的一次搜索动作 */
|
|
18
|
+
export interface WebSearchAction {
|
|
19
|
+
type: "search" | "open_page" | "find_in_page";
|
|
20
|
+
/** search 动作的查询词(已过滤 ws_call_id= 噪声条目) */
|
|
21
|
+
queries?: string[];
|
|
22
|
+
/** open_page 动作的 URL(已剥离 #ws_call_id= 后缀) */
|
|
23
|
+
url?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** token 用量统计(映射到 pi Usage 的输入) */
|
|
27
|
+
export interface SearchUsage {
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
reasoningTokens: number;
|
|
31
|
+
cachedTokens: number;
|
|
32
|
+
totalTokens: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 一次搜索的产物:综合回答 + 动作记录 */
|
|
36
|
+
export interface SearchResult {
|
|
37
|
+
/** final_answer message 的拼接文本(可能为空串,此时说明响应异常) */
|
|
38
|
+
answer: string;
|
|
39
|
+
/** 服务端执行的搜索动作序列 */
|
|
40
|
+
actions: WebSearchAction[];
|
|
41
|
+
usage?: SearchUsage;
|
|
42
|
+
model: string;
|
|
43
|
+
id?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 峰谷定价覆盖(可选)。官方定义:高峰 = 平时 × multiplier,时段为北京时间每日 */
|
|
47
|
+
export interface PeakPriceConfig {
|
|
48
|
+
/** 高峰单价 = 平峰单价 × multiplier */
|
|
49
|
+
multiplier: number;
|
|
50
|
+
/** 高峰时段列表(北京时间每日),半开区间 [start, end),如 [["09:00", "12:00"], ["14:00", "18:00"]] */
|
|
51
|
+
hours: Array<[string, string]>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 按模型覆盖的价格(models 表的值;字段可选 = 部分覆盖,缺省项回退顶层价格;禁止再嵌套 models) */
|
|
55
|
+
export interface ModelPriceConfig {
|
|
56
|
+
/** 输入单价(缓存未命中) */
|
|
57
|
+
inputPerMillion?: number;
|
|
58
|
+
/** 输入单价(缓存命中) */
|
|
59
|
+
cachedInputPerMillion?: number;
|
|
60
|
+
/** 输出单价 */
|
|
61
|
+
outputPerMillion?: number;
|
|
62
|
+
/** 峰谷覆盖:不配置 = 回退顶层 peak */
|
|
63
|
+
peak?: PeakPriceConfig;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 价格配置:元/百万 tokens(依据官方定价页 2026-08,deepseek-v4-flash 为 1 / 0.02 / 2) */
|
|
67
|
+
export interface PriceConfig {
|
|
68
|
+
/** 输入单价(缓存未命中) */
|
|
69
|
+
inputPerMillion: number;
|
|
70
|
+
/** 输入单价(缓存命中) */
|
|
71
|
+
cachedInputPerMillion: number;
|
|
72
|
+
/** 输出单价 */
|
|
73
|
+
outputPerMillion: number;
|
|
74
|
+
/** 峰谷覆盖:不配置 = 全时段平峰价(官方执行峰谷前的现状) */
|
|
75
|
+
peak?: PeakPriceConfig;
|
|
76
|
+
/** 按模型覆盖的价格表(key = 模型名,命中优先;整块覆盖,不逐 key 合并) */
|
|
77
|
+
models?: Record<string, ModelPriceConfig>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 搜索失败原因(判别联合,供上层按类型处理) */
|
|
81
|
+
export type SearchErrorCode
|
|
82
|
+
= | "invalid_api_key"
|
|
83
|
+
| "insufficient_balance"
|
|
84
|
+
| "rate_limited"
|
|
85
|
+
| "server_error"
|
|
86
|
+
| "network_error"
|
|
87
|
+
| "timeout"
|
|
88
|
+
| "aborted"
|
|
89
|
+
| "invalid_request"
|
|
90
|
+
| "unknown";
|
|
91
|
+
|
|
92
|
+
/** 搜索失败的错误(code 为判别联合,上层据此区分取消/限流/余额等场景) */
|
|
93
|
+
export class SearchError extends Error {
|
|
94
|
+
code: SearchErrorCode;
|
|
95
|
+
|
|
96
|
+
constructor(message: string, code: SearchErrorCode) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.name = "SearchError";
|
|
99
|
+
this.code = code;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 搜索后端抽象:业务只依赖该接口,具体实现(如 DeepSeek /responses)是适配器 */
|
|
104
|
+
export interface SearchProvider {
|
|
105
|
+
/** 稳定标识,写入 details 供用户区分后端,如 "deepseek-responses" */
|
|
106
|
+
readonly id: string;
|
|
107
|
+
/** 执行一次联网搜索,返回综合回答与搜索动作记录 */
|
|
108
|
+
search: (query: string, options: SearchOptions) => Promise<SearchResult>;
|
|
109
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// render.ts —— TUI 渲染层:renderCall/renderResult(纯展示,不参与核心逻辑;主题一律取自 theme 回调)。
|
|
2
|
+
// 注意:不直接依赖 @earendil-works/pi-agent-core(非直接依赖,pnpm 下解析不到);
|
|
3
|
+
// 用本地结构化类型描述工具结果,参数逆变保证与 ToolDefinition 的签名兼容。
|
|
4
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { DeepSeekWebSearchDetails, DeepSeekWebSearchInput } from "./types.js";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
/** 工具结果的结构化视图(与 AgentToolResult 兼容的最小面) */
|
|
10
|
+
interface RenderableToolResult<T> {
|
|
11
|
+
content: Array<{ type: string; text?: string }>;
|
|
12
|
+
details: T;
|
|
13
|
+
isError?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 展开态回答的最大显示字符数(渲染层限长,不影响返回给模型的内容) */
|
|
17
|
+
const EXPANDED_ANSWER_CHARS = 2000;
|
|
18
|
+
|
|
19
|
+
/** 工具行:标题 + 引号 query */
|
|
20
|
+
export function renderCall(args: DeepSeekWebSearchInput, theme: Theme): Component {
|
|
21
|
+
const title = theme.fg("toolTitle", theme.bold("deepseek_web_search "));
|
|
22
|
+
const query = theme.fg("dim", `"${args.query}"`);
|
|
23
|
+
return new Text(`${title}${query}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 工具结果行:执行中(partial)→ 错误态 → 完成(折叠摘要 / 展开详情) */
|
|
27
|
+
export function renderResult(
|
|
28
|
+
result: RenderableToolResult<Partial<DeepSeekWebSearchDetails> | { stage?: string }>,
|
|
29
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
30
|
+
theme: Theme,
|
|
31
|
+
): Component {
|
|
32
|
+
const contentText = result.content[0]?.type === "text" ? (result.content[0].text ?? "") : "";
|
|
33
|
+
const details = result.details as Partial<DeepSeekWebSearchDetails> | undefined;
|
|
34
|
+
|
|
35
|
+
// 执行中:onUpdate 推送的阶段文本("Searching the web..." / "Synthesizing answer...")
|
|
36
|
+
if (options.isPartial) {
|
|
37
|
+
return new Text(theme.fg("dim", contentText));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 错误态:details.error 优先,其次 content 文本
|
|
41
|
+
if (result.isError) {
|
|
42
|
+
const error = (result.details as { error?: unknown } | undefined)?.error;
|
|
43
|
+
const message = typeof error === "string" ? error : (contentText || "Search failed");
|
|
44
|
+
return new Text(theme.fg("error", `✗ ${message}`));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 折叠态:成功标记 + 摘要(搜索次数、回答字符数、截断标记)
|
|
48
|
+
if (!options.expanded) {
|
|
49
|
+
const searches = details?.searches?.length ?? 0;
|
|
50
|
+
const chars = details?.answer?.length ?? 0;
|
|
51
|
+
const truncated = details?.truncated ? " [truncated]" : "";
|
|
52
|
+
return new Text(theme.fg("success", `✓ ${searches} searches · ${chars} chars${truncated}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 展开态:回答全文(渲染限长)+ 搜索动作列表(dim)
|
|
56
|
+
const answer = (details?.answer ?? "").slice(0, EXPANDED_ANSWER_CHARS);
|
|
57
|
+
const actionLines: string[] = [];
|
|
58
|
+
for (const action of details?.searches ?? []) {
|
|
59
|
+
if (action.type === "search" && action.queries != null && action.queries.length > 0) {
|
|
60
|
+
actionLines.push(theme.fg("dim", `- searched: ${action.queries.join(" | ")}`));
|
|
61
|
+
} else if ((action.type === "open_page" || action.type === "find_in_page") && action.url != null) {
|
|
62
|
+
actionLines.push(theme.fg("dim", `- ${action.type === "open_page" ? "opened" : "searched in"}: ${action.url}`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const actionsText = actionLines.length > 0 ? `\n${actionLines.join("\n")}` : "";
|
|
66
|
+
return new Text(`${answer}${actionsText}`);
|
|
67
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Static } from "typebox";
|
|
2
|
+
// 工具输入/输出类型:导出 schema 与输入类型,供 isToolCallEventType 类型化与渲染层使用。
|
|
3
|
+
import type { ToolDetails } from "./format.js";
|
|
4
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
|
|
7
|
+
/** deepseek_web_search 工具参数 schema */
|
|
8
|
+
export const deepseekWebSearchSchema = Type.Object({
|
|
9
|
+
query: Type.String({ description: "The search query." }),
|
|
10
|
+
mode: Type.Optional(
|
|
11
|
+
StringEnum(["answer", "results"] as const, { description: "answer: full synthesized answer with citations; results: compact answer only." }),
|
|
12
|
+
),
|
|
13
|
+
maxResults: Type.Optional(
|
|
14
|
+
Type.Number({ description: "Hint for the number of sources to cite (informational only)." }),
|
|
15
|
+
),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type DeepSeekWebSearchInput = Static<typeof deepseekWebSearchSchema>;
|
|
19
|
+
|
|
20
|
+
/** 工具返回的 details 结构(供 TUI 渲染与调试)——由 format.ToolDetails 承担,此处保留别名以维持公共 API */
|
|
21
|
+
export type DeepSeekWebSearchDetails = ToolDetails;
|