hiwork-core 0.0.24 → 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,123 @@
1
+ import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web';
2
+ /** 本 provider 注册进 `ctx.web` 用的稳定 id(cordis.patch.yml 的 searchProvider pin 与它逐字一致)。 */
3
+ export declare const HIWORK_SEARCH_PROVIDER_ID = "hiwork-managed-search";
4
+ /** 网关侧端点(跨仓冻结;会话鉴权与 `/api/me/memory-config` 同一条)。 */
5
+ export declare const ME_WEB_SEARCH_PATH = "/api/me/web-search";
6
+ /** 出网超时。网关侧自持 10s 上游预算;这里取 12s 留出网关排队的余量,仍远小于 tool-web 的 60s 工具预算。 */
7
+ export declare const WEB_SEARCH_TIMEOUT_MS = 12000;
8
+ /**
9
+ * 网关侧原始响应(**snake_case**,跨仓冻结契约;本文件是唯一读它的地方)。
10
+ * 仅供类型自证 —— 真正的读取走宽容投影 {@link projectWebSearch}。
11
+ */
12
+ export interface GatewayWebSearchResponse {
13
+ query: string;
14
+ /** 产出结果的服务商(排障线索;本模块不按它分支 —— 换服务商正是要隐藏的变化)。 */
15
+ provider: string;
16
+ results: GatewayWebSearchResultItem[];
17
+ }
18
+ export interface GatewayWebSearchResultItem {
19
+ title?: string;
20
+ url: string;
21
+ snippet?: string;
22
+ content?: string;
23
+ published_at?: string;
24
+ }
25
+ /**
26
+ * 传输层结果:失败是数据而不是异常(gateway 端口**从不抛错**)。
27
+ * - `kind: 'http'`:拿到了非 2xx;`code` 是网关冻结错误码(body 里有就带上,
28
+ * 供 provider 分「关闭 / 未配置 / 超限」三档);
29
+ * - `kind: 'aborted'`:调用方取消(seam 的取消契约要求映射回 `WEB_ABORTED`);
30
+ * - `kind: 'network'`:超时 / 连接失败 / DNS;
31
+ * - `kind: 'invalid'`:2xx 但响应体不是可用的 JSON 对象(契约破坏)。
32
+ */
33
+ export type WebSearchOutcome = {
34
+ readonly ok: true;
35
+ readonly body: unknown;
36
+ } | {
37
+ readonly ok: false;
38
+ readonly kind: 'http';
39
+ readonly status: number;
40
+ readonly code?: string;
41
+ } | {
42
+ readonly ok: false;
43
+ readonly kind: 'aborted';
44
+ } | {
45
+ readonly ok: false;
46
+ readonly kind: 'network';
47
+ } | {
48
+ readonly ok: false;
49
+ readonly kind: 'invalid';
50
+ };
51
+ /** 网关客户端的 web-search 子集:只声明本模块真正调用的方法(便于单测打桩)。 */
52
+ export interface WebSearchGatewayPort {
53
+ searchWeb(sessionCookie: string, body: {
54
+ query: string;
55
+ max_results?: number;
56
+ }, signal?: AbortSignal): Promise<WebSearchOutcome>;
57
+ }
58
+ /** Host 侧依赖:会话解析 + 当前 baseURL 下的网关出网端口。 */
59
+ export interface WebSearchProviderDeps {
60
+ /**
61
+ * 解析网关会话 cookie。未登录 / 会话缺失返回 undefined。
62
+ * **返回值只在本模块内部用于出网**,绝不进日志与错误消息。
63
+ */
64
+ resolveSession(): Promise<string | undefined>;
65
+ /** 当前 baseURL 对应的网关出网端口;baseURL 未配置时返回 undefined。 */
66
+ gateway(): WebSearchGatewayPort | undefined;
67
+ /** Host 日志出口(只记事件与异常类型名,不记 query / URL / 响应内容)。 */
68
+ log?: ((message: string) => void) | undefined;
69
+ }
70
+ /**
71
+ * HiWork 受管搜索 provider。`available()` 恒 true(同步、无网络 —— DSH 契约):
72
+ * 会话是否可用要到 `search()` 时才知道,那时按 {@link WebError} 的稳定 code 上报,
73
+ * 而不是假装「不可用」让 seam 报一个笼统的 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`。
74
+ */
75
+ export declare class HiworkSearchProvider implements WebSearchProvider {
76
+ private readonly deps;
77
+ readonly id = "hiwork-managed-search";
78
+ constructor(deps: WebSearchProviderDeps);
79
+ available(): boolean;
80
+ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
81
+ }
82
+ /** 未登录 / 会话失效(网关 401,或本地就没有会话)。 */
83
+ export declare const HIWORK_SEARCH_UNAUTHENTICATED = "HIWORK_SEARCH_UNAUTHENTICATED";
84
+ /** 管理员还没开启联网搜索(网关 503 SEARCH_DISABLED)。 */
85
+ export declare const HIWORK_SEARCH_DISABLED = "HIWORK_SEARCH_DISABLED";
86
+ /** 网关没配上游(网关 503 SEARCH_NOT_CONFIGURED;找运维,不是员工能解决的)。 */
87
+ export declare const HIWORK_SEARCH_NOT_CONFIGURED = "HIWORK_SEARCH_NOT_CONFIGURED";
88
+ /** 当日次数用完(网关 429 SEARCH_LIMIT_EXCEEDED)。 */
89
+ export declare const HIWORK_SEARCH_LIMIT = "HIWORK_SEARCH_LIMIT";
90
+ /** 网关不可达 / 超时 / 宿主故障(可重试)。 */
91
+ export declare const HIWORK_SEARCH_UNREACHABLE = "HIWORK_SEARCH_UNREACHABLE";
92
+ /** 网关代打上游失败(网关 502 SEARCH_UPSTREAM_ERROR;可重试)。 */
93
+ export declare const HIWORK_SEARCH_UPSTREAM = "HIWORK_SEARCH_UPSTREAM";
94
+ /**
95
+ * 响应体 → 归一化结果。
96
+ *
97
+ * `results` 缺失或不是数组返回 undefined(契约破坏,调用方报 provider error);
98
+ * 单条缺 `url` 的**丢弃**(没法被引用),缺 title/snippet/published_at 的保留其余
99
+ * 字段(DSH 侧全是可选语义)。`content`(上游网页原文片段)在 snippet 缺席时兜底 ——
100
+ * 两个都是正文片段,给模型看比给一个空引用强。
101
+ */
102
+ export declare function projectWebSearch(body: Record<string, unknown>): WebSearchResult | undefined;
103
+ /**
104
+ * 本端点的出网实现(形状与 `createKnowledgeConfigGateway` 同款:user-agent 带包版本、
105
+ * 会话 cookie 走 `cookie` 头、外部 signal 与超时经 `AbortSignal.any` 合并)。
106
+ *
107
+ * **永不抛异常**:失败收敛成 {@link WebSearchOutcome}。取消(外部 signal 先触发)单独
108
+ * 分档 —— seam 的取消契约要求它以 `WEB_ABORTED` 上抛,不能被折进「网络故障」。
109
+ *
110
+ * `fetchImpl` / `timeoutMs` 仅用于测试注入。
111
+ */
112
+ export declare function createWebSearchGateway(baseURL: string, opts?: {
113
+ timeoutMs?: number;
114
+ fetchImpl?: typeof fetch;
115
+ }): WebSearchGatewayPort;
116
+ /**
117
+ * 把 provider 注册进 `ctx.web`。注册即挂 fiber(disposer 由 dsh-web 自己挂,见
118
+ * `WebRuntime.registerProvider`),与 web-search-exa 插件的 apply 同款。
119
+ * 依赖 `web` 服务在 inject 里声明(dsh-base 基座先于本插件挂载 dsh-web)。
120
+ */
121
+ export declare function registerManagedSearchProvider(web: {
122
+ registerSearchProvider(provider: WebSearchProvider): () => void;
123
+ }, deps: WebSearchProviderDeps): () => void;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `/hiwork-voice` loopback RPC 的 Host 实现(浏览器侧话筒录音 → 识别文本的唯一入口)。
3
+ *
4
+ * 设计要点(与 `src/daily-rpc.ts` / `src/usage-rpc.ts` 完全同构):
5
+ * - 只注册一个 channel,endpoint 白名单在 {@link VOICE_RPC_ENDPOINTS},未知 endpoint
6
+ * 直接 `bad-request`;
7
+ * - payload 严格校验:`status` 只接受空对象,`transcribe` 只接受 `{ audioBase64: 非空字符串 }`,
8
+ * 任何其它字段 / 类型不符一律 `bad-request`;
9
+ * - **网关会话 cookie 绝不离开 Host**:调用方只拿到端点结果,cookie 只在
10
+ * `resolveSession()` 内部出现;
11
+ * - 网关 `GatewayError` 的稳定 code 原样透传,但 message 先经
12
+ * {@link displayVoiceGatewayMessage} 消毒——baseURL / HTTP 状态码这类实现细节不进 UI;
13
+ * - 其余异常收敛为通用失败(code `internal`),Host 只记日志、不把堆栈或原始消息
14
+ * 交给客户端。
15
+ *
16
+ * 两处**与 daily / usage 不同**的语义,都写在这里免得后来的人「对齐」掉:
17
+ * 1. `status` 端点的失败**不是错误信封**——网关故障时回落
18
+ * `{enabled:false, maxDurationSeconds:60}`({@link VOICE_FALLBACK_CONFIG})。
19
+ * 这条端点的调用频率是「页面挂载一次」,对用户而言它只有一个含义:话筒该不该出现。
20
+ * 网关挂了就报一个红字,只会让一个本来就没有的功能显得像坏了。
21
+ * 2. `transcribe` 的入参是**一段 base64 录音**(全链路唯一的大对象,与技能包体同级),
22
+ * 所以它的载荷校验只走白名单 + 非空,不在 Host 侧做 base64 解码或大小判断——
23
+ * 那两件事分别由浏览器半边与网关负责(网关超限回 413)。
24
+ *
25
+ * 消毒逻辑是**同构复制**而非复用 usage/daily 的导出:兜底文案是用量域 / 每日一句域的,
26
+ * 复用会让语音识别的失败吐出别的域的文案;且本仓库的频道模块本就各自自带一份同实现,
27
+ * 保持模块自包含。
28
+ *
29
+ * 本模块不 import cordis 运行时,只依赖结构化的 {@link VoiceRpcContext} 端口,
30
+ * 便于单测直接伪造。
31
+ */
32
+ import { GatewayError, } from "./ai-router-client.js";
33
+ /** RPC 频道;必须与 client 半边 `src/client/voice/voice-data.ts` 的 `VOICE_CHANNEL` 一致(由测试锁定)。 */
34
+ export const VOICE_RPC_CHANNEL = '/hiwork-voice';
35
+ /** 端点白名单:`status`(配置)与 `transcribe`(识别)。 */
36
+ export const VOICE_RPC_ENDPOINTS = ['status', 'transcribe'];
37
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
38
+ export class VoiceRequestError extends Error {
39
+ code;
40
+ constructor(code, message) {
41
+ super(message);
42
+ this.code = code;
43
+ this.name = 'VoiceRequestError';
44
+ }
45
+ }
46
+ /** 各稳定错误码的用户可见文案(网关 message 含实现细节时用它兜底)。 */
47
+ const VOICE_MESSAGE_FALLBACK = {
48
+ SESSION_EXPIRED: '登录状态已过期,请重新登录',
49
+ GATEWAY_UNREACHABLE: '网关暂时不可用,请稍后重试',
50
+ VOICE_DISABLED: '本部署未启用语音识别,请联系管理员',
51
+ VOICE_NOT_CONFIGURED: '本部署尚未配置语音识别服务,请联系管理员',
52
+ VOICE_LIMIT_REACHED: '语音识别请求过于频繁,请稍后再试',
53
+ VOICE_AUDIO_TOO_LARGE: '录音过长,请缩短后重试',
54
+ VOICE_BAD_REQUEST: '录音数据不合法,请重新录制',
55
+ VOICE_UPSTREAM: '语音识别服务暂时不可用,请稍后重试',
56
+ RATE_LIMITED: '请求过于频繁,请稍后再试',
57
+ AUTH_FAILED: '登录状态异常,请重新登录',
58
+ };
59
+ /** 通用失败文案:不暴露任何网关细节。 */
60
+ export const VOICE_GENERIC_FAILURE_MESSAGE = '语音识别暂时无法使用,请稍后重试';
61
+ /** 未登录 / 会话失效的统一文案(与用量频道的 `SESSION_EXPIRED` 口径一致)。 */
62
+ export const VOICE_SESSION_EXPIRED_MESSAGE = '登录状态已过期,请重新登录';
63
+ /**
64
+ * `status` 端点读不到配置时的回落值(**成功信封**,见模块头注释第 1 条):
65
+ * 话筒消失,而不是弹一条用户看不懂的错误。
66
+ */
67
+ export const VOICE_FALLBACK_CONFIG = { enabled: false, maxDurationSeconds: 60 };
68
+ /**
69
+ * 网关 message 消毒(与 `daily-rpc.ts` 的 `displayDailyGatewayMessage` 同实现):
70
+ * 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的
71
+ * 中文业务文案)原样保留。code 不受影响,始终原样透传。
72
+ */
73
+ export function displayVoiceGatewayMessage(code, message) {
74
+ const text = typeof message === 'string' ? message.trim() : '';
75
+ if (text === '')
76
+ return VOICE_MESSAGE_FALLBACK[code] ?? VOICE_GENERIC_FAILURE_MESSAGE;
77
+ const leaksImplementationDetail = /https?:\/\//i.test(text)
78
+ || /\bHTTP\s*\d{3}\b/i.test(text)
79
+ || /\bbaseURL\b/i.test(text);
80
+ if (leaksImplementationDetail)
81
+ return VOICE_MESSAGE_FALLBACK[code] ?? VOICE_GENERIC_FAILURE_MESSAGE;
82
+ return text;
83
+ }
84
+ /**
85
+ * payload 校验:`status` 只放行空对象,`transcribe` 只放行 `{ audioBase64: 非空字符串 }`
86
+ * (纯空白视为空——它不可能是合法录音);其余字段 / 类型不符一律 `bad-request`。
87
+ *
88
+ * **不在这里解析 / 校验 base64 本身**:解码与大小上限分别由浏览器半边与网关负责
89
+ * (网关超限回 413,映射在 `AiRouterClient.transcribeVoice`),Host 侧只做协议形状。
90
+ */
91
+ export function parseVoicePayload(endpoint, raw) {
92
+ if (raw === undefined || raw === null) {
93
+ if (endpoint === 'transcribe')
94
+ throw new VoiceRequestError('bad-request', '语音识别请求缺少 audioBase64。');
95
+ return {};
96
+ }
97
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
98
+ throw new VoiceRequestError('bad-request', '语音识别请求参数不合法。');
99
+ }
100
+ const record = raw;
101
+ const keys = Object.keys(record);
102
+ if (endpoint === 'status') {
103
+ // status 无任何入参:出现任何字段都是客户端实现缺陷
104
+ if (keys.length > 0)
105
+ throw new VoiceRequestError('bad-request', `语音识别请求包含未知字段 '${keys[0]}'。`);
106
+ return {};
107
+ }
108
+ for (const key of keys) {
109
+ if (key !== 'audioBase64')
110
+ throw new VoiceRequestError('bad-request', `语音识别请求包含未知字段 '${key}'。`);
111
+ }
112
+ const rawAudio = record['audioBase64'];
113
+ if (typeof rawAudio !== 'string' || rawAudio.trim() === '') {
114
+ throw new VoiceRequestError('bad-request', '语音识别请求的 audioBase64 必须是非空字符串。');
115
+ }
116
+ return { audioBase64: rawAudio };
117
+ }
118
+ /** 是否为白名单端点。 */
119
+ export function isVoiceRpcEndpoint(endpoint) {
120
+ return VOICE_RPC_ENDPOINTS.includes(endpoint);
121
+ }
122
+ /** 取消判定(用函数包一层,避免 TS 把属性窄化固化到 `await` 之后)。 */
123
+ function isAborted(signal) {
124
+ return signal?.aborted === true;
125
+ }
126
+ /**
127
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
128
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
129
+ *
130
+ * `status` 走 {@link readVoiceConfig} 的**降级**分支:网关故障 → 成功信封里的
131
+ * `enabled:false`。「没有会话」「网关地址未配置」两条**不是**降级——那时还没有任何
132
+ * 功能状态可言(页面本身在登录门后),照常回错误信封。
133
+ */
134
+ export async function handleVoiceEndpoint(endpoint, rawPayload, deps, signal) {
135
+ try {
136
+ if (isAborted(signal))
137
+ return failure('cancelled', '语音识别请求已取消。');
138
+ if (!isVoiceRpcEndpoint(endpoint)) {
139
+ throw new VoiceRequestError('bad-request', `未知的语音识别端点 '${endpoint}'`);
140
+ }
141
+ const payload = parseVoicePayload(endpoint, rawPayload);
142
+ const sessionCookie = await deps.resolveSession();
143
+ if (sessionCookie === undefined || sessionCookie === '') {
144
+ return failure('SESSION_EXPIRED', VOICE_SESSION_EXPIRED_MESSAGE);
145
+ }
146
+ const gateway = deps.gateway();
147
+ if (gateway === undefined) {
148
+ return failure('GATEWAY_UNREACHABLE', '网关地址未配置,请先在「网关模型」里完成登录');
149
+ }
150
+ if (isAborted(signal))
151
+ return failure('cancelled', '语音识别请求已取消。');
152
+ if (endpoint === 'status')
153
+ return { ok: true, value: await readVoiceConfig(gateway, sessionCookie, deps.log) };
154
+ return { ok: true, value: await transcribe(gateway, sessionCookie, payload) };
155
+ }
156
+ catch (error) {
157
+ if (error instanceof VoiceRequestError)
158
+ return failure(error.code, error.message);
159
+ if (error instanceof GatewayError) {
160
+ // code 原样透传(稳定契约),message 消毒后再给客户端。
161
+ return failure(error.code, displayVoiceGatewayMessage(error.code, error.message));
162
+ }
163
+ deps.log?.(`hiwork-core: 语音识别 RPC '${endpoint}' 失败(${error instanceof Error ? error.name : typeof error})`);
164
+ return failure('internal', VOICE_GENERIC_FAILURE_MESSAGE);
165
+ }
166
+ }
167
+ /**
168
+ * `status` 端点取数:**读不到配置就让话筒消失**(返回 {@link VOICE_FALLBACK_CONFIG}),
169
+ * 而不是回错误信封。
170
+ *
171
+ * 这条端点会在页面挂载时被调一次,它的唯一用途是「要不要显示话筒」;网关故障、
172
+ * 会话在途中过期、响应形状不对……对用户而言都只有同一个结论:现在用不了。
173
+ * 抛错误信封会让一个**默认不存在**的功能冒出红字。所以这里把全部异常咽成禁用配置,
174
+ * 只留一条 Host 日志(日志里也不带 cookie / 响应原文)。
175
+ */
176
+ async function readVoiceConfig(gateway, sessionCookie, log) {
177
+ try {
178
+ const config = await gateway.getMyVoiceConfig(sessionCookie);
179
+ // 字段名 / 形状已在网关客户端层收敛(snake_case → camelCase + 缺省兜底),
180
+ // 这里只把「真值才算开」钉死:undefined / 非布尔一律当关。
181
+ return {
182
+ enabled: config.enabled === true,
183
+ maxDurationSeconds: config.maxDurationSeconds > 0 ? config.maxDurationSeconds : VOICE_FALLBACK_CONFIG.maxDurationSeconds,
184
+ };
185
+ }
186
+ catch (error) {
187
+ log?.(`hiwork-core: 语音配置读取失败,按禁用回落(${error instanceof Error ? error.name : typeof error})`);
188
+ return { ...VOICE_FALLBACK_CONFIG };
189
+ }
190
+ }
191
+ /** `transcribe` 端点:只回 `{ text }`(网关响应的其余字段不进频道)。 */
192
+ async function transcribe(gateway, sessionCookie, payload) {
193
+ const audioBase64 = payload.audioBase64;
194
+ // parseVoicePayload 已保证 transcribe 必带非空 audioBase64;这一支是类型层兜底,
195
+ // 真走到这里也只抛 VoiceRequestError(由上层信封收敛,不会逃逸)。
196
+ if (audioBase64 === undefined)
197
+ throw new VoiceRequestError('bad-request', '语音识别请求缺少 audioBase64。');
198
+ const result = await gateway.transcribeVoice(sessionCookie, audioBase64);
199
+ return { text: result.text };
200
+ }
201
+ function failure(code, message) {
202
+ return { ok: false, error: { code, message, details: {} } };
203
+ }
204
+ /**
205
+ * 注册 `/hiwork-voice` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
206
+ */
207
+ export function registerVoiceRpc(ctx, deps) {
208
+ const handler = (endpoint, payload, signal) => handleVoiceEndpoint(endpoint, payload, deps, signal);
209
+ const remove = ctx.connection.rpc.handle(VOICE_RPC_CHANNEL, handler, { authority: 'loopback' });
210
+ return async () => { await remove(); };
211
+ }
@@ -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.24",
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
+ }