hiwork-knowledge 0.1.0 → 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/README.md +133 -39
- package/cordis.patch.yml +19 -2
- package/lib/client.js +1599 -45
- package/lib/index.js +27793 -6
- package/lib/prompt.js +31 -0
- package/lib/protocol.js +139 -0
- package/lib/rpc.js +103 -0
- package/lib/service.js +344 -0
- package/lib/tool-names.js +15 -0
- package/lib/tools.js +244 -0
- package/lib/types/client/KnowledgeView.d.ts +19 -5
- package/lib/types/client/SettingsSection.d.ts +42 -0
- package/lib/types/client/ToolCards.d.ts +26 -0
- package/lib/types/client/composer.d.ts +98 -0
- package/lib/types/client/contracts.d.ts +100 -2
- package/lib/types/client/focus.d.ts +19 -0
- package/lib/types/client/format.d.ts +74 -0
- package/lib/types/client/icons.d.ts +26 -0
- package/lib/types/client/index.d.ts +31 -2
- package/lib/types/client/locales.d.ts +213 -6
- package/lib/types/client/runtime.d.ts +66 -0
- package/lib/types/client/tool-result.d.ts +90 -0
- package/lib/types/index.d.ts +49 -10
- package/lib/types/prompt.d.ts +21 -0
- package/lib/types/protocol.d.ts +200 -0
- package/lib/types/rpc.d.ts +18 -0
- package/lib/types/service.d.ts +142 -0
- package/lib/types/tool-names.d.ts +11 -0
- package/lib/types/tools.d.ts +21 -0
- package/lib/types/types.d.ts +109 -0
- package/lib/types/weknora.d.ts +112 -0
- package/lib/types.js +112 -0
- package/lib/weknora.js +366 -0
- package/package.json +28 -8
package/lib/types.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hiwork-knowledge` Host 半边的领域类型、默认值与存储域定义。
|
|
3
|
+
*
|
|
4
|
+
* 不变量:
|
|
5
|
+
* - 存储域名字必须匹配 `/^[a-z][a-z0-9_]*$/`(不能带连字符),改名等于换一套表;
|
|
6
|
+
* - 凭据(`apiKey`)只在 Host 半边流转:落盘在存储域、下发 WeKnora 时只进请求头,
|
|
7
|
+
* **绝不进日志、绝不回传给 Web 半边**(`toConfigView` 只回 `hasApiKey` 布尔);
|
|
8
|
+
* - 默认值与本节常量一一对应,`cordis.patch.yml` 的 config 只能收紧、不能放宽
|
|
9
|
+
* (例如把 `maxChunkChars` 调大仍受 schema 上限约束)。
|
|
10
|
+
*/
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
/** `ctx.storageDomain.open()` 的域标识;必须保持稳定。 */
|
|
13
|
+
export const KNOWLEDGE_DOMAIN_NAME = 'hiwork_knowledge';
|
|
14
|
+
export const KNOWLEDGE_DOMAIN_VERSION = 1;
|
|
15
|
+
/** 设置只有一条记录,键名固定。 */
|
|
16
|
+
export const KNOWLEDGE_SETTINGS_KEY = 'config';
|
|
17
|
+
/** 默认后端地址:与部署文档里的域名一致(`/api/v1` 会被补齐)。 */
|
|
18
|
+
export const DEFAULT_BASE_URL = 'https://hiwork-knowledge.hivery.cn/api/v1';
|
|
19
|
+
export const DEFAULT_MAX_RESULTS = 8;
|
|
20
|
+
export const DEFAULT_MAX_CHUNK_CHARS = 1_200;
|
|
21
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
22
|
+
export const DEFAULT_CHAT_TIMEOUT_MS = 300_000;
|
|
23
|
+
/** 检索结果条数上限(服务端受 `rerank_threshold` 约束,这里只做客户端截断)。 */
|
|
24
|
+
export const MAX_RESULTS_CEILING = 50;
|
|
25
|
+
/** 单条片段字符上限。 */
|
|
26
|
+
export const MAX_CHUNK_CHARS_CEILING = 8_000;
|
|
27
|
+
export const MAX_BASE_IDS = 50;
|
|
28
|
+
/**
|
|
29
|
+
* 「重排未生效」的分数判据。
|
|
30
|
+
*
|
|
31
|
+
* 2026-09-18 实测(同一后端、同一知识库):
|
|
32
|
+
* - 租户检索配置绑好 `rerank_model_id` 时,命中分数落在 **0.43–0.59**;
|
|
33
|
+
* - 未绑时管线 `Rerank action=skip reason="empty_model_id"`,分数是 RRF 融合值
|
|
34
|
+
* **0.0164**(且任意查询都返回同一个分块)。
|
|
35
|
+
* 因此「最高分 < 0.05」可作为「检索结果未经重排」的稳定信号,用于自检告警。
|
|
36
|
+
*/
|
|
37
|
+
export const RRF_SCORE_CEILING = 0.05;
|
|
38
|
+
/** 自检探针最多检查的知识库数(避免一次自检打穿后端)。 */
|
|
39
|
+
export const SELF_CHECK_BASE_LIMIT = 3;
|
|
40
|
+
export const knowledgeConfigSchema = z
|
|
41
|
+
.object({
|
|
42
|
+
/** WeKnora 后端地址;缺 `/api/v1` 时自动补齐。 */
|
|
43
|
+
baseUrl: z.string().trim().min(1),
|
|
44
|
+
/** 空间 API Key;空字符串表示未配置(匿名部署)。 */
|
|
45
|
+
apiKey: z.string(),
|
|
46
|
+
/** 平台级 Key 才需要(`X-Tenant-ID`)。 */
|
|
47
|
+
tenantId: z.string(),
|
|
48
|
+
/** 默认检索范围;空数组 = 凭据可见的全部知识库。 */
|
|
49
|
+
defaultBaseIds: z.array(z.string()).max(MAX_BASE_IDS),
|
|
50
|
+
maxResults: z.number().int().min(1).max(MAX_RESULTS_CEILING),
|
|
51
|
+
maxChunkChars: z.number().int().min(200).max(MAX_CHUNK_CHARS_CEILING),
|
|
52
|
+
requestTimeoutMs: z.number().int().min(1_000).max(600_000),
|
|
53
|
+
chatTimeoutMs: z.number().int().min(1_000).max(3_600_000),
|
|
54
|
+
/** 问答要走的自建 Agent ID(内置 agent 缺 model_id,会直接报错)。 */
|
|
55
|
+
agentId: z.string(),
|
|
56
|
+
/** 问答用的 chat 模型 ID(请求体 `summary_model_id`)。 */
|
|
57
|
+
chatModelId: z.string(),
|
|
58
|
+
})
|
|
59
|
+
.strict();
|
|
60
|
+
/** 默认配置:域名来自部署文档,凭据留空由用户在设置页填。 */
|
|
61
|
+
export const DEFAULT_KNOWLEDGE_CONFIG = {
|
|
62
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
63
|
+
apiKey: '',
|
|
64
|
+
tenantId: '',
|
|
65
|
+
defaultBaseIds: [],
|
|
66
|
+
maxResults: DEFAULT_MAX_RESULTS,
|
|
67
|
+
maxChunkChars: DEFAULT_MAX_CHUNK_CHARS,
|
|
68
|
+
requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
|
|
69
|
+
chatTimeoutMs: DEFAULT_CHAT_TIMEOUT_MS,
|
|
70
|
+
agentId: '',
|
|
71
|
+
chatModelId: '',
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* 把用户输入的地址补齐成 `<origin>/api/v1` 形态。
|
|
75
|
+
*
|
|
76
|
+
* 只做补路径与去尾斜杠,不猜测协议(缺协议时由 `fetch` 报错,错误信息里带上原值,
|
|
77
|
+
* 比 Host 静默拼一个 `https://` 更好排查)。
|
|
78
|
+
*/
|
|
79
|
+
export function normalizeBaseUrl(raw) {
|
|
80
|
+
const trimmed = raw.trim().replace(/\/+$/, '');
|
|
81
|
+
if (trimmed === '')
|
|
82
|
+
return DEFAULT_BASE_URL;
|
|
83
|
+
if (/\/api\/v1$/.test(trimmed))
|
|
84
|
+
return trimmed;
|
|
85
|
+
if (/\/api$/.test(trimmed))
|
|
86
|
+
return `${trimmed}/v1`;
|
|
87
|
+
return `${trimmed}/api/v1`;
|
|
88
|
+
}
|
|
89
|
+
/** Host 侧统一异常:携带稳定 code 与**不含凭据**的 message。 */
|
|
90
|
+
export class KnowledgeError extends Error {
|
|
91
|
+
code;
|
|
92
|
+
constructor(code, message) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = 'KnowledgeError';
|
|
95
|
+
this.code = code;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** 存储域的一条设置记录(带版本戳,便于将来迁移)。 */
|
|
99
|
+
export const knowledgeSettingsRecordSchema = z
|
|
100
|
+
.object({
|
|
101
|
+
version: z.literal(KNOWLEDGE_DOMAIN_VERSION),
|
|
102
|
+
config: knowledgeConfigSchema,
|
|
103
|
+
})
|
|
104
|
+
.strict();
|
|
105
|
+
/** `storageDomain.open()` 的域描述。 */
|
|
106
|
+
export const knowledgeDomainSpec = {
|
|
107
|
+
name: KNOWLEDGE_DOMAIN_NAME,
|
|
108
|
+
version: KNOWLEDGE_DOMAIN_VERSION,
|
|
109
|
+
tables: {
|
|
110
|
+
settings: { valueSchema: knowledgeSettingsRecordSchema },
|
|
111
|
+
},
|
|
112
|
+
};
|
package/lib/weknora.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeKnora REST 客户端(Host 半边专用)。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点:
|
|
5
|
+
* - 只实现 M0 需要的只读端点 + 问答(SSE);写操作(上传/删除)在 M2 才开;
|
|
6
|
+
* - 所有出网调用都带超时:`requestTimeoutMs` 给普通请求,`chatTimeoutMs` 给问答;
|
|
7
|
+
* - **实测行为**:后端在出错时**不主动关闭 SSE 流**(连接会一直挂着),
|
|
8
|
+
* 因此问答循环必须在收到 `response_type=error` 时立刻主动 abort;
|
|
9
|
+
* - 解析对形状容错(`data` 既可能是数组也可能是 `{items,total}`),但**不臆造字段**:
|
|
10
|
+
* 缺字段就是 `undefined`,由上层决定如何展示;
|
|
11
|
+
* - 错误一律映射成 `KnowledgeError`,message 里带上 HTTP 状态与 WeKnora 的原始理由,
|
|
12
|
+
* 但**永不回显 API Key**。
|
|
13
|
+
*/
|
|
14
|
+
import { KnowledgeError, normalizeBaseUrl, } from './types.js';
|
|
15
|
+
const MAX_ERROR_BODY_CHARS = 400;
|
|
16
|
+
function asRecord(value) {
|
|
17
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
18
|
+
return {};
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function str(value) {
|
|
22
|
+
return typeof value === 'string' ? value : '';
|
|
23
|
+
}
|
|
24
|
+
function num(value) {
|
|
25
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
26
|
+
return value;
|
|
27
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
|
|
28
|
+
return Number(value);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 拆出列表:`data` 为数组时直接用;为对象时依次尝试 `items` / `list` / `records`。
|
|
33
|
+
*
|
|
34
|
+
* 实测两个端点的形状不同——`GET /knowledge-bases/:id/knowledge` 是数组,
|
|
35
|
+
* `GET /chunks/:knowledge_id` 也是数组,但同一项目里另有返回 `{items,total}` 的接口,
|
|
36
|
+
* 所以这里同时容忍两种,避免后端小版本变化就打穿。
|
|
37
|
+
*/
|
|
38
|
+
function toItems(payload) {
|
|
39
|
+
const root = asRecord(payload);
|
|
40
|
+
const data = root['data'];
|
|
41
|
+
if (Array.isArray(data))
|
|
42
|
+
return data.map(asRecord);
|
|
43
|
+
const container = asRecord(data);
|
|
44
|
+
for (const key of ['items', 'list', 'records']) {
|
|
45
|
+
const value = container[key];
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return value.map(asRecord);
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
/** 把 WeKnora 的错误响应体收敛成一句可读理由(不含敏感字段)。 */
|
|
52
|
+
function errorReason(body) {
|
|
53
|
+
const text = body.slice(0, MAX_ERROR_BODY_CHARS);
|
|
54
|
+
try {
|
|
55
|
+
const parsed = asRecord(JSON.parse(text));
|
|
56
|
+
const error = asRecord(parsed['error']);
|
|
57
|
+
const message = str(error['message']) || str(parsed['message']);
|
|
58
|
+
if (message !== '')
|
|
59
|
+
return message;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// 非 JSON:直接用截断后的原文。
|
|
63
|
+
}
|
|
64
|
+
return text.trim();
|
|
65
|
+
}
|
|
66
|
+
/** HTTP 状态 → 稳定错误码。 */
|
|
67
|
+
function codeForStatus(status) {
|
|
68
|
+
if (status === 401 || status === 403)
|
|
69
|
+
return 'unauthorized';
|
|
70
|
+
if (status === 404)
|
|
71
|
+
return 'not-found';
|
|
72
|
+
if (status >= 400 && status < 500)
|
|
73
|
+
return 'bad-request';
|
|
74
|
+
return 'internal';
|
|
75
|
+
}
|
|
76
|
+
/** 合并外部取消信号与超时信号:任一触发即中止请求。 */
|
|
77
|
+
function combineSignals(timeoutMs, external) {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
controller.abort(new KnowledgeError('timeout', `请求超过 ${timeoutMs} ms 未完成。`));
|
|
81
|
+
}, timeoutMs);
|
|
82
|
+
const onAbort = () => {
|
|
83
|
+
controller.abort(new KnowledgeError('cancelled', '请求已取消。'));
|
|
84
|
+
};
|
|
85
|
+
if (external !== undefined) {
|
|
86
|
+
if (external.aborted)
|
|
87
|
+
onAbort();
|
|
88
|
+
else
|
|
89
|
+
external.addEventListener('abort', onAbort, { once: true });
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
dispose: () => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
external?.removeEventListener('abort', onAbort);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** 把底层异常归一成 `KnowledgeError`(保留超时/取消的区别)。 */
|
|
100
|
+
function normalizeError(error) {
|
|
101
|
+
if (error instanceof KnowledgeError)
|
|
102
|
+
return error;
|
|
103
|
+
const name = typeof error === 'object' && error !== null && 'name' in error ? String(error.name) : '';
|
|
104
|
+
if (name === 'AbortError' || name === 'TimeoutError') {
|
|
105
|
+
const reason = typeof error === 'object' && error !== null && 'cause' in error ? error.cause : undefined;
|
|
106
|
+
if (reason instanceof KnowledgeError)
|
|
107
|
+
return reason;
|
|
108
|
+
return new KnowledgeError('timeout', '请求超时。');
|
|
109
|
+
}
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
+
return new KnowledgeError('unreachable', message === '' ? '无法连接到 WeKnora 后端。' : `无法连接到 WeKnora 后端:${message}`);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 创建 WeKnora 客户端。
|
|
115
|
+
* @param config - 凭据与超时配置(`baseUrl` 会先归一化)。
|
|
116
|
+
* @param transport - 出网实现;缺省用全局 `fetch`(Node 24 自带)。
|
|
117
|
+
*/
|
|
118
|
+
export function createWeKnoraClient(config, transport) {
|
|
119
|
+
const baseUrl = normalizeBaseUrl(config.baseUrl);
|
|
120
|
+
const doFetch = transport ?? ((input, init) => fetch(input, init));
|
|
121
|
+
const headers = (extra) => {
|
|
122
|
+
const result = { Accept: 'application/json', ...extra };
|
|
123
|
+
if (config.apiKey !== '')
|
|
124
|
+
result['X-API-Key'] = config.apiKey;
|
|
125
|
+
if (config.tenantId !== '')
|
|
126
|
+
result['X-Tenant-ID'] = config.tenantId;
|
|
127
|
+
return result;
|
|
128
|
+
};
|
|
129
|
+
/** 普通 JSON 请求:超时 → HTTP 状态 → JSON 解析。 */
|
|
130
|
+
const requestJson = async (path, init, pick) => {
|
|
131
|
+
const url = new URL(`${baseUrl}${path}`);
|
|
132
|
+
for (const [key, value] of Object.entries(init.query ?? {}))
|
|
133
|
+
url.searchParams.set(key, value);
|
|
134
|
+
const { signal, dispose } = combineSignals(init.timeoutMs ?? config.requestTimeoutMs, init.signal);
|
|
135
|
+
try {
|
|
136
|
+
const response = await doFetch(url.toString(), {
|
|
137
|
+
method: init.method ?? 'GET',
|
|
138
|
+
headers: headers(init.body === undefined ? undefined : { 'Content-Type': 'application/json' }),
|
|
139
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
140
|
+
signal,
|
|
141
|
+
});
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
const text = await response.text().catch(() => '');
|
|
144
|
+
const reason = errorReason(text);
|
|
145
|
+
throw new KnowledgeError(codeForStatus(response.status), `WeKnora 返回 ${response.status}${reason === '' ? '' : `:${reason}`}`);
|
|
146
|
+
}
|
|
147
|
+
const text = await response.text();
|
|
148
|
+
if (text.trim() === '')
|
|
149
|
+
return pick({});
|
|
150
|
+
try {
|
|
151
|
+
return pick(JSON.parse(text));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new KnowledgeError('internal', 'WeKnora 返回了无法解析的 JSON。');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
throw normalizeError(error);
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
dispose();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const mapBase = (item) => ({
|
|
165
|
+
id: str(item['id']),
|
|
166
|
+
name: str(item['name']),
|
|
167
|
+
description: str(item['description']),
|
|
168
|
+
documentCount: num(item['document_count']) ?? num(item['knowledge_count']),
|
|
169
|
+
chunkCount: num(item['chunk_count']),
|
|
170
|
+
embeddingModelId: str(item['embedding_model_id']),
|
|
171
|
+
summaryModelId: str(item['summary_model_id']),
|
|
172
|
+
updatedAt: str(item['updated_at']),
|
|
173
|
+
});
|
|
174
|
+
const mapDoc = (item) => ({
|
|
175
|
+
id: str(item['id']),
|
|
176
|
+
title: str(item['title']) || str(item['file_name']),
|
|
177
|
+
fileName: str(item['file_name']),
|
|
178
|
+
fileType: str(item['file_type']),
|
|
179
|
+
fileSize: num(item['file_size']),
|
|
180
|
+
parseStatus: str(item['parse_status']),
|
|
181
|
+
enableStatus: str(item['enable_status']),
|
|
182
|
+
summaryStatus: str(item['summary_status']),
|
|
183
|
+
folderPath: str(item['folder_path']),
|
|
184
|
+
createdAt: str(item['created_at']),
|
|
185
|
+
});
|
|
186
|
+
const mapHit = (item) => ({
|
|
187
|
+
chunkId: str(item['id']) || str(item['chunk_id']),
|
|
188
|
+
content: str(item['content']),
|
|
189
|
+
knowledgeId: str(item['knowledge_id']),
|
|
190
|
+
chunkIndex: num(item['chunk_index']),
|
|
191
|
+
knowledgeTitle: str(item['knowledge_title']),
|
|
192
|
+
fileName: str(item['knowledge_filename']),
|
|
193
|
+
score: num(item['score']),
|
|
194
|
+
chunkType: str(item['chunk_type']),
|
|
195
|
+
startAt: num(item['start_at']),
|
|
196
|
+
endAt: num(item['end_at']),
|
|
197
|
+
});
|
|
198
|
+
const mapReference = (item) => ({
|
|
199
|
+
chunkId: str(item['id']) || str(item['chunk_id']),
|
|
200
|
+
content: str(item['content']),
|
|
201
|
+
knowledgeId: str(item['knowledge_id']),
|
|
202
|
+
chunkIndex: num(item['chunk_index']),
|
|
203
|
+
knowledgeTitle: str(item['knowledge_title']),
|
|
204
|
+
chunkType: str(item['chunk_type']),
|
|
205
|
+
startAt: num(item['start_at']),
|
|
206
|
+
endAt: num(item['end_at']),
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
listBases: signal => requestJson('/knowledge-bases', { signal }, payload => toItems(payload).map(mapBase)),
|
|
210
|
+
listDocs: (baseId, signal) => requestJson(`/knowledge-bases/${encodeURIComponent(baseId)}/knowledge`, { signal }, payload => toItems(payload).map(mapDoc)),
|
|
211
|
+
getDoc: async (knowledgeId, signal) => {
|
|
212
|
+
try {
|
|
213
|
+
const payload = await requestJson(`/knowledge/${encodeURIComponent(knowledgeId)}`, { signal }, value => asRecord(asRecord(value)['data']));
|
|
214
|
+
return Object.keys(payload).length === 0 ? null : mapDoc(payload);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
if (error instanceof KnowledgeError && error.code === 'not-found')
|
|
218
|
+
return null;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
chunks: (knowledgeId, signal) => requestJson(`/chunks/${encodeURIComponent(knowledgeId)}`, { signal }, payload => toItems(payload).map(item => ({
|
|
223
|
+
chunkId: str(item['id']) || str(item['chunk_id']),
|
|
224
|
+
content: str(item['content']),
|
|
225
|
+
knowledgeId: str(item['knowledge_id']) || knowledgeId,
|
|
226
|
+
chunkIndex: num(item['chunk_index']),
|
|
227
|
+
knowledgeTitle: str(item['knowledge_title']),
|
|
228
|
+
fileName: str(item['knowledge_filename']),
|
|
229
|
+
score: num(item['score']),
|
|
230
|
+
chunkType: str(item['chunk_type']),
|
|
231
|
+
startAt: num(item['start_at']),
|
|
232
|
+
endAt: num(item['end_at']),
|
|
233
|
+
}))),
|
|
234
|
+
search: async (query, scope, signal) => {
|
|
235
|
+
const body = { query };
|
|
236
|
+
const baseIds = scope.baseIds ?? [];
|
|
237
|
+
const knowledgeIds = scope.knowledgeIds ?? [];
|
|
238
|
+
if (baseIds.length === 1)
|
|
239
|
+
body['knowledge_base_id'] = baseIds[0];
|
|
240
|
+
else if (baseIds.length > 1)
|
|
241
|
+
body['knowledge_base_ids'] = baseIds;
|
|
242
|
+
if (knowledgeIds.length > 0)
|
|
243
|
+
body['knowledge_ids'] = knowledgeIds;
|
|
244
|
+
return requestJson('/knowledge-search', { method: 'POST', body, signal, query: { resource_urls: 'handle' } }, payload => toItems(payload).map(mapHit));
|
|
245
|
+
},
|
|
246
|
+
capabilities: signal => requestJson('/system/capabilities', { signal }, payload => {
|
|
247
|
+
const caps = asRecord(asRecord(asRecord(payload)['data'])['capabilities']);
|
|
248
|
+
const result = {};
|
|
249
|
+
for (const [key, value] of Object.entries(caps)) {
|
|
250
|
+
const entry = asRecord(value);
|
|
251
|
+
const reason = str(entry['reason']);
|
|
252
|
+
result[key] = reason === '' ? { supported: entry['supported'] === true } : { supported: entry['supported'] === true, reason };
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}),
|
|
256
|
+
ask: async (query, scope, signal) => {
|
|
257
|
+
const startedAt = Date.now();
|
|
258
|
+
const sessionPayload = await requestJson('/sessions', { method: 'POST', body: {}, signal }, value => asRecord(asRecord(value)['data']));
|
|
259
|
+
const sessionId = str(sessionPayload['id']);
|
|
260
|
+
if (sessionId === '')
|
|
261
|
+
throw new KnowledgeError('internal', 'WeKnora 未返回会话 ID。');
|
|
262
|
+
const body = { query };
|
|
263
|
+
const baseIds = scope.baseIds ?? [];
|
|
264
|
+
if (baseIds.length > 0)
|
|
265
|
+
body['knowledge_base_ids'] = [...baseIds];
|
|
266
|
+
// 实测:内置 agent 的 model_id 为空,/agent-chat 会直接报
|
|
267
|
+
// 「chat model is not configured」;问答必须由请求体提供 chat 模型。
|
|
268
|
+
if (config.chatModelId !== '')
|
|
269
|
+
body['summary_model_id'] = config.chatModelId;
|
|
270
|
+
const { signal: aborted, dispose } = combineSignals(config.chatTimeoutMs, signal);
|
|
271
|
+
const controller = new AbortController();
|
|
272
|
+
const relay = () => controller.abort(aborted.reason);
|
|
273
|
+
aborted.addEventListener('abort', relay, { once: true });
|
|
274
|
+
try {
|
|
275
|
+
const path = `/knowledge-chat/${encodeURIComponent(sessionId)}`;
|
|
276
|
+
const url = new URL(`${baseUrl}${path}`);
|
|
277
|
+
const response = await doFetch(url.toString(), {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: headers({ 'Content-Type': 'application/json', Accept: 'text/event-stream' }),
|
|
280
|
+
body: JSON.stringify(body),
|
|
281
|
+
signal: controller.signal,
|
|
282
|
+
});
|
|
283
|
+
if (!response.ok) {
|
|
284
|
+
const text = await response.text().catch(() => '');
|
|
285
|
+
throw new KnowledgeError(codeForStatus(response.status), `WeKnora 返回 ${response.status}${text.trim() === '' ? '' : `:${errorReason(text)}`}`);
|
|
286
|
+
}
|
|
287
|
+
if (response.body === null)
|
|
288
|
+
throw new KnowledgeError('internal', 'WeKnora 未返回流式响应体。');
|
|
289
|
+
let answer = '';
|
|
290
|
+
let references = [];
|
|
291
|
+
const toolsUsed = [];
|
|
292
|
+
const reader = response.body.getReader();
|
|
293
|
+
const decoder = new TextDecoder();
|
|
294
|
+
let buffer = '';
|
|
295
|
+
try {
|
|
296
|
+
for (;;) {
|
|
297
|
+
const { done, value } = await reader.read();
|
|
298
|
+
if (done)
|
|
299
|
+
break;
|
|
300
|
+
buffer += decoder.decode(value, { stream: true });
|
|
301
|
+
let index = buffer.indexOf('\n');
|
|
302
|
+
while (index >= 0) {
|
|
303
|
+
const line = buffer.slice(0, index).trim();
|
|
304
|
+
buffer = buffer.slice(index + 1);
|
|
305
|
+
index = buffer.indexOf('\n');
|
|
306
|
+
if (!line.startsWith('data:'))
|
|
307
|
+
continue;
|
|
308
|
+
const raw = line.slice(5).trim();
|
|
309
|
+
if (raw === '')
|
|
310
|
+
continue;
|
|
311
|
+
let frame;
|
|
312
|
+
try {
|
|
313
|
+
frame = asRecord(JSON.parse(raw));
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const type = str(frame['response_type']);
|
|
319
|
+
if (type === 'answer')
|
|
320
|
+
answer += str(frame['content']);
|
|
321
|
+
else if (type === 'references') {
|
|
322
|
+
const refs = frame['knowledge_references'];
|
|
323
|
+
if (Array.isArray(refs))
|
|
324
|
+
references = refs.map(item => mapReference(asRecord(item)));
|
|
325
|
+
}
|
|
326
|
+
else if (type === 'tool_call') {
|
|
327
|
+
const name = str(asRecord(frame['data'])['tool_name']) || str(frame['content']).replace(/^Calling tool:\s*/, '');
|
|
328
|
+
if (name !== '' && !toolsUsed.includes(name))
|
|
329
|
+
toolsUsed.push(name);
|
|
330
|
+
}
|
|
331
|
+
else if (type === 'error') {
|
|
332
|
+
// 实测后端出错后不关流:必须主动中止,否则调用方要等满超时。
|
|
333
|
+
const message = str(frame['content']) || 'WeKnora 问答返回错误。';
|
|
334
|
+
controller.abort(new KnowledgeError('internal', message));
|
|
335
|
+
throw new KnowledgeError('internal', message);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
await reader.cancel().catch(() => undefined);
|
|
342
|
+
}
|
|
343
|
+
return { answer, references, sessionId, toolsUsed, elapsedMs: Date.now() - startedAt };
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
throw normalizeError(error);
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
aborted.removeEventListener('abort', relay);
|
|
350
|
+
dispose();
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** 自检用:不带任何业务查询地探测后端可达性与凭据(`GET /knowledge-bases`)。 */
|
|
356
|
+
export async function probeConnection(config, transport, signal) {
|
|
357
|
+
const client = createWeKnoraClient(config, transport);
|
|
358
|
+
try {
|
|
359
|
+
const bases = await client.listBases(signal);
|
|
360
|
+
return { ok: true, code: 'ok', message: `已连接,可见 ${bases.length} 个知识库。`, baseCount: bases.length };
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
const failure = error instanceof KnowledgeError ? error : normalizeError(error);
|
|
364
|
+
return { ok: false, code: failure.code, message: failure.message };
|
|
365
|
+
}
|
|
366
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hiwork-knowledge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "HiWork
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "HiWork 知识库插件:接腾讯 WeKnora,向 Agent 提供检索/阅读/问答工具,并在桌面端提供知识库浏览与设置。",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"packageManager": "pnpm@10.28.1",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"client": {
|
|
35
35
|
"platform": "web",
|
|
36
36
|
"inject": [
|
|
37
|
+
"@deepseek-ai/dsh-client-connection",
|
|
37
38
|
"@deepseek-ai/dsh-client-locale",
|
|
38
39
|
"@deepseek-ai/dsh-client-ui-settings",
|
|
39
40
|
"@deepseek-ai/dsh-client-ui-slots"
|
|
@@ -47,25 +48,43 @@
|
|
|
47
48
|
"build": "pnpm build:host && pnpm build:client",
|
|
48
49
|
"test": "vitest run",
|
|
49
50
|
"check": "pnpm typecheck && pnpm test && pnpm build",
|
|
50
|
-
"verify": "pnpm typecheck && pnpm build && pnpm test"
|
|
51
|
+
"verify": "pnpm typecheck && pnpm build && pnpm test",
|
|
52
|
+
"smoke:live": "node scripts/live-smoke.mjs"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"zod": "^4.1.5"
|
|
51
56
|
},
|
|
52
57
|
"peerDependencies": {
|
|
53
58
|
"@deepseek-ai/cordis": ">=4.0.1 <5.0.0",
|
|
59
|
+
"@deepseek-ai/dsh-agent": ">=0.1.0-rc.5 <0.2.0",
|
|
60
|
+
"@deepseek-ai/dsh-client-connection": ">=0.1.0-rc.5 <0.2.0",
|
|
54
61
|
"@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.5 <0.2.0",
|
|
55
62
|
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.5 <0.2.0",
|
|
56
63
|
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.5 <0.2.0",
|
|
57
|
-
"
|
|
64
|
+
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.5 <0.2.0",
|
|
65
|
+
"@deepseek-ai/dsh-storage-domain": ">=0.1.0-rc.5 <0.2.0",
|
|
66
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.5 <0.2.0",
|
|
67
|
+
"react": "^18.2.0",
|
|
68
|
+
"@deepseek-ai/schemastery": ">=3.18.1 <4.0.0"
|
|
58
69
|
},
|
|
59
70
|
"peerDependenciesMeta": {
|
|
60
71
|
"react": {
|
|
61
72
|
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"@deepseek-ai/dsh-agent": {
|
|
75
|
+
"optional": true
|
|
62
76
|
}
|
|
63
77
|
},
|
|
64
78
|
"devDependencies": {
|
|
65
79
|
"@deepseek-ai/cordis": "4.0.2",
|
|
66
|
-
"@deepseek-ai/dsh-
|
|
67
|
-
"@deepseek-ai/dsh-client-
|
|
68
|
-
"@deepseek-ai/dsh-client-
|
|
80
|
+
"@deepseek-ai/dsh-agent": "0.1.5-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-client-connection": "0.1.5-rc.1",
|
|
82
|
+
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.1",
|
|
83
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.1",
|
|
84
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.1",
|
|
85
|
+
"@deepseek-ai/dsh-llm": "0.1.5-rc.1",
|
|
86
|
+
"@deepseek-ai/dsh-storage-domain": "0.1.5-rc.1",
|
|
87
|
+
"@deepseek-ai/dsh-tools": "0.1.5-rc.1",
|
|
69
88
|
"@types/node": "^22.0.0",
|
|
70
89
|
"@types/react": "~18.3.1",
|
|
71
90
|
"@types/react-dom": "~18.3.0",
|
|
@@ -74,6 +93,7 @@
|
|
|
74
93
|
"react": "^18.3.1",
|
|
75
94
|
"react-dom": "^18.3.1",
|
|
76
95
|
"typescript": "^5.9.0",
|
|
77
|
-
"vitest": "^3.2.0"
|
|
96
|
+
"vitest": "^3.2.0",
|
|
97
|
+
"@deepseek-ai/schemastery": "3.18.2"
|
|
78
98
|
}
|
|
79
99
|
}
|