@modesto_ma/dsh-guanxiang 0.1.0 → 0.2.0-beta.2
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 +67 -2
- package/lib/client.js +382 -97
- package/lib/client.js.map +1 -1
- package/lib/index.js +472 -62
- package/lib/index.js.map +1 -1
- package/package.json +2 -1
- package/src/analyze/contract.ts +124 -0
- package/src/analyze/server.ts +195 -0
- package/src/analyze/topics.ts +109 -0
- package/src/client/page.tsx +172 -41
- package/src/client/styles.ts +4 -0
- package/src/config.ts +25 -3
- package/src/index.ts +10 -4
- package/src/repos/credentials.ts +2 -3
- package/src/service.ts +47 -12
- package/src/settings/contract.ts +41 -2
package/lib/index.js
CHANGED
|
@@ -1,21 +1,413 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
1
3
|
import { homedir, tmpdir } from "node:os";
|
|
2
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
6
|
import { parse } from "yaml";
|
|
7
7
|
import { createWriteStream } from "node:fs";
|
|
8
8
|
import { Readable, Transform } from "node:stream";
|
|
9
9
|
import { pipeline } from "node:stream/promises";
|
|
10
10
|
import { execFile } from "node:child_process";
|
|
11
|
+
//#region src/settings/contract.ts
|
|
12
|
+
/**
|
|
13
|
+
* 设置面板与 Host 路由共用的路由常量和 JSON 结构。浏览器端 bundle 也会
|
|
14
|
+
* 编译本文件,因此这里只放类型和常量,不 import 任何 Node 模块。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-guanxiang/settings/contract
|
|
17
|
+
*/
|
|
18
|
+
/** GET:面板所需的全部状态(生效设置、默认值、仓库与应用)。 */
|
|
19
|
+
const STATE_ROUTE = "/guanxiang/state";
|
|
20
|
+
/** PUT:保存可编辑设置;DELETE:清除面板覆盖、恢复 cordis.yml 默认值。 */
|
|
21
|
+
const SETTINGS_ROUTE = "/guanxiang/settings";
|
|
22
|
+
/** POST:立即重新拉取全部仓库,或 body `{ url }` 指定的一个仓库。 */
|
|
23
|
+
const SYNC_ROUTE = "/guanxiang/sync";
|
|
24
|
+
/** PUT:body `{ tokenEnv, value }` 把令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受已保存的访问令牌配置引用的名称和分析接口令牌的名称。 */
|
|
25
|
+
const TOKEN_ROUTE = "/guanxiang/token";
|
|
26
|
+
/**
|
|
27
|
+
* POST:日志平台分析接口(Bearer 令牌鉴权,不走 DSH 登录)。它在 DSH 的
|
|
28
|
+
* `/api` 命名空间之外,网关可以把它和需要登录的 DSH 接口分开暴露。
|
|
29
|
+
*/
|
|
30
|
+
const ANALYZE_ROUTE = "/open-api/logalyze";
|
|
31
|
+
/** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier), which {@link GitCredentialSpec.tokenEnv} follows. */
|
|
32
|
+
const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
33
|
+
/** Keys of {@link EditableSettings}, in panel order. */
|
|
34
|
+
const EDITABLE_KEYS = [
|
|
35
|
+
"reposDir",
|
|
36
|
+
"repos",
|
|
37
|
+
"gitCredentials",
|
|
38
|
+
"fetchIntervalMs",
|
|
39
|
+
"maxAppsPerRequest",
|
|
40
|
+
"dispatchTimeoutMs",
|
|
41
|
+
"download",
|
|
42
|
+
"skillUpdates",
|
|
43
|
+
"analyze"
|
|
44
|
+
];
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/analyze/contract.ts
|
|
47
|
+
/**
|
|
48
|
+
* 日志平台分析接口的请求与响应。
|
|
49
|
+
*
|
|
50
|
+
* `POST /open-api/logalyze`(DSH Web 服务器上),带
|
|
51
|
+
* `Authorization: Bearer <令牌>`、`Idempotency-Key` 请求头和 JSON 请求体:
|
|
52
|
+
* 发起人、触发时间、应用与错误码、日志详情页、目标群和提示词。合法请求把
|
|
53
|
+
* 分析根卡片发进群并回 `202`,分析随后在卡片下的话题里流式进行。日志包由
|
|
54
|
+
* 提示词调用的分析 SKILL 自行获取,详情页只列在卡片上。
|
|
55
|
+
*
|
|
56
|
+
* 接口原由 dsh-channels 0.7 提供;迁到观象后请求、响应和错误文本不变。
|
|
57
|
+
*
|
|
58
|
+
* @module dsh-guanxiang/analyze/contract
|
|
59
|
+
*/
|
|
60
|
+
/** Largest accepted request body (the prompt dominates it). */
|
|
61
|
+
const ANALYZE_MAX_BODY_BYTES = 1024 * 1024;
|
|
62
|
+
/** Largest accepted prompt, in characters. */
|
|
63
|
+
const ANALYZE_MAX_PROMPT_CHARS = 1e5;
|
|
64
|
+
/**
|
|
65
|
+
* `Idempotency-Key` format: 1–200 visible ASCII characters without spaces, so
|
|
66
|
+
* a platform can use a UUID or its own `type:id` token.
|
|
67
|
+
*/
|
|
68
|
+
const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,200}$/u;
|
|
69
|
+
/** Feishu `user_id`: not an `open_id` (`ou_`), `union_id` (`on_`), or chat id (`oc_`). */
|
|
70
|
+
const FEISHU_USER_ID = /^(?!ou_|on_|oc_)[A-Za-z0-9_-]+$/u;
|
|
71
|
+
/**
|
|
72
|
+
* Application / error-code segment: it names a workspace directory
|
|
73
|
+
* (`<baseDir>/analyze/<app>/<errorCode>` below the Feishu channel's
|
|
74
|
+
* `baseDir`), so only path-safe characters pass.
|
|
75
|
+
*/
|
|
76
|
+
const SUBJECT_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u;
|
|
77
|
+
/** JSON body of one analyze request. */
|
|
78
|
+
const analyzeRequestSchema = z.object({
|
|
79
|
+
/** Feishu user_id of the person who started the analysis on the platform. */
|
|
80
|
+
requesterUserId: z.string().regex(FEISHU_USER_ID, "must be a Feishu user_id").max(128),
|
|
81
|
+
/** When the analysis was started on the platform, shown verbatim on the root card. */
|
|
82
|
+
triggeredAt: z.string().min(1).max(64).refine((value) => value.trim() !== "", "must not be blank"),
|
|
83
|
+
/** Application the logs belong to (workspace directory segment). */
|
|
84
|
+
app: z.string().regex(SUBJECT_SEGMENT, "must be 1-128 of A-Z a-z 0-9 . _ -"),
|
|
85
|
+
/** Error code of the logs (workspace directory segment). */
|
|
86
|
+
errorCode: z.string().regex(SUBJECT_SEGMENT, "must be 1-128 of A-Z a-z 0-9 . _ -"),
|
|
87
|
+
/** Log-platform detail pages of the logs that triggered the analysis (listed on the root card). */
|
|
88
|
+
logDetails: z.array(z.object({
|
|
89
|
+
/** Row label. */
|
|
90
|
+
name: z.string().min(1).max(200).refine((value) => value.trim() !== "", "must not be blank"),
|
|
91
|
+
/** Detail page address. */
|
|
92
|
+
url: z.url({
|
|
93
|
+
protocol: /^https?$/u,
|
|
94
|
+
error: "must be an http(s) URL"
|
|
95
|
+
}).max(2048)
|
|
96
|
+
}).strict()).min(1).max(50),
|
|
97
|
+
/** Target group chat (`oc_…`); must be an authorized group. */
|
|
98
|
+
chatId: z.string().regex(/^oc_[A-Za-z0-9_-]{1,128}$/u, "must be a Feishu group chat_id (oc_...)"),
|
|
99
|
+
/** Prompt forwarded verbatim as the analysis Session's first user message. */
|
|
100
|
+
prompt: z.string().min(1).max(ANALYZE_MAX_PROMPT_CHARS).refine((value) => value.trim() !== "", "must not be blank")
|
|
101
|
+
}).strict();
|
|
102
|
+
/**
|
|
103
|
+
* Fingerprint of one validated request, used to tell a retry of the same
|
|
104
|
+
* request from a reused `Idempotency-Key`. Field order is fixed: dsh-channels
|
|
105
|
+
* 0.7 stored the same digest, so its accepted keys keep replaying.
|
|
106
|
+
* @param request - the validated request.
|
|
107
|
+
* @returns SHA-256 hex digest.
|
|
108
|
+
*/
|
|
109
|
+
function analyzeRequestHash(request) {
|
|
110
|
+
const canonical = JSON.stringify([
|
|
111
|
+
request.requesterUserId,
|
|
112
|
+
request.triggeredAt,
|
|
113
|
+
request.app,
|
|
114
|
+
request.errorCode,
|
|
115
|
+
request.logDetails.map((detail) => [detail.name, detail.url]),
|
|
116
|
+
request.chatId,
|
|
117
|
+
request.prompt
|
|
118
|
+
]);
|
|
119
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Validate one decoded JSON body.
|
|
123
|
+
* @param raw - the decoded body.
|
|
124
|
+
* @returns the request, or a readable list of field errors.
|
|
125
|
+
*/
|
|
126
|
+
function parseAnalyzeRequest(raw) {
|
|
127
|
+
const parsed = analyzeRequestSchema.safeParse(raw);
|
|
128
|
+
if (parsed.success) return {
|
|
129
|
+
ok: true,
|
|
130
|
+
value: parsed.data
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: `请求参数不合法:${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(body)"}: ${issue.message}`).join("; ")}`
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/analyze/topics.ts
|
|
139
|
+
/** Name dsh-channels provides the service under. */
|
|
140
|
+
const FEISHU_TOPICS_SERVICE = "feishuTopics";
|
|
141
|
+
/**
|
|
142
|
+
* Classify the value `ctx.get('feishuTopics')` returned. The provider is
|
|
143
|
+
* another package that may be older or newer than this one, so its version
|
|
144
|
+
* is checked at run time.
|
|
145
|
+
* @param value - the looked-up service, or undefined.
|
|
146
|
+
* @returns the usable service, or why there is none.
|
|
147
|
+
*/
|
|
148
|
+
function lookupTopics(value) {
|
|
149
|
+
if (value === void 0 || value === null) return { availability: "missing" };
|
|
150
|
+
const service = value;
|
|
151
|
+
if (service.version !== 1 || typeof service.launch !== "function") return { availability: "incompatible" };
|
|
152
|
+
return {
|
|
153
|
+
availability: "ready",
|
|
154
|
+
service: value
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The topic one analyze request starts.
|
|
159
|
+
* @param request - the validated request.
|
|
160
|
+
* @param idempotencyKey - the request's `Idempotency-Key`, used unchanged as the launch key.
|
|
161
|
+
* @returns the launch spec.
|
|
162
|
+
*/
|
|
163
|
+
function analyzeTopicSpec(request, idempotencyKey) {
|
|
164
|
+
return {
|
|
165
|
+
key: idempotencyKey,
|
|
166
|
+
fingerprint: analyzeRequestHash(request),
|
|
167
|
+
chatId: request.chatId,
|
|
168
|
+
requesterUserId: request.requesterUserId,
|
|
169
|
+
card: {
|
|
170
|
+
title: "日志分析",
|
|
171
|
+
taskName: "分析",
|
|
172
|
+
lead: [
|
|
173
|
+
{ text: `于 ${request.triggeredAt} 发起了对 ` },
|
|
174
|
+
{
|
|
175
|
+
text: request.app,
|
|
176
|
+
bold: true
|
|
177
|
+
},
|
|
178
|
+
{ text: " 应用 " },
|
|
179
|
+
{
|
|
180
|
+
text: request.errorCode,
|
|
181
|
+
bold: true
|
|
182
|
+
},
|
|
183
|
+
{ text: " 错误码不良日志的分析。" }
|
|
184
|
+
],
|
|
185
|
+
links: {
|
|
186
|
+
nameHeader: "日志",
|
|
187
|
+
linkHeader: "详情页",
|
|
188
|
+
rows: request.logDetails
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
workspace: {
|
|
192
|
+
segments: [
|
|
193
|
+
"analyze",
|
|
194
|
+
request.app,
|
|
195
|
+
request.errorCode
|
|
196
|
+
],
|
|
197
|
+
title: `日志分析 · ${request.app} · ${request.errorCode}`
|
|
198
|
+
},
|
|
199
|
+
prompt: request.prompt
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* HTTP answer of one launch result.
|
|
204
|
+
* @param result - what the service answered.
|
|
205
|
+
* @returns status and body.
|
|
206
|
+
*/
|
|
207
|
+
function analyzeResponseOf(result) {
|
|
208
|
+
if (result.ok) return {
|
|
209
|
+
status: 202,
|
|
210
|
+
body: {
|
|
211
|
+
ok: true,
|
|
212
|
+
chatId: result.chatId,
|
|
213
|
+
messageId: result.messageId
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
switch (result.code) {
|
|
217
|
+
case "not-connected": return refused(503, result.message);
|
|
218
|
+
case "forbidden-chat": return refused(403, result.message);
|
|
219
|
+
case "key-conflict": return refused(409, "Idempotency-Key 已被内容不同的请求使用");
|
|
220
|
+
case "send-failed": return refused(502, result.message);
|
|
221
|
+
case "invalid": return refused(500, `分析请求处理失败:${result.message}`);
|
|
222
|
+
default: return refused(500, `分析请求处理失败:${String(result.message)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Start the analysis topic of one validated request.
|
|
227
|
+
* @param topics - the `feishuTopics` service.
|
|
228
|
+
* @param request - the validated request.
|
|
229
|
+
* @param idempotencyKey - the request's `Idempotency-Key`.
|
|
230
|
+
* @returns the HTTP answer.
|
|
231
|
+
*/
|
|
232
|
+
async function launchAnalysis(topics, request, idempotencyKey) {
|
|
233
|
+
return analyzeResponseOf(await topics.launch(analyzeTopicSpec(request, idempotencyKey)));
|
|
234
|
+
}
|
|
235
|
+
function refused(status, error) {
|
|
236
|
+
return {
|
|
237
|
+
status,
|
|
238
|
+
body: {
|
|
239
|
+
ok: false,
|
|
240
|
+
error
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/analyze/server.ts
|
|
246
|
+
/**
|
|
247
|
+
* 日志平台分析接口在 DSH Web 服务器上的路由。
|
|
248
|
+
*
|
|
249
|
+
* 只在宿主同时提供 `webServer` 和版本兼容的 dsh-channels `feishuTopics`
|
|
250
|
+
* 服务时注册精确路由 `POST /open-api/logalyze`;dsh-channels 未加载、重载
|
|
251
|
+
* 中、版本不兼容或为 0.7 及更早版本(自己注册该路由)时观象不注册,两者
|
|
252
|
+
* 不会争用同一路径。
|
|
253
|
+
* 本模块按顺序做传输层检查:路径、开关、方法、Bearer 令牌、
|
|
254
|
+
* `Idempotency-Key`、媒体类型、请求体大小和 JSON 校验;通过后交给
|
|
255
|
+
* `feishuTopics`。请求体、令牌和提示词都不写日志。
|
|
256
|
+
*
|
|
257
|
+
* @module dsh-guanxiang/analyze/server
|
|
258
|
+
*/
|
|
259
|
+
var HttpError$1 = class extends Error {
|
|
260
|
+
status;
|
|
261
|
+
constructor(status, message) {
|
|
262
|
+
super(message);
|
|
263
|
+
this.status = status;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
function send(res, response) {
|
|
267
|
+
const body = JSON.stringify(response.body);
|
|
268
|
+
res.writeHead(response.status, {
|
|
269
|
+
"content-type": "application/json; charset=utf-8",
|
|
270
|
+
"cache-control": "no-store",
|
|
271
|
+
"content-length": Buffer.byteLength(body)
|
|
272
|
+
});
|
|
273
|
+
res.end(body);
|
|
274
|
+
}
|
|
275
|
+
function refuse(res, status, error, headers = {}) {
|
|
276
|
+
for (const [name, value] of Object.entries(headers)) res.setHeader(name, value);
|
|
277
|
+
send(res, {
|
|
278
|
+
status,
|
|
279
|
+
body: {
|
|
280
|
+
ok: false,
|
|
281
|
+
error
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/** Constant-time token comparison over fixed-length digests. */
|
|
286
|
+
function tokenMatches(presented, expected) {
|
|
287
|
+
return timingSafeEqual(createHash("sha256").update(presented).digest(), createHash("sha256").update(expected).digest());
|
|
288
|
+
}
|
|
289
|
+
/** The bearer credential of one request, or undefined. */
|
|
290
|
+
function bearerOf(req) {
|
|
291
|
+
const header = req.headers.authorization;
|
|
292
|
+
if (header === void 0) return void 0;
|
|
293
|
+
return /^Bearer[ \t]+(\S+)[ \t]*$/iu.exec(header)?.[1];
|
|
294
|
+
}
|
|
295
|
+
/** Read the body up to {@link ANALYZE_MAX_BODY_BYTES}; larger bodies fail with 413. */
|
|
296
|
+
async function readBody(req) {
|
|
297
|
+
const declared = Number(req.headers["content-length"]);
|
|
298
|
+
if (Number.isFinite(declared) && declared > 1048576) throw new HttpError$1(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`);
|
|
299
|
+
const chunks = [];
|
|
300
|
+
let size = 0;
|
|
301
|
+
for await (const chunk of req) {
|
|
302
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
303
|
+
size += buffer.length;
|
|
304
|
+
if (size > 1048576) throw new HttpError$1(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`);
|
|
305
|
+
chunks.push(buffer);
|
|
306
|
+
}
|
|
307
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Handle one HTTP request against the analyze API.
|
|
311
|
+
* @param req - the incoming request.
|
|
312
|
+
* @param res - its response.
|
|
313
|
+
* @param gate - switch and token.
|
|
314
|
+
* @param launch - starts the analysis of a validated request.
|
|
315
|
+
*/
|
|
316
|
+
async function handleAnalyzeHttp(req, res, gate, launch) {
|
|
317
|
+
if (new URL(req.url ?? "/", "http://analyze.invalid").pathname !== "/open-api/logalyze") {
|
|
318
|
+
refuse(res, 404, "未知路径");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!gate.enabled()) {
|
|
322
|
+
refuse(res, 404, "日志分析接口未开启,请在观象设置面板「分析接口」打开开关");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if ((req.method ?? "GET").toUpperCase() !== "POST") {
|
|
326
|
+
refuse(res, 405, "只接受 POST", { allow: "POST" });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const expected = await gate.token();
|
|
330
|
+
if (expected === void 0 || expected === "") {
|
|
331
|
+
refuse(res, 503, `分析接口未配置访问令牌,请在观象设置面板「分析接口」保存令牌(凭据名 ${gate.tokenEnv()})`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const presented = bearerOf(req);
|
|
335
|
+
if (presented === void 0 || !tokenMatches(presented, expected)) {
|
|
336
|
+
refuse(res, 401, "访问令牌无效", { "www-authenticate": "Bearer" });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const idempotencyKey = req.headers["idempotency-key"];
|
|
340
|
+
if (typeof idempotencyKey !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
|
|
341
|
+
refuse(res, 400, "缺少或不合法的 Idempotency-Key 请求头(1-200 个可见 ASCII 字符)");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if ((req.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase() !== "application/json") {
|
|
345
|
+
refuse(res, 415, "Content-Type 必须是 application/json");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let raw;
|
|
349
|
+
try {
|
|
350
|
+
raw = JSON.parse(await readBody(req));
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error instanceof HttpError$1) {
|
|
353
|
+
refuse(res, error.status, error.message);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
refuse(res, 400, "请求体不是合法的 JSON");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const parsed = parseAnalyzeRequest(raw);
|
|
360
|
+
if (!parsed.ok) {
|
|
361
|
+
refuse(res, 400, parsed.error);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
send(res, await launch(parsed.value, idempotencyKey));
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Register the route while the host provides both `webServer` and a
|
|
368
|
+
* compatible `feishuTopics`; it is removed when either goes away. Requests skip the DSH
|
|
369
|
+
* login gate on purpose: the bearer token is this API's only credential.
|
|
370
|
+
* @param ctx - the plugin context.
|
|
371
|
+
* @param gate - switch and token.
|
|
372
|
+
*/
|
|
373
|
+
function mountAnalyzeRoute(ctx, gate) {
|
|
374
|
+
const attach = (scope) => {
|
|
375
|
+
const server = scope.get?.("webServer");
|
|
376
|
+
if (server === void 0) return;
|
|
377
|
+
const topics = lookupTopics(scope.get?.(FEISHU_TOPICS_SERVICE));
|
|
378
|
+
if (topics.availability !== "ready") {
|
|
379
|
+
if (topics.availability === "incompatible") ctx.logger?.warn?.("guanxiang: dsh-channels 的 feishuTopics 服务版本与观象不兼容,分析接口未注册;请把两者升级到相互兼容的版本");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const { service } = topics;
|
|
383
|
+
const launch = (request, key) => launchAnalysis(service, request, key);
|
|
384
|
+
scope.effect(() => server.register({
|
|
385
|
+
kind: "exact",
|
|
386
|
+
path: ANALYZE_ROUTE,
|
|
387
|
+
handler: async (req, res) => {
|
|
388
|
+
try {
|
|
389
|
+
await handleAnalyzeHttp(req, res, gate, launch);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
ctx.logger?.warn?.("guanxiang: 分析请求处理失败:%s", String(error));
|
|
392
|
+
if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`);
|
|
393
|
+
else res.destroy();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}), "guanxiang: analyze route");
|
|
397
|
+
};
|
|
398
|
+
if (typeof ctx.inject === "function") {
|
|
399
|
+
ctx.inject(["webServer", FEISHU_TOPICS_SERVICE], attach);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
attach(ctx);
|
|
403
|
+
}
|
|
404
|
+
//#endregion
|
|
11
405
|
//#region src/repos/credentials.ts
|
|
12
406
|
/** Environment variables the helper reads; set only on git child processes. */
|
|
13
407
|
const USERNAME_VARIABLE = "GUANXIANG_GIT_USERNAME";
|
|
14
408
|
const TOKEN_VARIABLE = "GUANXIANG_GIT_TOKEN";
|
|
15
409
|
/** Answers git's `get` request from the two variables and ignores `store` and `erase`. */
|
|
16
410
|
const HELPER = `!f() { test "$1" = get || exit 0; printf 'username=%s\\npassword=%s\\n' "$${USERNAME_VARIABLE}" "$${TOKEN_VARIABLE}"; }; f`;
|
|
17
|
-
/** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier). */
|
|
18
|
-
const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
19
411
|
/** Longest accepted token; personal access tokens are far shorter. */
|
|
20
412
|
const MAX_TOKEN_LENGTH = 4096;
|
|
21
413
|
/**
|
|
@@ -147,8 +539,9 @@ const repoSchema = z.object({
|
|
|
147
539
|
}).strict();
|
|
148
540
|
const credentialSchema = z.object({
|
|
149
541
|
url: z.string().trim().refine((value) => normalizeCredentialUrl(value) !== void 0, "令牌地址不合法:填 https://主机[:端口][/路径],不含用户名、查询参数或片段").transform((value) => normalizeCredentialUrl(value)),
|
|
542
|
+
alias: z.string().trim().max(64, "令牌别名不能超过 64 个字符").regex(/^[^\u0000-\u001f\u007f]*$/, "令牌别名不能包含控制字符").optional().transform((value) => value === "" ? void 0 : value),
|
|
150
543
|
username: z.string().trim().regex(/^\S+$/, "用户名不能为空或包含空白").default("oauth2"),
|
|
151
|
-
tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN, "
|
|
544
|
+
tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN, "令牌名称只能包含字母、数字和下划线,且不能以数字开头(它是凭据库中的键名,不是令牌本身)")
|
|
152
545
|
}).strict();
|
|
153
546
|
const hostSchema = z.string().trim().toLowerCase().transform((value) => value.replace(/^\*\./, "")).refine((value) => HOST_NAME.test(value), "域名不合法:只填主机名,例如 oss.example.com");
|
|
154
547
|
const durationMs = (min) => z.number().int().min(min).max(MAX_TIMER_DELAY_MS);
|
|
@@ -162,6 +555,7 @@ const skillUpdatesSchema = z.object({
|
|
|
162
555
|
maxFiles: z.number().int().min(1).max(256).default(16),
|
|
163
556
|
maxFileBytes: z.number().int().min(1).max(16 * 1024 * 1024).default(256 * 1024)
|
|
164
557
|
}).strict();
|
|
558
|
+
const analyzeSchema = z.object({ enabled: z.boolean().default(false) }).strict();
|
|
165
559
|
const authorSchema = z.object({
|
|
166
560
|
name: z.string().trim().min(1, "提交作者名不能为空").refine((value) => !/[<>\n]/.test(value), "提交作者名不能包含 < > 或换行"),
|
|
167
561
|
email: z.string().trim().min(1, "提交作者邮箱不能为空").refine((value) => !/[<>\s]/.test(value), "提交作者邮箱不能包含 < > 或空白")
|
|
@@ -195,7 +589,8 @@ const editableFields = {
|
|
|
195
589
|
maxAppsPerRequest: z.number().int().min(1).max(32),
|
|
196
590
|
dispatchTimeoutMs: durationMs(1e3),
|
|
197
591
|
download: downloadSchema,
|
|
198
|
-
skillUpdates: skillUpdatesSchema
|
|
592
|
+
skillUpdates: skillUpdatesSchema,
|
|
593
|
+
analyze: analyzeSchema
|
|
199
594
|
};
|
|
200
595
|
/** Standard Schema the Cordis loader validates `config` with before `apply()`. */
|
|
201
596
|
const pluginConfigSchema = z.object({
|
|
@@ -208,6 +603,12 @@ const pluginConfigSchema = z.object({
|
|
|
208
603
|
dispatchTimeoutMs: editableFields.dispatchTimeoutMs.default(1800 * 1e3),
|
|
209
604
|
download: downloadSchema.prefault({}),
|
|
210
605
|
skillUpdates: skillUpdatesSchema.prefault({}),
|
|
606
|
+
analyze: analyzeSchema.prefault({}),
|
|
607
|
+
/**
|
|
608
|
+
* Credential reference (environment-variable name) whose value is the
|
|
609
|
+
* analyze API's bearer token.
|
|
610
|
+
*/
|
|
611
|
+
analyzeTokenRef: z.string().trim().regex(TOKEN_ENV_PATTERN, "令牌名称只能包含字母、数字和下划线,且不能以数字开头").default("GUANXIANG_ANALYZE_TOKEN"),
|
|
211
612
|
/**
|
|
212
613
|
* Author and committer of SKILL improvement commits; omitted uses the git
|
|
213
614
|
* identity configured on the host (`user.name` / `user.email`).
|
|
@@ -238,7 +639,8 @@ const overlaySchema = z.object({
|
|
|
238
639
|
maxAppsPerRequest: editableFields.maxAppsPerRequest.optional(),
|
|
239
640
|
dispatchTimeoutMs: editableFields.dispatchTimeoutMs.optional(),
|
|
240
641
|
download: downloadSchema.optional(),
|
|
241
|
-
skillUpdates: skillUpdatesSchema.optional()
|
|
642
|
+
skillUpdates: skillUpdatesSchema.optional(),
|
|
643
|
+
analyze: analyzeSchema.optional()
|
|
242
644
|
}).strict();
|
|
243
645
|
/** A complete editable settings payload, as the panel saves it. */
|
|
244
646
|
const editableSettingsSchema = z.object(editableFields).strict();
|
|
@@ -292,6 +694,7 @@ function copyRepos(repos) {
|
|
|
292
694
|
function copyCredentials(credentials) {
|
|
293
695
|
return credentials.map((credential) => ({
|
|
294
696
|
url: credential.url,
|
|
697
|
+
...credential.alias === void 0 ? {} : { alias: credential.alias },
|
|
295
698
|
username: credential.username,
|
|
296
699
|
tokenEnv: credential.tokenEnv
|
|
297
700
|
}));
|
|
@@ -315,7 +718,8 @@ function defaultsOf(config, env = process.env) {
|
|
|
315
718
|
...config.download,
|
|
316
719
|
allowedHosts: [...config.download.allowedHosts]
|
|
317
720
|
},
|
|
318
|
-
skillUpdates: { ...config.skillUpdates }
|
|
721
|
+
skillUpdates: { ...config.skillUpdates },
|
|
722
|
+
analyze: { ...config.analyze }
|
|
319
723
|
};
|
|
320
724
|
}
|
|
321
725
|
/**
|
|
@@ -334,7 +738,8 @@ function applyOverlay(defaults, overlay) {
|
|
|
334
738
|
maxAppsPerRequest: overlay.maxAppsPerRequest ?? defaults.maxAppsPerRequest,
|
|
335
739
|
dispatchTimeoutMs: overlay.dispatchTimeoutMs ?? defaults.dispatchTimeoutMs,
|
|
336
740
|
download: overlay.download ?? defaults.download,
|
|
337
|
-
skillUpdates: overlay.skillUpdates ?? defaults.skillUpdates
|
|
741
|
+
skillUpdates: overlay.skillUpdates ?? defaults.skillUpdates,
|
|
742
|
+
analyze: overlay.analyze ?? defaults.analyze
|
|
338
743
|
};
|
|
339
744
|
}
|
|
340
745
|
//#endregion
|
|
@@ -2319,33 +2724,6 @@ var RepoRegistry = class {
|
|
|
2319
2724
|
}
|
|
2320
2725
|
};
|
|
2321
2726
|
//#endregion
|
|
2322
|
-
//#region src/settings/contract.ts
|
|
2323
|
-
/**
|
|
2324
|
-
* 设置面板与 Host 路由共用的路由常量和 JSON 结构。浏览器端 bundle 也会
|
|
2325
|
-
* 编译本文件,因此这里只放类型和常量,不 import 任何 Node 模块。
|
|
2326
|
-
*
|
|
2327
|
-
* @module dsh-guanxiang/settings/contract
|
|
2328
|
-
*/
|
|
2329
|
-
/** GET:面板所需的全部状态(生效设置、默认值、仓库与应用)。 */
|
|
2330
|
-
const STATE_ROUTE = "/guanxiang/state";
|
|
2331
|
-
/** PUT:保存可编辑设置;DELETE:清除面板覆盖、恢复 cordis.yml 默认值。 */
|
|
2332
|
-
const SETTINGS_ROUTE = "/guanxiang/settings";
|
|
2333
|
-
/** POST:立即重新拉取全部仓库,或 body `{ url }` 指定的一个仓库。 */
|
|
2334
|
-
const SYNC_ROUTE = "/guanxiang/sync";
|
|
2335
|
-
/** PUT:body `{ tokenEnv, value }` 把令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受已保存的访问令牌配置引用的名称。 */
|
|
2336
|
-
const TOKEN_ROUTE = "/guanxiang/token";
|
|
2337
|
-
/** Keys of {@link EditableSettings}, in panel order. */
|
|
2338
|
-
const EDITABLE_KEYS = [
|
|
2339
|
-
"reposDir",
|
|
2340
|
-
"repos",
|
|
2341
|
-
"gitCredentials",
|
|
2342
|
-
"fetchIntervalMs",
|
|
2343
|
-
"maxAppsPerRequest",
|
|
2344
|
-
"dispatchTimeoutMs",
|
|
2345
|
-
"download",
|
|
2346
|
-
"skillUpdates"
|
|
2347
|
-
];
|
|
2348
|
-
//#endregion
|
|
2349
2727
|
//#region src/settings/overlay.ts
|
|
2350
2728
|
/**
|
|
2351
2729
|
* 面板覆盖文件 `$DSH_HOME/guanxiang/settings.json` 的读写。文件只保存与
|
|
@@ -2625,6 +3003,7 @@ var GuanxiangService = class {
|
|
|
2625
3003
|
skill;
|
|
2626
3004
|
active = true;
|
|
2627
3005
|
refreshWaitMs;
|
|
3006
|
+
analyzeTokenRef;
|
|
2628
3007
|
booted = Promise.withResolvers();
|
|
2629
3008
|
/**
|
|
2630
3009
|
* @param ctx - plugin context.
|
|
@@ -2637,6 +3016,7 @@ var GuanxiangService = class {
|
|
|
2637
3016
|
this.store = store;
|
|
2638
3017
|
this.defaults = defaultsOf(config);
|
|
2639
3018
|
this.refreshWaitMs = config.refreshWaitMs;
|
|
3019
|
+
this.analyzeTokenRef = config.analyzeTokenRef;
|
|
2640
3020
|
this.current = this.defaults;
|
|
2641
3021
|
const git = options.git ?? runGit;
|
|
2642
3022
|
this.registry = new RepoRegistry({
|
|
@@ -2753,7 +3133,32 @@ var GuanxiangService = class {
|
|
|
2753
3133
|
overridden: overriddenKeys(this.overlay),
|
|
2754
3134
|
...this.overlayError === void 0 ? {} : { overlayError: this.overlayError },
|
|
2755
3135
|
repos: this.registry.status(),
|
|
2756
|
-
credentials: await this.credentialsState()
|
|
3136
|
+
credentials: await this.credentialsState(),
|
|
3137
|
+
analyze: await this.analyzeState()
|
|
3138
|
+
};
|
|
3139
|
+
}
|
|
3140
|
+
/** @returns the analyze route's switch and bearer token, read per request. */
|
|
3141
|
+
analyzeGate() {
|
|
3142
|
+
return {
|
|
3143
|
+
enabled: () => this.current.analyze.enabled,
|
|
3144
|
+
tokenEnv: () => this.analyzeTokenRef,
|
|
3145
|
+
token: async () => {
|
|
3146
|
+
try {
|
|
3147
|
+
return (await this.credentialStore()?.resolve(this.analyzeTokenRef))?.value;
|
|
3148
|
+
} catch (error) {
|
|
3149
|
+
this.ctx.logger?.warn?.("guanxiang: 读取分析接口令牌 %s 失败:%s", this.analyzeTokenRef, error instanceof Error ? error.message : String(error));
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
3155
|
+
async analyzeState() {
|
|
3156
|
+
const store = this.credentialStore();
|
|
3157
|
+
return {
|
|
3158
|
+
route: ANALYZE_ROUTE,
|
|
3159
|
+
tokenEnv: this.analyzeTokenRef,
|
|
3160
|
+
...store === void 0 ? {} : { token: await describeToken(store, this.analyzeTokenRef) },
|
|
3161
|
+
topics: lookupTopics(this.ctx.get?.(FEISHU_TOPICS_SERVICE)).availability
|
|
2757
3162
|
};
|
|
2758
3163
|
}
|
|
2759
3164
|
credentialStore() {
|
|
@@ -2768,24 +3173,7 @@ var GuanxiangService = class {
|
|
|
2768
3173
|
const names = [...new Set(this.current.gitCredentials.map((credential) => credential.tokenEnv))];
|
|
2769
3174
|
return {
|
|
2770
3175
|
available: true,
|
|
2771
|
-
tokens: await Promise.all(names.map(
|
|
2772
|
-
try {
|
|
2773
|
-
const info = await store.describe(tokenEnv);
|
|
2774
|
-
return {
|
|
2775
|
-
tokenEnv,
|
|
2776
|
-
configured: info.configured,
|
|
2777
|
-
...info.source === void 0 ? {} : { source: info.source },
|
|
2778
|
-
writable: info.writable
|
|
2779
|
-
};
|
|
2780
|
-
} catch (error) {
|
|
2781
|
-
return {
|
|
2782
|
-
tokenEnv,
|
|
2783
|
-
configured: false,
|
|
2784
|
-
writable: false,
|
|
2785
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2786
|
-
};
|
|
2787
|
-
}
|
|
2788
|
-
}))
|
|
3176
|
+
tokens: await Promise.all(names.map((tokenEnv) => describeToken(store, tokenEnv)))
|
|
2789
3177
|
};
|
|
2790
3178
|
}
|
|
2791
3179
|
/**
|
|
@@ -2807,10 +3195,11 @@ var GuanxiangService = class {
|
|
|
2807
3195
|
}
|
|
2808
3196
|
/**
|
|
2809
3197
|
* Store the token of one access-token configuration in DSH's credential
|
|
2810
|
-
* store, then re-sync the repositories that use it
|
|
3198
|
+
* store, then re-sync the repositories that use it; or store the analyze
|
|
3199
|
+
* API's bearer token (`analyzeTokenRef`), which applies from the next request.
|
|
2811
3200
|
* @param raw - request body `{ tokenEnv, value }`.
|
|
2812
3201
|
* @returns the new panel state.
|
|
2813
|
-
* @throws SettingsError when the body is invalid, the name is
|
|
3202
|
+
* @throws SettingsError when the body is invalid, the name is neither referenced by the saved settings nor `analyzeTokenRef`, or no credential store is loaded.
|
|
2814
3203
|
*/
|
|
2815
3204
|
async setToken(raw) {
|
|
2816
3205
|
const parsed = tokenSetSchema.safeParse(raw);
|
|
@@ -2830,7 +3219,7 @@ var GuanxiangService = class {
|
|
|
2830
3219
|
return await this.writeToken(parsed.data.tokenEnv, void 0);
|
|
2831
3220
|
}
|
|
2832
3221
|
async writeToken(tokenEnv, value) {
|
|
2833
|
-
if (!this.current.gitCredentials.some((credential) => credential.tokenEnv === tokenEnv)) throw new SettingsError(`${tokenEnv} 不是已保存的访问令牌配置引用的名称`);
|
|
3222
|
+
if (tokenEnv !== this.analyzeTokenRef && !this.current.gitCredentials.some((credential) => credential.tokenEnv === tokenEnv)) throw new SettingsError(`${tokenEnv} 不是已保存的访问令牌配置引用的名称`);
|
|
2834
3223
|
const store = this.credentialStore();
|
|
2835
3224
|
if (store === void 0) throw new SettingsError("当前 DSH 组合没有凭据服务(@deepseek-ai/dsh-credentials-local),无法保存令牌");
|
|
2836
3225
|
if (value === void 0) await store.unset(tokenEnv);
|
|
@@ -2891,6 +3280,24 @@ var GuanxiangService = class {
|
|
|
2891
3280
|
this.skill.invalidate();
|
|
2892
3281
|
}
|
|
2893
3282
|
};
|
|
3283
|
+
async function describeToken(store, tokenEnv) {
|
|
3284
|
+
try {
|
|
3285
|
+
const info = await store.describe(tokenEnv);
|
|
3286
|
+
return {
|
|
3287
|
+
tokenEnv,
|
|
3288
|
+
configured: info.configured,
|
|
3289
|
+
...info.source === void 0 ? {} : { source: info.source },
|
|
3290
|
+
writable: info.writable
|
|
3291
|
+
};
|
|
3292
|
+
} catch (error) {
|
|
3293
|
+
return {
|
|
3294
|
+
tokenEnv,
|
|
3295
|
+
configured: false,
|
|
3296
|
+
writable: false,
|
|
3297
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
2894
3301
|
//#endregion
|
|
2895
3302
|
//#region src/settings/routes.ts
|
|
2896
3303
|
/** Request body cap: the settings payload is small. */
|
|
@@ -3081,7 +3488,8 @@ function mountSettingsRoutes(ctx, service) {
|
|
|
3081
3488
|
* 改进时的 `guanxiang_propose_skill` 工具(经用户授权后把子 Agent 的 SKILL
|
|
3082
3489
|
* 修改提议推送到新分支并创建合并请求)。应用仓库在设置
|
|
3083
3490
|
* 面板中只填 git 地址,由插件拉取;应用描述来自各仓库的
|
|
3084
|
-
* `.dsh/guanxiang.yaml
|
|
3491
|
+
* `.dsh/guanxiang.yaml`。另提供日志平台分析接口 `POST /open-api/logalyze`,
|
|
3492
|
+
* 经 dsh-channels 的 `feishuTopics` 服务在飞书群话题中开始分析。
|
|
3085
3493
|
*
|
|
3086
3494
|
* @module dsh-guanxiang
|
|
3087
3495
|
*/
|
|
@@ -3094,8 +3502,9 @@ const inject = [
|
|
|
3094
3502
|
"subagents"
|
|
3095
3503
|
];
|
|
3096
3504
|
/**
|
|
3097
|
-
* Install the entry skill, the dispatch and SKILL proposal tools,
|
|
3098
|
-
* clone or update the configured
|
|
3505
|
+
* Install the entry skill, the dispatch and SKILL proposal tools, the settings
|
|
3506
|
+
* routes, and the analyze route, then clone or update the configured
|
|
3507
|
+
* repositories in the background.
|
|
3099
3508
|
* @param ctx - plugin context.
|
|
3100
3509
|
* @param config - loader-validated configuration.
|
|
3101
3510
|
* @param options - test seams.
|
|
@@ -3105,6 +3514,7 @@ function apply(ctx, config, options = {}) {
|
|
|
3105
3514
|
service.registerSkill();
|
|
3106
3515
|
service.registerTool();
|
|
3107
3516
|
mountSettingsRoutes(ctx, service);
|
|
3517
|
+
mountAnalyzeRoute(ctx, service.analyzeGate());
|
|
3108
3518
|
ctx.effect(() => {
|
|
3109
3519
|
service.boot().catch((error) => {
|
|
3110
3520
|
ctx.logger?.error?.("guanxiang: 启动失败:%s", error instanceof Error ? error.message : String(error));
|
|
@@ -3115,6 +3525,6 @@ function apply(ctx, config, options = {}) {
|
|
|
3115
3525
|
}, "guanxiang: repositories");
|
|
3116
3526
|
}
|
|
3117
3527
|
//#endregion
|
|
3118
|
-
export { pluginConfigSchema as Config, pluginConfigSchema, DISPATCH_TOOL_NAME, Dispatcher, ENTRY_SKILL_NAME, GuanxiangService, MANIFEST_PATH, PROPOSE_TOOL_NAME, RepoRegistry, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, SkillUpdater, apply, inject, name, readAppManifest, renderChildPrompt, renderEntrySkill, resolveConfig };
|
|
3528
|
+
export { ANALYZE_ROUTE, pluginConfigSchema as Config, pluginConfigSchema, DISPATCH_TOOL_NAME, Dispatcher, ENTRY_SKILL_NAME, GuanxiangService, MANIFEST_PATH, PROPOSE_TOOL_NAME, RepoRegistry, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, SkillUpdater, analyzeRequestHash, apply, inject, name, parseAnalyzeRequest, readAppManifest, renderChildPrompt, renderEntrySkill, resolveConfig };
|
|
3119
3529
|
|
|
3120
3530
|
//# sourceMappingURL=index.js.map
|