@modesto_ma/dsh-guanxiang 0.2.0-beta.1 → 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 +65 -1
- package/lib/client.js +242 -6
- package/lib/client.js.map +1 -1
- package/lib/index.js +442 -34
- 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 +89 -5
- package/src/config.ts +14 -0
- package/src/index.ts +10 -4
- package/src/service.ts +47 -12
- package/src/settings/contract.ts +36 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modesto_ma/dsh-guanxiang",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.2",
|
|
4
4
|
"description": "观象 GuanXiang:DeepSeek Harness (DSH) 日志分析插件。/guanxiang 识别日志所属应用,把分析派发给工作目录为该应用代码仓库的子 Agent;Web 左侧栏提供「观象」设置面板",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@deepseek-ai/cordis": "^4.0.5",
|
|
85
|
+
"@modesto_ma/dsh-channels": "^0.8.0-beta.1",
|
|
85
86
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
86
87
|
"@semantic-release/github": "^12.0.10",
|
|
87
88
|
"@semantic-release/npm": "^13.2.0",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志平台分析接口的请求与响应。
|
|
3
|
+
*
|
|
4
|
+
* `POST /open-api/logalyze`(DSH Web 服务器上),带
|
|
5
|
+
* `Authorization: Bearer <令牌>`、`Idempotency-Key` 请求头和 JSON 请求体:
|
|
6
|
+
* 发起人、触发时间、应用与错误码、日志详情页、目标群和提示词。合法请求把
|
|
7
|
+
* 分析根卡片发进群并回 `202`,分析随后在卡片下的话题里流式进行。日志包由
|
|
8
|
+
* 提示词调用的分析 SKILL 自行获取,详情页只列在卡片上。
|
|
9
|
+
*
|
|
10
|
+
* 接口原由 dsh-channels 0.7 提供;迁到观象后请求、响应和错误文本不变。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-guanxiang/analyze/contract
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { z } from 'zod'
|
|
17
|
+
|
|
18
|
+
export { ANALYZE_ROUTE } from '../settings/contract.ts'
|
|
19
|
+
|
|
20
|
+
/** Largest accepted request body (the prompt dominates it). */
|
|
21
|
+
export const ANALYZE_MAX_BODY_BYTES = 1024 * 1024
|
|
22
|
+
|
|
23
|
+
/** Largest accepted prompt, in characters. */
|
|
24
|
+
export const ANALYZE_MAX_PROMPT_CHARS = 100_000
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `Idempotency-Key` format: 1–200 visible ASCII characters without spaces, so
|
|
28
|
+
* a platform can use a UUID or its own `type:id` token.
|
|
29
|
+
*/
|
|
30
|
+
export const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{1,200}$/u
|
|
31
|
+
|
|
32
|
+
/** Feishu `user_id`: not an `open_id` (`ou_`), `union_id` (`on_`), or chat id (`oc_`). */
|
|
33
|
+
const FEISHU_USER_ID = /^(?!ou_|on_|oc_)[A-Za-z0-9_-]+$/u
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Application / error-code segment: it names a workspace directory
|
|
37
|
+
* (`<baseDir>/analyze/<app>/<errorCode>` below the Feishu channel's
|
|
38
|
+
* `baseDir`), so only path-safe characters pass.
|
|
39
|
+
*/
|
|
40
|
+
const SUBJECT_SEGMENT = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/u
|
|
41
|
+
|
|
42
|
+
/** Most detail pages one request may list on its root card. */
|
|
43
|
+
export const ANALYZE_MAX_LOG_DETAILS = 50
|
|
44
|
+
|
|
45
|
+
/** Feishu group chat id (`oc_…`). */
|
|
46
|
+
const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,128}$/u
|
|
47
|
+
|
|
48
|
+
/** JSON body of one analyze request. */
|
|
49
|
+
export const analyzeRequestSchema = z.object({
|
|
50
|
+
/** Feishu user_id of the person who started the analysis on the platform. */
|
|
51
|
+
requesterUserId: z.string().regex(FEISHU_USER_ID, 'must be a Feishu user_id').max(128),
|
|
52
|
+
/** When the analysis was started on the platform, shown verbatim on the root card. */
|
|
53
|
+
triggeredAt: z.string().min(1).max(64).refine(value => value.trim() !== '', 'must not be blank'),
|
|
54
|
+
/** Application the logs belong to (workspace directory segment). */
|
|
55
|
+
app: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
|
|
56
|
+
/** Error code of the logs (workspace directory segment). */
|
|
57
|
+
errorCode: z.string().regex(SUBJECT_SEGMENT, 'must be 1-128 of A-Z a-z 0-9 . _ -'),
|
|
58
|
+
/** Log-platform detail pages of the logs that triggered the analysis (listed on the root card). */
|
|
59
|
+
logDetails: z.array(z.object({
|
|
60
|
+
/** Row label. */
|
|
61
|
+
name: z.string().min(1).max(200).refine(value => value.trim() !== '', 'must not be blank'),
|
|
62
|
+
/** Detail page address. */
|
|
63
|
+
url: z.url({ protocol: /^https?$/u, error: 'must be an http(s) URL' }).max(2048),
|
|
64
|
+
}).strict()).min(1).max(ANALYZE_MAX_LOG_DETAILS),
|
|
65
|
+
/** Target group chat (`oc_…`); must be an authorized group. */
|
|
66
|
+
chatId: z.string().regex(CHAT_ID, 'must be a Feishu group chat_id (oc_...)'),
|
|
67
|
+
/** Prompt forwarded verbatim as the analysis Session's first user message. */
|
|
68
|
+
prompt: z.string().min(1).max(ANALYZE_MAX_PROMPT_CHARS)
|
|
69
|
+
.refine(value => value.trim() !== '', 'must not be blank'),
|
|
70
|
+
}).strict()
|
|
71
|
+
|
|
72
|
+
/** One validated analyze request. */
|
|
73
|
+
export type AnalyzeRequest = z.infer<typeof analyzeRequestSchema>
|
|
74
|
+
|
|
75
|
+
/** Body of an accepted (or replayed) request. */
|
|
76
|
+
export interface AnalyzeAccepted {
|
|
77
|
+
readonly ok: true
|
|
78
|
+
/** Target group chat id. */
|
|
79
|
+
readonly chatId: string
|
|
80
|
+
/** Root card message id; the analysis topic hangs below it. */
|
|
81
|
+
readonly messageId: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Body of a refused request. */
|
|
85
|
+
export interface AnalyzeRefused {
|
|
86
|
+
readonly ok: false
|
|
87
|
+
/** Readable reason (Chinese, shown to platform operators). */
|
|
88
|
+
readonly error: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One HTTP answer of the analyze handler. */
|
|
92
|
+
export interface AnalyzeResponse {
|
|
93
|
+
readonly status: number
|
|
94
|
+
readonly body: AnalyzeAccepted | AnalyzeRefused
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Fingerprint of one validated request, used to tell a retry of the same
|
|
99
|
+
* request from a reused `Idempotency-Key`. Field order is fixed: dsh-channels
|
|
100
|
+
* 0.7 stored the same digest, so its accepted keys keep replaying.
|
|
101
|
+
* @param request - the validated request.
|
|
102
|
+
* @returns SHA-256 hex digest.
|
|
103
|
+
*/
|
|
104
|
+
export function analyzeRequestHash(request: AnalyzeRequest): string {
|
|
105
|
+
const canonical = JSON.stringify([
|
|
106
|
+
request.requesterUserId, request.triggeredAt, request.app, request.errorCode,
|
|
107
|
+
request.logDetails.map(detail => [detail.name, detail.url]), request.chatId, request.prompt,
|
|
108
|
+
])
|
|
109
|
+
return createHash('sha256').update(canonical).digest('hex')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validate one decoded JSON body.
|
|
114
|
+
* @param raw - the decoded body.
|
|
115
|
+
* @returns the request, or a readable list of field errors.
|
|
116
|
+
*/
|
|
117
|
+
export function parseAnalyzeRequest(raw: unknown): { ok: true; value: AnalyzeRequest } | { ok: false; error: string } {
|
|
118
|
+
const parsed = analyzeRequestSchema.safeParse(raw)
|
|
119
|
+
if (parsed.success) return { ok: true, value: parsed.data }
|
|
120
|
+
const details = parsed.error.issues
|
|
121
|
+
.map(issue => `${issue.path.join('.') || '(body)'}: ${issue.message}`)
|
|
122
|
+
.join('; ')
|
|
123
|
+
return { ok: false, error: `请求参数不合法:${details}` }
|
|
124
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志平台分析接口在 DSH Web 服务器上的路由。
|
|
3
|
+
*
|
|
4
|
+
* 只在宿主同时提供 `webServer` 和版本兼容的 dsh-channels `feishuTopics`
|
|
5
|
+
* 服务时注册精确路由 `POST /open-api/logalyze`;dsh-channels 未加载、重载
|
|
6
|
+
* 中、版本不兼容或为 0.7 及更早版本(自己注册该路由)时观象不注册,两者
|
|
7
|
+
* 不会争用同一路径。
|
|
8
|
+
* 本模块按顺序做传输层检查:路径、开关、方法、Bearer 令牌、
|
|
9
|
+
* `Idempotency-Key`、媒体类型、请求体大小和 JSON 校验;通过后交给
|
|
10
|
+
* `feishuTopics`。请求体、令牌和提示词都不写日志。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-guanxiang/analyze/server
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
16
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
17
|
+
import type { HostContext, HostWebServer } from '../host.ts'
|
|
18
|
+
import {
|
|
19
|
+
ANALYZE_MAX_BODY_BYTES, ANALYZE_ROUTE, IDEMPOTENCY_KEY_PATTERN, parseAnalyzeRequest,
|
|
20
|
+
} from './contract.ts'
|
|
21
|
+
import type { AnalyzeRequest, AnalyzeResponse } from './contract.ts'
|
|
22
|
+
import { FEISHU_TOPICS_SERVICE, launchAnalysis, lookupTopics } from './topics.ts'
|
|
23
|
+
|
|
24
|
+
/** The plugin side of the route: switch and token, read per request. */
|
|
25
|
+
export interface AnalyzeGate {
|
|
26
|
+
/** @returns whether the API is switched on; false answers every request with 404. */
|
|
27
|
+
enabled(): boolean
|
|
28
|
+
/** Credential reference holding the bearer token, named in the 503 answer while it is unset. */
|
|
29
|
+
tokenEnv(): string
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the expected bearer token.
|
|
32
|
+
* @returns the token, or undefined when it is not configured or cannot be read.
|
|
33
|
+
*/
|
|
34
|
+
token(): Promise<string | undefined>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Everything after transport checks: start the analysis of one validated request. */
|
|
38
|
+
export type AnalyzeLaunch = (request: AnalyzeRequest, idempotencyKey: string) => Promise<AnalyzeResponse>
|
|
39
|
+
|
|
40
|
+
class HttpError extends Error {
|
|
41
|
+
constructor(readonly status: number, message: string) {
|
|
42
|
+
super(message)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function send(res: ServerResponse, response: AnalyzeResponse): void {
|
|
47
|
+
const body = JSON.stringify(response.body)
|
|
48
|
+
res.writeHead(response.status, {
|
|
49
|
+
'content-type': 'application/json; charset=utf-8',
|
|
50
|
+
'cache-control': 'no-store',
|
|
51
|
+
'content-length': Buffer.byteLength(body),
|
|
52
|
+
})
|
|
53
|
+
res.end(body)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function refuse(res: ServerResponse, status: number, error: string, headers: Record<string, string> = {}): void {
|
|
57
|
+
for (const [name, value] of Object.entries(headers)) res.setHeader(name, value)
|
|
58
|
+
send(res, { status, body: { ok: false, error } })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Constant-time token comparison over fixed-length digests. */
|
|
62
|
+
function tokenMatches(presented: string, expected: string): boolean {
|
|
63
|
+
const left = createHash('sha256').update(presented).digest()
|
|
64
|
+
const right = createHash('sha256').update(expected).digest()
|
|
65
|
+
return timingSafeEqual(left, right)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The bearer credential of one request, or undefined. */
|
|
69
|
+
function bearerOf(req: IncomingMessage): string | undefined {
|
|
70
|
+
const header = req.headers.authorization
|
|
71
|
+
if (header === undefined) return undefined
|
|
72
|
+
const match = /^Bearer[ \t]+(\S+)[ \t]*$/iu.exec(header)
|
|
73
|
+
return match?.[1]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Read the body up to {@link ANALYZE_MAX_BODY_BYTES}; larger bodies fail with 413. */
|
|
77
|
+
async function readBody(req: IncomingMessage): Promise<string> {
|
|
78
|
+
const declared = Number(req.headers['content-length'])
|
|
79
|
+
if (Number.isFinite(declared) && declared > ANALYZE_MAX_BODY_BYTES) {
|
|
80
|
+
throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`)
|
|
81
|
+
}
|
|
82
|
+
const chunks: Buffer[] = []
|
|
83
|
+
let size = 0
|
|
84
|
+
for await (const chunk of req) {
|
|
85
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer
|
|
86
|
+
size += buffer.length
|
|
87
|
+
if (size > ANALYZE_MAX_BODY_BYTES) throw new HttpError(413, `请求体超过 ${ANALYZE_MAX_BODY_BYTES} 字节`)
|
|
88
|
+
chunks.push(buffer)
|
|
89
|
+
}
|
|
90
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Handle one HTTP request against the analyze API.
|
|
95
|
+
* @param req - the incoming request.
|
|
96
|
+
* @param res - its response.
|
|
97
|
+
* @param gate - switch and token.
|
|
98
|
+
* @param launch - starts the analysis of a validated request.
|
|
99
|
+
*/
|
|
100
|
+
export async function handleAnalyzeHttp(
|
|
101
|
+
req: IncomingMessage,
|
|
102
|
+
res: ServerResponse,
|
|
103
|
+
gate: AnalyzeGate,
|
|
104
|
+
launch: AnalyzeLaunch,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const pathname = new URL(req.url ?? '/', 'http://analyze.invalid').pathname
|
|
107
|
+
if (pathname !== ANALYZE_ROUTE) {
|
|
108
|
+
refuse(res, 404, '未知路径')
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (!gate.enabled()) {
|
|
112
|
+
refuse(res, 404, '日志分析接口未开启,请在观象设置面板「分析接口」打开开关')
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if ((req.method ?? 'GET').toUpperCase() !== 'POST') {
|
|
116
|
+
refuse(res, 405, '只接受 POST', { allow: 'POST' })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const expected = await gate.token()
|
|
120
|
+
if (expected === undefined || expected === '') {
|
|
121
|
+
refuse(res, 503, `分析接口未配置访问令牌,请在观象设置面板「分析接口」保存令牌(凭据名 ${gate.tokenEnv()})`)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
const presented = bearerOf(req)
|
|
125
|
+
if (presented === undefined || !tokenMatches(presented, expected)) {
|
|
126
|
+
refuse(res, 401, '访问令牌无效', { 'www-authenticate': 'Bearer' })
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const idempotencyKey = req.headers['idempotency-key']
|
|
130
|
+
if (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
|
|
131
|
+
refuse(res, 400, '缺少或不合法的 Idempotency-Key 请求头(1-200 个可见 ASCII 字符)')
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const mediaType = (req.headers['content-type'] ?? '').split(';')[0]?.trim().toLowerCase()
|
|
135
|
+
if (mediaType !== 'application/json') {
|
|
136
|
+
refuse(res, 415, 'Content-Type 必须是 application/json')
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
let raw: unknown
|
|
140
|
+
try {
|
|
141
|
+
raw = JSON.parse(await readBody(req))
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof HttpError) {
|
|
144
|
+
refuse(res, error.status, error.message)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
refuse(res, 400, '请求体不是合法的 JSON')
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
const parsed = parseAnalyzeRequest(raw)
|
|
151
|
+
if (!parsed.ok) {
|
|
152
|
+
refuse(res, 400, parsed.error)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
send(res, await launch(parsed.value, idempotencyKey))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Register the route while the host provides both `webServer` and a
|
|
160
|
+
* compatible `feishuTopics`; it is removed when either goes away. Requests skip the DSH
|
|
161
|
+
* login gate on purpose: the bearer token is this API's only credential.
|
|
162
|
+
* @param ctx - the plugin context.
|
|
163
|
+
* @param gate - switch and token.
|
|
164
|
+
*/
|
|
165
|
+
export function mountAnalyzeRoute(ctx: HostContext, gate: AnalyzeGate): void {
|
|
166
|
+
const attach = (scope: HostContext): void => {
|
|
167
|
+
const server = scope.get?.('webServer') as HostWebServer | undefined
|
|
168
|
+
if (server === undefined) return
|
|
169
|
+
const topics = lookupTopics(scope.get?.(FEISHU_TOPICS_SERVICE))
|
|
170
|
+
if (topics.availability !== 'ready') {
|
|
171
|
+
if (topics.availability === 'incompatible') ctx.logger?.warn?.('guanxiang: dsh-channels 的 feishuTopics 服务版本与观象不兼容,分析接口未注册;请把两者升级到相互兼容的版本')
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
const { service } = topics
|
|
175
|
+
const launch: AnalyzeLaunch = (request, key) => launchAnalysis(service, request, key)
|
|
176
|
+
scope.effect(() => server.register({
|
|
177
|
+
kind: 'exact',
|
|
178
|
+
path: ANALYZE_ROUTE,
|
|
179
|
+
handler: async (req, res) => {
|
|
180
|
+
try {
|
|
181
|
+
await handleAnalyzeHttp(req, res, gate, launch)
|
|
182
|
+
} catch (error) {
|
|
183
|
+
ctx.logger?.warn?.('guanxiang: 分析请求处理失败:%s', String(error))
|
|
184
|
+
if (!res.headersSent) refuse(res, 500, `分析请求处理失败:${String(error)}`)
|
|
185
|
+
else res.destroy()
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
}), 'guanxiang: analyze route')
|
|
189
|
+
}
|
|
190
|
+
if (typeof ctx.inject === 'function') {
|
|
191
|
+
ctx.inject(['webServer', FEISHU_TOPICS_SERVICE], attach)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
attach(ctx)
|
|
195
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分析请求到 dsh-channels `feishuTopics` 服务的转换:根卡片内容、工作区
|
|
3
|
+
* `analyze/<app>/<errorCode>`、幂等键与指纹,以及服务结果到 HTTP 响应的映射。
|
|
4
|
+
* 卡片、工作区和指纹与 dsh-channels 0.7 自带的分析接口一致,升级前接受的
|
|
5
|
+
* `Idempotency-Key` 升级后仍按原结果重放。
|
|
6
|
+
*
|
|
7
|
+
* 只从 `@modesto_ma/dsh-channels/topics` 引入类型;服务在运行时经
|
|
8
|
+
* `ctx.get('feishuTopics')` 取得,版本不符时不调用。
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-guanxiang/analyze/topics
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
FeishuTopicService, FeishuTopicsVersion, TopicLaunchResult, TopicLaunchSpec,
|
|
15
|
+
} from '@modesto_ma/dsh-channels/topics'
|
|
16
|
+
import type { TopicsAvailability } from '../settings/contract.ts'
|
|
17
|
+
import { analyzeRequestHash } from './contract.ts'
|
|
18
|
+
import type { AnalyzeRequest, AnalyzeResponse } from './contract.ts'
|
|
19
|
+
|
|
20
|
+
/** Name dsh-channels provides the service under. */
|
|
21
|
+
export const FEISHU_TOPICS_SERVICE = 'feishuTopics'
|
|
22
|
+
|
|
23
|
+
/** The `feishuTopics` contract version this plugin speaks. */
|
|
24
|
+
export const FEISHU_TOPICS_VERSION: FeishuTopicsVersion = 1
|
|
25
|
+
|
|
26
|
+
/** Result of looking the service up. */
|
|
27
|
+
export type TopicsLookup =
|
|
28
|
+
| { readonly availability: 'ready'; readonly service: FeishuTopicService }
|
|
29
|
+
| { readonly availability: Exclude<TopicsAvailability, 'ready'> }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Classify the value `ctx.get('feishuTopics')` returned. The provider is
|
|
33
|
+
* another package that may be older or newer than this one, so its version
|
|
34
|
+
* is checked at run time.
|
|
35
|
+
* @param value - the looked-up service, or undefined.
|
|
36
|
+
* @returns the usable service, or why there is none.
|
|
37
|
+
*/
|
|
38
|
+
export function lookupTopics(value: unknown): TopicsLookup {
|
|
39
|
+
if (value === undefined || value === null) return { availability: 'missing' }
|
|
40
|
+
const service = value as Partial<Record<keyof FeishuTopicService, unknown>>
|
|
41
|
+
if (service.version !== FEISHU_TOPICS_VERSION || typeof service.launch !== 'function') return { availability: 'incompatible' }
|
|
42
|
+
return { availability: 'ready', service: value as FeishuTopicService }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The topic one analyze request starts.
|
|
47
|
+
* @param request - the validated request.
|
|
48
|
+
* @param idempotencyKey - the request's `Idempotency-Key`, used unchanged as the launch key.
|
|
49
|
+
* @returns the launch spec.
|
|
50
|
+
*/
|
|
51
|
+
export function analyzeTopicSpec(request: AnalyzeRequest, idempotencyKey: string): TopicLaunchSpec {
|
|
52
|
+
return {
|
|
53
|
+
key: idempotencyKey,
|
|
54
|
+
fingerprint: analyzeRequestHash(request),
|
|
55
|
+
chatId: request.chatId,
|
|
56
|
+
requesterUserId: request.requesterUserId,
|
|
57
|
+
card: {
|
|
58
|
+
title: '日志分析',
|
|
59
|
+
taskName: '分析',
|
|
60
|
+
lead: [
|
|
61
|
+
{ text: `于 ${request.triggeredAt} 发起了对 ` },
|
|
62
|
+
{ text: request.app, bold: true },
|
|
63
|
+
{ text: ' 应用 ' },
|
|
64
|
+
{ text: request.errorCode, bold: true },
|
|
65
|
+
{ text: ' 错误码不良日志的分析。' },
|
|
66
|
+
],
|
|
67
|
+
links: { nameHeader: '日志', linkHeader: '详情页', rows: request.logDetails },
|
|
68
|
+
},
|
|
69
|
+
workspace: {
|
|
70
|
+
segments: ['analyze', request.app, request.errorCode],
|
|
71
|
+
title: `日志分析 · ${request.app} · ${request.errorCode}`,
|
|
72
|
+
},
|
|
73
|
+
prompt: request.prompt,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* HTTP answer of one launch result.
|
|
79
|
+
* @param result - what the service answered.
|
|
80
|
+
* @returns status and body.
|
|
81
|
+
*/
|
|
82
|
+
export function analyzeResponseOf(result: TopicLaunchResult): AnalyzeResponse {
|
|
83
|
+
if (result.ok) return { status: 202, body: { ok: true, chatId: result.chatId, messageId: result.messageId } }
|
|
84
|
+
switch (result.code) {
|
|
85
|
+
case 'not-connected': return refused(503, result.message)
|
|
86
|
+
case 'forbidden-chat': return refused(403, result.message)
|
|
87
|
+
case 'key-conflict': return refused(409, 'Idempotency-Key 已被内容不同的请求使用')
|
|
88
|
+
case 'send-failed': return refused(502, result.message)
|
|
89
|
+
// The request passed this plugin's own validation, so a refused spec is a
|
|
90
|
+
// disagreement between the two plugins, not a client error.
|
|
91
|
+
case 'invalid': return refused(500, `分析请求处理失败:${result.message}`)
|
|
92
|
+
default: return refused(500, `分析请求处理失败:${String((result as { message?: unknown }).message)}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Start the analysis topic of one validated request.
|
|
98
|
+
* @param topics - the `feishuTopics` service.
|
|
99
|
+
* @param request - the validated request.
|
|
100
|
+
* @param idempotencyKey - the request's `Idempotency-Key`.
|
|
101
|
+
* @returns the HTTP answer.
|
|
102
|
+
*/
|
|
103
|
+
export async function launchAnalysis(topics: FeishuTopicService, request: AnalyzeRequest, idempotencyKey: string): Promise<AnalyzeResponse> {
|
|
104
|
+
return analyzeResponseOf(await topics.launch(analyzeTopicSpec(request, idempotencyKey)))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function refused(status: number, error: string): AnalyzeResponse {
|
|
108
|
+
return { status, body: { ok: false, error } }
|
|
109
|
+
}
|
package/src/client/page.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 左侧栏「观象」主区页面:应用仓库、访问令牌、日志下载、分析接口、高级(含 SKILL 改进)五个
|
|
3
|
+
* 分页,共用一份草稿和底部保存条;令牌值不进草稿,单独存入 DSH 凭据库。
|
|
4
|
+
* 仓库拉取中时每 2 秒刷新状态。
|
|
5
5
|
*
|
|
6
6
|
* @module dsh-guanxiang/client/page
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
10
|
-
import type { EditableKey, EditableSettings, GitCredentialSpec, RepoSpec, RepoStatusWire, StateWire, TokenStatusWire } from '../settings/contract.ts'
|
|
10
|
+
import type { AnalyzeWire, EditableKey, EditableSettings, GitCredentialSpec, RepoSpec, RepoStatusWire, StateWire, TokenStatusWire } from '../settings/contract.ts'
|
|
11
11
|
import { clearToken, fetchState, messageOf, resetSettings, saveSettings, saveToken, syncRepos } from './api.ts'
|
|
12
12
|
|
|
13
13
|
/** Sidebar label and page title. */
|
|
@@ -17,12 +17,13 @@ const KB = 1024
|
|
|
17
17
|
const MB = 1024 * 1024
|
|
18
18
|
const MINUTE = 60 * 1000
|
|
19
19
|
|
|
20
|
-
type TabId = 'repos' | 'tokens' | 'download' | 'advanced'
|
|
20
|
+
type TabId = 'repos' | 'tokens' | 'download' | 'analyze' | 'advanced'
|
|
21
21
|
|
|
22
22
|
const TABS: readonly { id: TabId; label: string }[] = [
|
|
23
23
|
{ id: 'repos', label: '应用仓库' },
|
|
24
24
|
{ id: 'tokens', label: '访问令牌' },
|
|
25
25
|
{ id: 'download', label: '日志下载' },
|
|
26
|
+
{ id: 'analyze', label: '分析接口' },
|
|
26
27
|
{ id: 'advanced', label: '高级' },
|
|
27
28
|
]
|
|
28
29
|
|
|
@@ -40,6 +41,7 @@ interface Draft {
|
|
|
40
41
|
skillUpdatesEnabled: boolean
|
|
41
42
|
skillMaxFiles: string
|
|
42
43
|
skillMaxKb: string
|
|
44
|
+
analyzeEnabled: boolean
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
function trimNumber(value: number): string {
|
|
@@ -60,6 +62,7 @@ function toDraft(settings: EditableSettings): Draft {
|
|
|
60
62
|
skillUpdatesEnabled: settings.skillUpdates.enabled,
|
|
61
63
|
skillMaxFiles: String(settings.skillUpdates.maxFiles),
|
|
62
64
|
skillMaxKb: trimNumber(settings.skillUpdates.maxFileBytes / KB),
|
|
65
|
+
analyzeEnabled: settings.analyze.enabled,
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -96,6 +99,7 @@ function fromDraft(draft: Draft): EditableSettings {
|
|
|
96
99
|
maxFiles,
|
|
97
100
|
maxFileBytes: Math.round(positive(draft.skillMaxKb, '提议单文件大小上限') * KB),
|
|
98
101
|
},
|
|
102
|
+
analyze: { enabled: draft.analyzeEnabled },
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
|
|
@@ -254,6 +258,18 @@ export function GuanxiangPanel(): JSX.Element {
|
|
|
254
258
|
/>
|
|
255
259
|
: null}
|
|
256
260
|
{tab === 'download' ? <DownloadTab draft={draft} setDraft={setDraft} overridden={state.overridden} /> : null}
|
|
261
|
+
{tab === 'analyze'
|
|
262
|
+
? <AnalyzeTab
|
|
263
|
+
draft={draft}
|
|
264
|
+
setDraft={setDraft}
|
|
265
|
+
analyze={state.analyze}
|
|
266
|
+
credentialsAvailable={state.credentials.available}
|
|
267
|
+
overridden={state.overridden}
|
|
268
|
+
busy={busy}
|
|
269
|
+
onSave={async value => await run(() => saveToken(state.analyze.tokenEnv, value), '已保存分析接口令牌,下一个请求起生效', false) !== undefined}
|
|
270
|
+
onClear={() => { void run(() => clearToken(state.analyze.tokenEnv), '已清除面板保存的分析接口令牌', false) }}
|
|
271
|
+
/>
|
|
272
|
+
: null}
|
|
257
273
|
{tab === 'advanced' ? <AdvancedTab draft={draft} setDraft={setDraft} overridden={state.overridden} busy={busy} onReset={() => { void run(resetSettings, '已恢复为 cordis.yml 中的配置', true) }} /> : null}
|
|
258
274
|
</div>
|
|
259
275
|
{notice === undefined ? null : <div className={`gx__notice gx__notice--${notice.kind}`}>{notice.text}</div>}
|
|
@@ -482,6 +498,74 @@ function DownloadTab({ draft, setDraft, overridden }: TabProps & { overridden: r
|
|
|
482
498
|
)
|
|
483
499
|
}
|
|
484
500
|
|
|
501
|
+
const TOPICS_STATUS: Readonly<Record<AnalyzeWire['topics'], { pill: string; text: string }>> = {
|
|
502
|
+
ready: { pill: 'gx__pill--ok', text: '飞书渠道就绪' },
|
|
503
|
+
missing: { pill: 'gx__pill--bad', text: '未找到飞书渠道' },
|
|
504
|
+
incompatible: { pill: 'gx__pill--bad', text: '飞书渠道版本不兼容' },
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function AnalyzeTab({ draft, setDraft, analyze, credentialsAvailable, overridden, busy, onSave, onClear }: TabProps & {
|
|
508
|
+
analyze: AnalyzeWire
|
|
509
|
+
credentialsAvailable: boolean
|
|
510
|
+
overridden: readonly EditableKey[]
|
|
511
|
+
busy: boolean
|
|
512
|
+
onSave: (value: string) => Promise<boolean>
|
|
513
|
+
onClear: () => void
|
|
514
|
+
}): JSX.Element {
|
|
515
|
+
const [value, setValue] = useState('')
|
|
516
|
+
const token = analyze.token
|
|
517
|
+
const save = (): void => {
|
|
518
|
+
const trimmed = value.trim()
|
|
519
|
+
if (trimmed === '' || busy) return
|
|
520
|
+
void onSave(trimmed).then((ok) => { if (ok) setValue('') })
|
|
521
|
+
}
|
|
522
|
+
const topics = TOPICS_STATUS[analyze.topics]
|
|
523
|
+
const endpoint = new URL(analyze.route.slice(1), document.baseURI).href
|
|
524
|
+
return (
|
|
525
|
+
<>
|
|
526
|
+
<section className="gx__sec">
|
|
527
|
+
<h2 className="gx__secTitle">日志平台分析接口</h2>
|
|
528
|
+
<div className="gx__list">
|
|
529
|
+
<div className="gx__row">
|
|
530
|
+
<div className="gx__rowText">
|
|
531
|
+
<div className="gx__rowLabel">开启接口 <Overridden keys={overridden} name="analyze" /><span className={`gx__pill ${topics.pill}`}><span className="gx__dot" />{topics.text}</span></div>
|
|
532
|
+
<div className="gx__rowSummary">日志平台调用接口后,观象经飞书渠道(dsh-channels 0.8 及以上)在目标群发出分析卡片,并在卡片下的话题里开始分析;群内成员都可以在话题里继续追问。关闭时接口回复 404。</div>
|
|
533
|
+
{analyze.topics === 'ready' ? null : <div className="gx__rowError">{analyze.topics === 'missing' ? '接口需要 dsh-channels 0.8 及以上版本并已加载;dsh-channels 0.7 自带同名接口,升级前由它提供。' : '请把观象与 dsh-channels 升级到相互兼容的版本。'}</div>}
|
|
534
|
+
</div>
|
|
535
|
+
<div className="gx__rowCtl"><input type="checkbox" className="gx__check" aria-label="开启分析接口" checked={draft.analyzeEnabled} onChange={(event) => { setDraft({ ...draft, analyzeEnabled: event.target.checked }) }} /></div>
|
|
536
|
+
</div>
|
|
537
|
+
<div className="gx__row gx__row--top">
|
|
538
|
+
<div className="gx__rowText">
|
|
539
|
+
<div className="gx__rowLabel">访问令牌 <span className="gx__tag gx__mono">{analyze.tokenEnv}</span>{credentialsAvailable ? tokenPill(token) : null}</div>
|
|
540
|
+
<div className="gx__rowSummary">日志平台以 <span className="gx__mono">Authorization: Bearer <令牌></span> 调用接口。令牌未设置时接口回复 503。</div>
|
|
541
|
+
{credentialsAvailable ? null : <div className="gx__rowError">当前 DSH 组合没有加载凭据服务(@deepseek-ai/dsh-credentials-local),无法保存或读取令牌。</div>}
|
|
542
|
+
{token?.error === undefined ? null : <div className="gx__rowError">{token.error}</div>}
|
|
543
|
+
{token !== undefined && token.writable
|
|
544
|
+
? (
|
|
545
|
+
<div className="gx__addRow gx__addRow--flush">
|
|
546
|
+
<input className="gx__input gx__mono" type="password" autoComplete="new-password" spellCheck={false} placeholder={token.configured ? '输入新令牌以替换' : '粘贴令牌'} value={value} onChange={(event) => { setValue(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Enter') save() }} />
|
|
547
|
+
<button type="button" className="gx__btn" disabled={busy || value.trim() === ''} onClick={save}>保存令牌</button>
|
|
548
|
+
{token.configured && token.source === 'file' ? <button type="button" className="gx__btn gx__btn--ghost" disabled={busy} onClick={onClear}>清除</button> : null}
|
|
549
|
+
</div>
|
|
550
|
+
)
|
|
551
|
+
: null}
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
</section>
|
|
556
|
+
<section className="gx__sec">
|
|
557
|
+
<h2 className="gx__secTitle">调用方式</h2>
|
|
558
|
+
<div className="gx__list">
|
|
559
|
+
<div className="gx__row"><div className="gx__rowText"><div className="gx__rowLabel">地址</div><div className="gx__rowSummary gx__mono">POST {endpoint}</div></div></div>
|
|
560
|
+
<div className="gx__row"><div className="gx__rowText"><div className="gx__rowLabel">请求头</div><div className="gx__rowSummary gx__mono">Authorization: Bearer <令牌> · Idempotency-Key: <1-200 个可见 ASCII 字符> · Content-Type: application/json</div></div></div>
|
|
561
|
+
<div className="gx__row"><div className="gx__rowText"><div className="gx__rowLabel">请求体</div><div className="gx__rowSummary gx__mono">requesterUserId, triggeredAt, app, errorCode, logDetails[{'{'}name, url{'}'}], chatId, prompt</div></div></div>
|
|
562
|
+
<div className="gx__row"><div className="gx__rowText"><div className="gx__rowLabel">响应</div><div className="gx__rowSummary">202 已发出分析卡片(同一 Idempotency-Key 重试返回同一张卡片);400/401/404/405/409/413/415 请求问题;403 目标群未授权;502 卡片发送失败;503 飞书未连接或令牌未设置。字段说明见观象 README。</div></div></div>
|
|
563
|
+
</div>
|
|
564
|
+
</section>
|
|
565
|
+
</>
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
485
569
|
function AdvancedTab({ draft, setDraft, overridden, busy, onReset }: TabProps & { overridden: readonly EditableKey[]; busy: boolean; onReset: () => void }): JSX.Element {
|
|
486
570
|
return (
|
|
487
571
|
<>
|
package/src/config.ts
CHANGED
|
@@ -86,6 +86,10 @@ const skillUpdatesSchema = z.object({
|
|
|
86
86
|
maxFileBytes: z.number().int().min(1).max(16 * 1024 * 1024).default(256 * 1024),
|
|
87
87
|
}).strict()
|
|
88
88
|
|
|
89
|
+
const analyzeSchema = z.object({
|
|
90
|
+
enabled: z.boolean().default(false),
|
|
91
|
+
}).strict()
|
|
92
|
+
|
|
89
93
|
const authorSchema = z.object({
|
|
90
94
|
name: z.string().trim().min(1, '提交作者名不能为空').refine(value => !/[<>\n]/.test(value), '提交作者名不能包含 < > 或换行'),
|
|
91
95
|
email: z.string().trim().min(1, '提交作者邮箱不能为空').refine(value => !/[<>\s]/.test(value), '提交作者邮箱不能包含 < > 或空白'),
|
|
@@ -113,6 +117,7 @@ export const editableFields = {
|
|
|
113
117
|
dispatchTimeoutMs: durationMs(1000),
|
|
114
118
|
download: downloadSchema,
|
|
115
119
|
skillUpdates: skillUpdatesSchema,
|
|
120
|
+
analyze: analyzeSchema,
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
const configSchema = z.object({
|
|
@@ -125,6 +130,12 @@ const configSchema = z.object({
|
|
|
125
130
|
dispatchTimeoutMs: editableFields.dispatchTimeoutMs.default(30 * 60 * 1000),
|
|
126
131
|
download: downloadSchema.prefault({}),
|
|
127
132
|
skillUpdates: skillUpdatesSchema.prefault({}),
|
|
133
|
+
analyze: analyzeSchema.prefault({}),
|
|
134
|
+
/**
|
|
135
|
+
* Credential reference (environment-variable name) whose value is the
|
|
136
|
+
* analyze API's bearer token.
|
|
137
|
+
*/
|
|
138
|
+
analyzeTokenRef: z.string().trim().regex(TOKEN_ENV_PATTERN, '令牌名称只能包含字母、数字和下划线,且不能以数字开头').default('GUANXIANG_ANALYZE_TOKEN'),
|
|
128
139
|
/**
|
|
129
140
|
* Author and committer of SKILL improvement commits; omitted uses the git
|
|
130
141
|
* identity configured on the host (`user.name` / `user.email`).
|
|
@@ -163,6 +174,7 @@ export const overlaySchema = z.object({
|
|
|
163
174
|
dispatchTimeoutMs: editableFields.dispatchTimeoutMs.optional(),
|
|
164
175
|
download: downloadSchema.optional(),
|
|
165
176
|
skillUpdates: skillUpdatesSchema.optional(),
|
|
177
|
+
analyze: analyzeSchema.optional(),
|
|
166
178
|
}).strict()
|
|
167
179
|
|
|
168
180
|
/** Validated panel overlay. */
|
|
@@ -247,6 +259,7 @@ export function defaultsOf(config: GuanxiangConfig, env: NodeJS.ProcessEnv = pro
|
|
|
247
259
|
dispatchTimeoutMs: config.dispatchTimeoutMs,
|
|
248
260
|
download: { ...config.download, allowedHosts: [...config.download.allowedHosts] },
|
|
249
261
|
skillUpdates: { ...config.skillUpdates },
|
|
262
|
+
analyze: { ...config.analyze },
|
|
250
263
|
}
|
|
251
264
|
}
|
|
252
265
|
|
|
@@ -267,5 +280,6 @@ export function applyOverlay(defaults: EditableSettings, overlay: SettingsOverla
|
|
|
267
280
|
dispatchTimeoutMs: overlay.dispatchTimeoutMs ?? defaults.dispatchTimeoutMs,
|
|
268
281
|
download: overlay.download ?? defaults.download,
|
|
269
282
|
skillUpdates: overlay.skillUpdates ?? defaults.skillUpdates,
|
|
283
|
+
analyze: overlay.analyze ?? defaults.analyze,
|
|
270
284
|
}
|
|
271
285
|
}
|