hiwork-core 0.0.22 → 0.0.29

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,259 @@
1
+ /**
2
+ * 联网搜索的 DSH `ctx.web` provider(id `hiwork-managed-search`)。
3
+ *
4
+ * 背景:DSH 自带 `web_search` 工具(`tool-web`),但官方搜索提供方
5
+ * `web-search-deepseek` 依赖 `DEEPSEEK_API_KEY`(企业用户没有),本产品在
6
+ * `cordis.patch.yml` 里停用了它。本模块把「搜索后端」接到 HiWork 网关的统一搜索
7
+ * 端点上 —— **服务商(百度千帆 / 将来的其他家)凭据只在网关,客户端对服务商无感知**:
8
+ *
9
+ * ```
10
+ * DSH web_search 工具(tool-web,执行时调 ctx.web.search)
11
+ * ↑ 本文件(hiwork-core 注册的 WebSearchProvider)
12
+ * hiwork-admin POST /api/me/web-search ← 网关统一搜索(持有上游 Key、可换服务商)
13
+ * ```
14
+ *
15
+ * 形状来源:`src/knowledge-config.ts`(同族先例 —— 「网关出网 + 会话 cookie 留在
16
+ * Host」的同一套纪律)。与它的结构性差异只有两处:
17
+ * ① 消费方是 **DSH 的 `ctx.web` seam**,不是插件侧 host 服务 —— 失败按 DSH 契约
18
+ * **抛 `WebError`**(tool-web 把 code 放进工具的结构化错误元数据),而不是返回
19
+ * `{ ok: false }`;取消必须映射成 `WEB_ABORTED`(seam 的取消契约)。
20
+ * ② 无缓存:每次 `web_search` 都是一次实时搜索,缓存它没有意义。
21
+ *
22
+ * 不变量(与 knowledge-config 同口径,不得放宽):
23
+ * - **会话 cookie 与 baseURL 全部留在 Host**:渲染端永远碰不到;本模块不生成、
24
+ * 不保存任何凭据。
25
+ * - **字段名分层只在本文件做**:网关 snake_case(`results[].published_at`,跨仓冻结
26
+ * 契约)→ DSH camelCase(`WebSearchSource.publishedAt`)。
27
+ * - **不造值**:上游没给的 `title` / `snippet` / `publishedAt` 留空 —— DSH 侧对这些
28
+ * 字段是可选语义,编造会让工具输出假引用。
29
+ * - **message 是给员工看的中文**:不含 URL 原文、不含 HTTP 状态码、不含服务商名。
30
+ * - **日志只记事件与异常类型名**:不记 query / URL / 响应内容(query 是用户输入,
31
+ * 与「请求体不进日志」的既有口径一致)。
32
+ *
33
+ * 本模块不 import cordis 运行时:provider 走结构化的 {@link WebSearchProviderDeps}
34
+ * 端口,接线方(index.ts)传真实 ctx 侧依赖;单测直接伪造。
35
+ */
36
+ import { WebError } from '@deepseek-ai/dsh-web';
37
+ import { PKG_VERSION } from "./version.js";
38
+ /** 本 provider 注册进 `ctx.web` 用的稳定 id(cordis.patch.yml 的 searchProvider pin 与它逐字一致)。 */
39
+ export const HIWORK_SEARCH_PROVIDER_ID = 'hiwork-managed-search';
40
+ /** 网关侧端点(跨仓冻结;会话鉴权与 `/api/me/memory-config` 同一条)。 */
41
+ export const ME_WEB_SEARCH_PATH = '/api/me/web-search';
42
+ /** 出网超时。网关侧自持 10s 上游预算;这里取 12s 留出网关排队的余量,仍远小于 tool-web 的 60s 工具预算。 */
43
+ export const WEB_SEARCH_TIMEOUT_MS = 12_000;
44
+ /**
45
+ * HiWork 受管搜索 provider。`available()` 恒 true(同步、无网络 —— DSH 契约):
46
+ * 会话是否可用要到 `search()` 时才知道,那时按 {@link WebError} 的稳定 code 上报,
47
+ * 而不是假装「不可用」让 seam 报一个笼统的 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`。
48
+ */
49
+ export class HiworkSearchProvider {
50
+ deps;
51
+ id = HIWORK_SEARCH_PROVIDER_ID;
52
+ constructor(deps) {
53
+ this.deps = deps;
54
+ }
55
+ available() {
56
+ return true;
57
+ }
58
+ async search(request, signal) {
59
+ if (signal?.aborted)
60
+ throw aborted();
61
+ let sessionCookie;
62
+ try {
63
+ sessionCookie = await this.deps.resolveSession();
64
+ }
65
+ catch (error) {
66
+ // 凭据服务读取失败是宿主故障:按 provider 错误上报,不诱导用户重登。
67
+ this.deps.log?.(`hiwork-core: 联网搜索会话解析失败(${errorName(error)})`);
68
+ throw new WebError('联网搜索暂时不可用,请稍后重试', HIWORK_SEARCH_UNREACHABLE, { cause: error });
69
+ }
70
+ if (sessionCookie === undefined || sessionCookie === '') {
71
+ throw new WebError('登录后才能使用联网搜索', HIWORK_SEARCH_UNAUTHENTICATED);
72
+ }
73
+ const gateway = this.deps.gateway();
74
+ // 没有网关地址 = 还没完成登录(baseURL 是登录时写下的),与网络不通同一档。
75
+ if (gateway === undefined) {
76
+ throw new WebError('联网搜索暂时不可用,请稍后重试', HIWORK_SEARCH_UNREACHABLE);
77
+ }
78
+ const outcome = await gateway.searchWeb(sessionCookie,
79
+ // maxResults 是 tool-web 传进来的上限,透传给网关少拉数据(费用按次不按条,
80
+ // 但少拉条数能压响应体)。seam 仍会在结果侧强制截断,这里只是配合。
81
+ { query: request.query, ...request.maxResults !== undefined ? { max_results: request.maxResults } : {} }, signal);
82
+ if (!outcome.ok) {
83
+ throw outcomeToError(outcome);
84
+ }
85
+ const view = projectWebSearch(outcome.body);
86
+ if (view === undefined) {
87
+ this.deps.log?.('hiwork-core: 网关返回的 web-search 响应不符合契约(results 缺失或形状不对)');
88
+ throw new WebError('联网搜索返回了无法解析的结果', 'WEB_PROVIDER_ERROR');
89
+ }
90
+ return view;
91
+ }
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // 冻结契约的客户端侧映射:网关错误档 → 稳定 WebError code(tool-web 会透出 code)
95
+ // ---------------------------------------------------------------------------
96
+ /** 未登录 / 会话失效(网关 401,或本地就没有会话)。 */
97
+ export const HIWORK_SEARCH_UNAUTHENTICATED = 'HIWORK_SEARCH_UNAUTHENTICATED';
98
+ /** 管理员还没开启联网搜索(网关 503 SEARCH_DISABLED)。 */
99
+ export const HIWORK_SEARCH_DISABLED = 'HIWORK_SEARCH_DISABLED';
100
+ /** 网关没配上游(网关 503 SEARCH_NOT_CONFIGURED;找运维,不是员工能解决的)。 */
101
+ export const HIWORK_SEARCH_NOT_CONFIGURED = 'HIWORK_SEARCH_NOT_CONFIGURED';
102
+ /** 当日次数用完(网关 429 SEARCH_LIMIT_EXCEEDED)。 */
103
+ export const HIWORK_SEARCH_LIMIT = 'HIWORK_SEARCH_LIMIT';
104
+ /** 网关不可达 / 超时 / 宿主故障(可重试)。 */
105
+ export const HIWORK_SEARCH_UNREACHABLE = 'HIWORK_SEARCH_UNREACHABLE';
106
+ /** 网关代打上游失败(网关 502 SEARCH_UPSTREAM_ERROR;可重试)。 */
107
+ export const HIWORK_SEARCH_UPSTREAM = 'HIWORK_SEARCH_UPSTREAM';
108
+ /** 取消必须按 seam 契约映射成 `WEB_ABORTED`,其余自造 code 消费方都能容忍。 */
109
+ function aborted() {
110
+ return new WebError('联网搜索已取消', 'WEB_ABORTED');
111
+ }
112
+ /** 传输失败 → WebError:code 分档(消费方按 code 分类文案),message 是给员工看的中文。 */
113
+ function outcomeToError(outcome) {
114
+ switch (outcome.kind) {
115
+ case 'aborted':
116
+ return aborted();
117
+ case 'network':
118
+ return new WebError('联网搜索暂时不可用,请稍后重试', HIWORK_SEARCH_UNREACHABLE);
119
+ case 'invalid':
120
+ return new WebError('联网搜索返回了无法解析的结果', 'WEB_PROVIDER_ERROR');
121
+ case 'http':
122
+ return httpOutcomeToError(outcome.status, outcome.code);
123
+ }
124
+ }
125
+ /** 非 2xx → WebError:按网关冻结错误码分五档(未知状态兜 provider error)。 */
126
+ function httpOutcomeToError(status, code) {
127
+ if (status === 401)
128
+ return new WebError('登录状态已过期,请重新登录', HIWORK_SEARCH_UNAUTHENTICATED);
129
+ if (code === 'SEARCH_DISABLED')
130
+ return new WebError('管理员还没开启联网搜索', HIWORK_SEARCH_DISABLED);
131
+ if (code === 'SEARCH_NOT_CONFIGURED')
132
+ return new WebError('联网搜索还没有配置好,请联系管理员', HIWORK_SEARCH_NOT_CONFIGURED);
133
+ if (status === 429 || code === 'SEARCH_LIMIT_EXCEEDED')
134
+ return new WebError('今天的联网搜索次数已用完,明天再来', HIWORK_SEARCH_LIMIT);
135
+ if (status === 502 || code === 'SEARCH_UPSTREAM_ERROR')
136
+ return new WebError('搜索服务暂时不可用,请稍后重试', HIWORK_SEARCH_UPSTREAM);
137
+ return new WebError('联网搜索暂时不可用,请稍后重试', 'WEB_PROVIDER_ERROR');
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // 投影:网关 snake_case → DSH WebSearchResult(全仓唯一做这层转换的地方)
141
+ // ---------------------------------------------------------------------------
142
+ /** 裁掉两侧空白;非字符串或空白串返回 undefined。 */
143
+ function textOrUndefined(value) {
144
+ if (typeof value !== 'string')
145
+ return undefined;
146
+ const text = value.trim();
147
+ return text === '' ? undefined : text;
148
+ }
149
+ /**
150
+ * 响应体 → 归一化结果。
151
+ *
152
+ * `results` 缺失或不是数组返回 undefined(契约破坏,调用方报 provider error);
153
+ * 单条缺 `url` 的**丢弃**(没法被引用),缺 title/snippet/published_at 的保留其余
154
+ * 字段(DSH 侧全是可选语义)。`content`(上游网页原文片段)在 snippet 缺席时兜底 ——
155
+ * 两个都是正文片段,给模型看比给一个空引用强。
156
+ */
157
+ export function projectWebSearch(body) {
158
+ const raw = body['results'];
159
+ if (!Array.isArray(raw))
160
+ return undefined;
161
+ const sources = [];
162
+ for (const item of raw) {
163
+ if (item === null || typeof item !== 'object')
164
+ continue;
165
+ const record = item;
166
+ const url = textOrUndefined(record['url']);
167
+ if (url === undefined)
168
+ continue;
169
+ const title = textOrUndefined(record['title']);
170
+ const snippet = textOrUndefined(record['snippet']) ?? textOrUndefined(record['content']);
171
+ const publishedAt = textOrUndefined(record['published_at']);
172
+ sources.push({
173
+ url,
174
+ ...title !== undefined ? { title } : {},
175
+ ...snippet !== undefined ? { snippet } : {},
176
+ ...publishedAt !== undefined ? { publishedAt } : {},
177
+ });
178
+ }
179
+ // 网关不做答案生成(web_search 端点,不是 chat/completions),content 留空。
180
+ // 最终 maxResults 截断由 seam 负责,这里恒 truncated: false。
181
+ return { sources, truncated: false };
182
+ }
183
+ /** 异常类型名(日志只记类型,不记消息,避免把会话相关信息带进日志)。 */
184
+ function errorName(error) {
185
+ return error instanceof Error ? error.name : typeof error;
186
+ }
187
+ /**
188
+ * 本端点的出网实现(形状与 `createKnowledgeConfigGateway` 同款:user-agent 带包版本、
189
+ * 会话 cookie 走 `cookie` 头、外部 signal 与超时经 `AbortSignal.any` 合并)。
190
+ *
191
+ * **永不抛异常**:失败收敛成 {@link WebSearchOutcome}。取消(外部 signal 先触发)单独
192
+ * 分档 —— seam 的取消契约要求它以 `WEB_ABORTED` 上抛,不能被折进「网络故障」。
193
+ *
194
+ * `fetchImpl` / `timeoutMs` 仅用于测试注入。
195
+ */
196
+ export function createWebSearchGateway(baseURL, opts = {}) {
197
+ const root = baseURL.replace(/\/+$/, '');
198
+ const timeoutMs = opts.timeoutMs ?? WEB_SEARCH_TIMEOUT_MS;
199
+ const doFetch = opts.fetchImpl ?? fetch;
200
+ return {
201
+ async searchWeb(sessionCookie, body, signal) {
202
+ const signals = [AbortSignal.timeout(timeoutMs)];
203
+ if (signal !== undefined)
204
+ signals.push(signal);
205
+ const merged = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
206
+ let res;
207
+ try {
208
+ res = await doFetch(`${root}${ME_WEB_SEARCH_PATH}`, {
209
+ method: 'POST',
210
+ headers: {
211
+ 'user-agent': `hiwork-core/${PKG_VERSION}`,
212
+ cookie: sessionCookie,
213
+ 'content-type': 'application/json',
214
+ },
215
+ body: JSON.stringify(body),
216
+ signal: merged,
217
+ });
218
+ }
219
+ catch (error) {
220
+ if (signal?.aborted)
221
+ return { ok: false, kind: 'aborted' };
222
+ // 其余(含超时)一律按网络档;错误原文不进任何返回值。
223
+ return { ok: false, kind: 'network' };
224
+ }
225
+ if (!res.ok) {
226
+ // 非 2xx 也试着读出冻结错误码(网关的错误体是 {code, message}),读不出就算了:
227
+ // code 只是分档的辅助,状态码本身已够兜底。
228
+ let code;
229
+ try {
230
+ const parsed = await res.json();
231
+ if (typeof parsed?.code === 'string' && parsed.code !== '')
232
+ code = parsed.code;
233
+ }
234
+ catch {
235
+ // 错误体不是 JSON(反代 5xx 页面等):状态码兜底。
236
+ }
237
+ return { ok: false, kind: 'http', status: res.status, ...code !== undefined ? { code } : {} };
238
+ }
239
+ let parsed;
240
+ try {
241
+ parsed = await res.json();
242
+ }
243
+ catch {
244
+ return { ok: false, kind: 'invalid' };
245
+ }
246
+ if (parsed === null || typeof parsed !== 'object')
247
+ return { ok: false, kind: 'invalid' };
248
+ return { ok: true, body: parsed };
249
+ },
250
+ };
251
+ }
252
+ /**
253
+ * 把 provider 注册进 `ctx.web`。注册即挂 fiber(disposer 由 dsh-web 自己挂,见
254
+ * `WebRuntime.registerProvider`),与 web-search-exa 插件的 apply 同款。
255
+ * 依赖 `web` 服务在 inject 里声明(dsh-base 基座先于本插件挂载 dsh-web)。
256
+ */
257
+ export function registerManagedSearchProvider(web, deps) {
258
+ return web.registerSearchProvider(new HiworkSearchProvider(deps));
259
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hiwork-core",
3
- "version": "0.0.22",
3
+ "version": "0.0.29",
4
4
  "type": "module",
5
5
  "description": "HiWork 企业统一登录与模型自动供给插件(ai-router 网关登录,Web 表单,无 CLI 认证)",
6
6
  "license": "UNLICENSED",
@@ -34,7 +34,8 @@
34
34
  "@deepseek-ai/dsh-client-ui-settings",
35
35
  "@deepseek-ai/dsh-api-remotes",
36
36
  "@deepseek-ai/dsh-client-ui-primitives",
37
- "@deepseek-ai/dsh-client-ui-chat"
37
+ "@deepseek-ai/dsh-client-ui-chat",
38
+ "@deepseek-ai/dsh-client-ui-conversation"
38
39
  ]
39
40
  }
40
41
  },
@@ -59,6 +60,7 @@
59
60
  "@deepseek-ai/dsh-llm-pi-ai": "0.1.5-rc.1",
60
61
  "@deepseek-ai/dsh-settings": "0.1.5-rc.1",
61
62
  "@deepseek-ai/dsh-settings-file": "0.1.5-rc.1",
63
+ "@deepseek-ai/dsh-web": "0.1.5-rc.1",
62
64
  "@deepseek-ai/dsh-workspace": "0.1.5-rc.1",
63
65
  "@deepseek-ai/schemastery": "3.18.2",
64
66
  "@types/node": "^22",
@@ -79,4 +81,4 @@
79
81
  "build": "npm run build:host && npm run build:client",
80
82
  "test": "vitest run"
81
83
  }
82
- }
84
+ }