@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.
@@ -1,13 +1,13 @@
1
1
  /**
2
- * 左侧栏「观象」主区页面:应用仓库、访问令牌、日志下载、高级(含 SKILL 改进)四个分页,共用
3
- * 一份草稿和底部保存条;令牌值不进草稿,单独存入 DSH 凭据库。仓库拉取中时
4
- * 每 2 秒刷新状态。
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
 
@@ -77,7 +80,12 @@ function fromDraft(draft: Draft): EditableSettings {
77
80
  return {
78
81
  reposDir: draft.reposDir.trim(),
79
82
  repos: draft.repos.map(repo => repo.branch === undefined || repo.branch.trim() === '' ? { url: repo.url.trim() } : { url: repo.url.trim(), branch: repo.branch.trim() }),
80
- gitCredentials: draft.gitCredentials.map(credential => ({ url: credential.url.trim(), username: credential.username.trim(), tokenEnv: credential.tokenEnv.trim() })),
83
+ gitCredentials: draft.gitCredentials.map(credential => ({
84
+ url: credential.url.trim(),
85
+ ...credential.alias === undefined || credential.alias.trim() === '' ? {} : { alias: credential.alias.trim() },
86
+ username: credential.username.trim(),
87
+ tokenEnv: credential.tokenEnv.trim(),
88
+ })),
81
89
  fetchIntervalMs: Math.round(positive(draft.fetchIntervalMin, '更新间隔', true) * MINUTE),
82
90
  maxAppsPerRequest: maxApps,
83
91
  dispatchTimeoutMs: Math.round(positive(draft.dispatchTimeoutMin, '派发超时') * MINUTE),
@@ -91,6 +99,7 @@ function fromDraft(draft: Draft): EditableSettings {
91
99
  maxFiles,
92
100
  maxFileBytes: Math.round(positive(draft.skillMaxKb, '提议单文件大小上限') * KB),
93
101
  },
102
+ analyze: { enabled: draft.analyzeEnabled },
94
103
  }
95
104
  }
96
105
 
@@ -174,16 +183,17 @@ export function GuanxiangPanel(): JSX.Element {
174
183
 
175
184
  const dirty = state !== undefined && draft !== undefined && !sameSettings(draft, state.settings)
176
185
 
177
- const run = async (action: () => Promise<StateWire>, success: string, resetDraft: boolean): Promise<boolean> => {
186
+ const run = async (action: () => Promise<StateWire>, success: string, resetDraft: boolean): Promise<StateWire | undefined> => {
178
187
  setBusy(true)
179
188
  setNotice(undefined)
180
189
  try {
181
- adopt(await action(), resetDraft)
190
+ const next = await action()
191
+ adopt(next, resetDraft)
182
192
  setNotice({ kind: 'ok', text: success })
183
- return true
193
+ return next
184
194
  } catch (error) {
185
195
  setNotice({ kind: 'error', text: messageOf(error) })
186
- return false
196
+ return undefined
187
197
  } finally {
188
198
  setBusy(false)
189
199
  }
@@ -236,11 +246,30 @@ export function GuanxiangPanel(): JSX.Element {
236
246
  setDraft={setDraft}
237
247
  state={state}
238
248
  busy={busy}
239
- onSave={(tokenEnv, value) => run(() => saveToken(tokenEnv, value), `已保存 ${tokenEnv},使用它的仓库会重新拉取`, false)}
240
- onClear={(tokenEnv) => run(() => clearToken(tokenEnv), `已清除面板保存的 ${tokenEnv}`, false)}
249
+ onAdd={async (credential, token) => {
250
+ const saved = await run(() => saveSettings({ ...state.settings, gitCredentials: [...state.settings.gitCredentials, credential] }), token === '' ? `已添加 ${credential.url},请填入令牌` : `已添加 ${credential.url}`, false)
251
+ const added = saved?.settings.gitCredentials[state.settings.gitCredentials.length]
252
+ if (added === undefined) return false
253
+ setDraft(current => current === undefined || current.gitCredentials.some(item => item.url === added.url) ? current : { ...current, gitCredentials: [...current.gitCredentials, added] })
254
+ return token === '' || await run(() => saveToken(added.tokenEnv, token), `已添加 ${added.url} 并保存令牌,使用它的仓库会重新拉取`, false) !== undefined
255
+ }}
256
+ onSave={async (tokenEnv, value) => await run(() => saveToken(tokenEnv, value), `已保存 ${tokenEnv},使用它的仓库会重新拉取`, false) !== undefined}
257
+ onClear={(tokenEnv) => { void run(() => clearToken(tokenEnv), `已清除面板保存的 ${tokenEnv}`, false) }}
241
258
  />
242
259
  : null}
243
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}
244
273
  {tab === 'advanced' ? <AdvancedTab draft={draft} setDraft={setDraft} overridden={state.overridden} busy={busy} onReset={() => { void run(resetSettings, '已恢复为 cordis.yml 中的配置', true) }} /> : null}
245
274
  </div>
246
275
  {notice === undefined ? null : <div className={`gx__notice gx__notice--${notice.kind}`}>{notice.text}</div>}
@@ -314,7 +343,8 @@ function ReposTab({ draft, setDraft, state, busy, onSync }: TabProps & { state:
314
343
  }
315
344
 
316
345
  const DEFAULT_USERNAME = 'oauth2'
317
- const DEFAULT_TOKEN_ENV = 'GITLAB_TOKEN'
346
+ /** Prefix of the credential keys the panel allocates; namespaced so an unrelated `GITLAB_TOKEN` in the environment is not picked up. */
347
+ const DEFAULT_TOKEN_ENV = 'GUANXIANG_GITLAB_TOKEN'
318
348
 
319
349
  /** Credential store layers of `@deepseek-ai/dsh-credentials-local` other than its own file. */
320
350
  const TOKEN_SOURCES: Readonly<Record<string, string>> = { 'env': '启动环境变量', 'project-env': '项目 .env', 'user-env': '$DSH_HOME/.env' }
@@ -327,54 +357,84 @@ function tokenPill(token: TokenStatusWire | undefined): JSX.Element {
327
357
  return <span className="gx__pill gx__pill--ok"><span className="gx__dot" />已设置{source === undefined ? '' : `(来自${source})`}</span>
328
358
  }
329
359
 
330
- function TokensTab({ draft, setDraft, state, busy, onSave, onClear }: TabProps & {
360
+ /**
361
+ * First of `GUANXIANG_GITLAB_TOKEN`, `GUANXIANG_GITLAB_TOKEN_2`, … that no
362
+ * saved or drafted entry uses.
363
+ * @param used - keys already in use.
364
+ * @returns a free key.
365
+ */
366
+ function freeTokenEnv(used: ReadonlySet<string>): string {
367
+ if (!used.has(DEFAULT_TOKEN_ENV)) return DEFAULT_TOKEN_ENV
368
+ let index = 2
369
+ while (used.has(`${DEFAULT_TOKEN_ENV}_${index}`)) index += 1
370
+ return `${DEFAULT_TOKEN_ENV}_${index}`
371
+ }
372
+
373
+ function TokensTab({ draft, setDraft, state, busy, onAdd, onSave, onClear }: TabProps & {
331
374
  state: StateWire
332
375
  busy: boolean
376
+ /** Saves the entry, then its token; resolves whether both succeeded. */
377
+ onAdd: (credential: GitCredentialSpec, token: string) => Promise<boolean>
333
378
  onSave: (tokenEnv: string, value: string) => Promise<boolean>
334
- onClear: (tokenEnv: string) => Promise<boolean>
379
+ onClear: (tokenEnv: string) => void
335
380
  }): JSX.Element {
336
381
  const [url, setUrl] = useState('')
337
- const [username, setUsername] = useState(DEFAULT_USERNAME)
338
- const [tokenEnv, setTokenEnv] = useState(DEFAULT_TOKEN_ENV)
382
+ const [alias, setAlias] = useState('')
383
+ const [token, setToken] = useState('')
384
+ const [addError, setAddError] = useState<string | undefined>()
385
+ // Spans both requests of one addition; `busy` drops between them.
386
+ const [adding, setAdding] = useState(false)
339
387
  const [values, setValues] = useState<Record<string, string>>({})
340
- const tokens = useMemo(() => new Map(state.credentials.tokens.map(token => [token.tokenEnv, token])), [state.credentials.tokens])
388
+ const tokens = useMemo(() => new Map(state.credentials.tokens.map(item => [item.tokenEnv, item])), [state.credentials.tokens])
341
389
  const saved = useMemo(() => new Set(state.settings.gitCredentials.map(credential => credential.tokenEnv)), [state.settings.gitCredentials])
390
+ const complete = url.trim() !== '' && alias.trim() !== '' && token.trim() !== ''
342
391
  const add = (): void => {
343
- const trimmed = url.trim()
344
- if (trimmed === '' || draft.gitCredentials.some(credential => credential.url === trimmed)) return
345
- setDraft({ ...draft, gitCredentials: [...draft.gitCredentials, { url: trimmed, username: username.trim() === '' ? DEFAULT_USERNAME : username.trim(), tokenEnv: tokenEnv.trim() === '' ? DEFAULT_TOKEN_ENV : tokenEnv.trim() }] })
346
- setUrl('')
392
+ if (!complete || busy || adding) return
393
+ const address = url.trim()
394
+ if (state.settings.gitCredentials.some(credential => credential.url === address)) {
395
+ setAddError('这个地址已经添加过了,请在上面的列表里更新它的令牌。')
396
+ return
397
+ }
398
+ setAddError(undefined)
399
+ setAdding(true)
400
+ const tokenEnv = freeTokenEnv(new Set([...saved, ...draft.gitCredentials.map(credential => credential.tokenEnv)]))
401
+ void onAdd({ url: address, alias: alias.trim(), username: DEFAULT_USERNAME, tokenEnv }, token.trim()).then((ok) => {
402
+ if (!ok) return
403
+ setUrl('')
404
+ setAlias('')
405
+ setToken('')
406
+ }).finally(() => { setAdding(false) })
347
407
  }
348
408
  const save = (name: string): void => {
349
409
  const value = values[name]?.trim() ?? ''
350
- if (value === '') return
410
+ if (value === '' || busy || adding) return
351
411
  void onSave(name, value).then((ok) => { if (ok) setValues(current => ({ ...current, [name]: '' })) })
352
412
  }
413
+ const submitOnEnter = (event: { key: string }): void => { if (event.key === 'Enter') add() }
353
414
  return (
354
415
  <section className="gx__sec">
355
- <h2 className="gx__secTitle">HTTPS 访问令牌 <Overridden keys={state.overridden} name="gitCredentials" /></h2>
416
+ <h2 className="gx__secTitle">GitLab 访问令牌 <Overridden keys={state.overridden} name="gitCredentials" /></h2>
356
417
  {state.credentials.available ? null : <div className="gx__notice gx__notice--error">当前 DSH 组合没有加载凭据服务(@deepseek-ai/dsh-credentials-local),无法保存或读取令牌。</div>}
357
418
  <div className="gx__list">
358
- {draft.gitCredentials.length === 0 ? <div className="gx__emptyRow">还没有访问令牌。用 https 地址拉取 GitLab 等私有仓库时,添加服务器地址后保存,再填入个人访问令牌。</div> : null}
419
+ {draft.gitCredentials.length === 0 ? <div className="gx__emptyRow">还没有访问令牌。用 https 地址拉取 GitLab 私有仓库时,在下面填入 GitLab 地址、令牌别名和令牌。</div> : null}
359
420
  {draft.gitCredentials.map((credential, index) => {
360
- const token = saved.has(credential.tokenEnv) ? tokens.get(credential.tokenEnv) : undefined
421
+ const status = saved.has(credential.tokenEnv) ? tokens.get(credential.tokenEnv) : undefined
361
422
  const value = values[credential.tokenEnv] ?? ''
362
423
  return (
363
424
  <div key={credential.url} className="gx__row gx__row--top">
364
425
  <div className="gx__rowText">
365
426
  <div className="gx__rowLabel">
366
- <span className="gx__mono">{credential.url}</span>
367
- <span className="gx__tag">用户名 {credential.username}</span>
368
- {tokenPill(token)}
427
+ <span>{credential.alias ?? credential.url}</span>
428
+ {tokenPill(status)}
369
429
  </div>
370
- <div className="gx__rowSummary">令牌名称 <span className="gx__mono">{credential.tokenEnv}</span></div>
371
- {token?.error === undefined ? null : <div className="gx__rowError">{token.error}</div>}
372
- {token !== undefined && token.writable
430
+ {credential.alias === undefined ? null : <div className="gx__rowSummary gx__mono">{credential.url}</div>}
431
+ {status?.error === undefined ? null : <div className="gx__rowError">{status.error}</div>}
432
+ {status !== undefined && status.writable
373
433
  ? (
374
434
  <div className="gx__addRow gx__addRow--flush">
375
- <input className="gx__input gx__mono" type="password" autoComplete="new-password" spellCheck={false} placeholder={token.configured ? '输入新令牌以替换' : '粘贴个人访问令牌,例如 glpat-…'} value={value} onChange={(event) => { setValues({ ...values, [credential.tokenEnv]: event.target.value }) }} onKeyDown={(event) => { if (event.key === 'Enter') save(credential.tokenEnv) }} />
376
- <button type="button" className="gx__btn" disabled={busy || value.trim() === ''} onClick={() => { save(credential.tokenEnv) }}>保存令牌</button>
377
- {token.configured && token.source === 'file' ? <button type="button" className="gx__btn gx__btn--ghost" disabled={busy} onClick={() => { void onClear(credential.tokenEnv) }}>清除</button> : null}
435
+ <input className="gx__input gx__mono" type="password" autoComplete="new-password" spellCheck={false} placeholder={status.configured ? '输入新令牌以替换' : '粘贴令牌'} value={value} onChange={(event) => { setValues({ ...values, [credential.tokenEnv]: event.target.value }) }} onKeyDown={(event) => { if (event.key === 'Enter') save(credential.tokenEnv) }} />
436
+ <button type="button" className="gx__btn" disabled={busy || adding || value.trim() === ''} onClick={() => { save(credential.tokenEnv) }}>保存令牌</button>
437
+ {status.configured && status.source === 'file' ? <button type="button" className="gx__btn gx__btn--ghost" disabled={busy} onClick={() => { onClear(credential.tokenEnv) }}>清除</button> : null}
378
438
  </div>
379
439
  )
380
440
  : null}
@@ -385,14 +445,17 @@ function TokensTab({ draft, setDraft, state, busy, onSave, onClear }: TabProps &
385
445
  </div>
386
446
  )
387
447
  })}
388
- <div className="gx__addRow">
389
- <input className="gx__input" placeholder="服务器地址,例如 https://gitlab.example.com" value={url} onChange={(event) => { setUrl(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Enter') add() }} />
390
- <input className="gx__input gx__input--branch" placeholder={`用户名(默认 ${DEFAULT_USERNAME})`} value={username} onChange={(event) => { setUsername(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Enter') add() }} />
391
- <input className="gx__input gx__input--branch gx__mono" placeholder="令牌名称" value={tokenEnv} onChange={(event) => { setTokenEnv(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Enter') add() }} />
392
- <button type="button" className="gx__btn" disabled={url.trim() === ''} onClick={add}>添加</button>
448
+ <div className="gx__addForm">
449
+ <div className="gx__addRow gx__addRow--flush">
450
+ <label className="gx__field">GitLab 地址<input className="gx__input" placeholder="https://gitlab.example.com" value={url} onChange={(event) => { setUrl(event.target.value) }} onKeyDown={submitOnEnter} /></label>
451
+ <label className="gx__field">令牌别名<input className="gx__input" placeholder="例如 guanxiang" value={alias} onChange={(event) => { setAlias(event.target.value) }} onKeyDown={submitOnEnter} /></label>
452
+ <label className="gx__field">令牌<input className="gx__input gx__mono" type="password" autoComplete="new-password" spellCheck={false} placeholder="粘贴个人访问令牌" value={token} onChange={(event) => { setToken(event.target.value) }} onKeyDown={submitOnEnter} /></label>
453
+ <button type="button" className="gx__btn gx__btn--end" disabled={busy || adding || !complete} onClick={add}>添加</button>
454
+ </div>
455
+ {addError === undefined ? null : <div className="gx__rowError">{addError}</div>}
393
456
  </div>
394
457
  </div>
395
- <p className="gx__secFoot">地址在这个前缀下的 https 仓库使用对应令牌拉取(多个匹配时取路径最长的),ssh 地址不受影响。GitLab 个人访问令牌需要 read_repository 权限,用户名填 oauth2 即可。令牌保存在 DSH 凭据库(默认 $DSH_HOME/.credentials.yaml,仅本机用户可读),面板只显示是否已设置;也可以设置与令牌名称同名的环境变量。拉取时令牌只经环境变量交给 git,不写入仓库配置、日志或模型上下文。</p>
458
+ <p className="gx__secFoot">地址在这个前缀下的 https 仓库使用对应令牌(多个匹配时取路径最长的),ssh 地址不受影响。拉取需要 read_repository 权限,开启 SKILL 改进时还需要 write_repository。令牌保存在 DSH 凭据库(默认 $DSH_HOME/.credentials.yaml,仅本机用户可读),面板只显示是否已设置;令牌只经环境变量交给 git,不写入设置文件、仓库配置、日志或模型上下文。</p>
396
459
  </section>
397
460
  )
398
461
  }
@@ -435,6 +498,74 @@ function DownloadTab({ draft, setDraft, overridden }: TabProps & { overridden: r
435
498
  )
436
499
  }
437
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 &lt;令牌&gt;</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 &lt;令牌&gt; · Idempotency-Key: &lt;1-200 个可见 ASCII 字符&gt; · 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
+
438
569
  function AdvancedTab({ draft, setDraft, overridden, busy, onReset }: TabProps & { overridden: readonly EditableKey[]; busy: boolean; onReset: () => void }): JSX.Element {
439
570
  return (
440
571
  <>
@@ -51,6 +51,10 @@ const CSS = `
51
51
  .gx__addRow .gx__input{flex:1 1 160px;width:auto}
52
52
  .gx__addRow .gx__input--branch{flex:0 1 140px}
53
53
  .gx__addRow--flush{padding:6px 0 0}
54
+ .gx__addForm{display:flex;flex-direction:column;gap:6px;padding:10px 14px}
55
+ .gx__field{display:flex;flex-direction:column;gap:2px;flex:1 1 160px;color:var(--gx-fg3);font-size:12px}
56
+ .gx__field .gx__input{flex:none;width:100%}
57
+ .gx__btn--end{align-self:flex-end;height:30px}
54
58
  .gx__emptyRow{padding:11px 14px;color:var(--gx-fg3);font-size:12px}
55
59
  .gx__btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:28px;padding:0 12px;border:1px solid var(--gx-line);border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}
56
60
  .gx__btn:hover:not(:disabled){background:var(--gx-hover)}
package/src/config.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  import { homedir } from 'node:os'
9
9
  import { isAbsolute, join, resolve } from 'node:path'
10
10
  import { z } from 'zod'
11
- import { normalizeCredentialUrl, TOKEN_ENV_PATTERN } from './repos/credentials.ts'
11
+ import { normalizeCredentialUrl } from './repos/credentials.ts'
12
+ import { TOKEN_ENV_PATTERN } from './settings/contract.ts'
12
13
  import type { EditableSettings, GitCredentialSpec, RepoSpec } from './settings/contract.ts'
13
14
 
14
15
  /** Largest delay Node's `setTimeout` honors (2^31 − 1 ms). */
@@ -61,8 +62,10 @@ const credentialSchema = z.object({
61
62
  url: z.string().trim()
62
63
  .refine(value => normalizeCredentialUrl(value) !== undefined, '令牌地址不合法:填 https://主机[:端口][/路径],不含用户名、查询参数或片段')
63
64
  .transform(value => normalizeCredentialUrl(value) as string),
65
+ alias: z.string().trim().max(64, '令牌别名不能超过 64 个字符').regex(/^[^\u0000-\u001f\u007f]*$/, '令牌别名不能包含控制字符').optional()
66
+ .transform(value => value === '' ? undefined : value),
64
67
  username: z.string().trim().regex(/^\S+$/, '用户名不能为空或包含空白').default('oauth2'),
65
- tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN, '令牌名称只能包含字母、数字和下划线,且不能以数字开头'),
68
+ tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN, '令牌名称只能包含字母、数字和下划线,且不能以数字开头(它是凭据库中的键名,不是令牌本身)'),
66
69
  }).strict()
67
70
 
68
71
  const hostSchema = z.string().trim().toLowerCase()
@@ -83,6 +86,10 @@ const skillUpdatesSchema = z.object({
83
86
  maxFileBytes: z.number().int().min(1).max(16 * 1024 * 1024).default(256 * 1024),
84
87
  }).strict()
85
88
 
89
+ const analyzeSchema = z.object({
90
+ enabled: z.boolean().default(false),
91
+ }).strict()
92
+
86
93
  const authorSchema = z.object({
87
94
  name: z.string().trim().min(1, '提交作者名不能为空').refine(value => !/[<>\n]/.test(value), '提交作者名不能包含 < > 或换行'),
88
95
  email: z.string().trim().min(1, '提交作者邮箱不能为空').refine(value => !/[<>\s]/.test(value), '提交作者邮箱不能包含 < > 或空白'),
@@ -110,6 +117,7 @@ export const editableFields = {
110
117
  dispatchTimeoutMs: durationMs(1000),
111
118
  download: downloadSchema,
112
119
  skillUpdates: skillUpdatesSchema,
120
+ analyze: analyzeSchema,
113
121
  }
114
122
 
115
123
  const configSchema = z.object({
@@ -122,6 +130,12 @@ const configSchema = z.object({
122
130
  dispatchTimeoutMs: editableFields.dispatchTimeoutMs.default(30 * 60 * 1000),
123
131
  download: downloadSchema.prefault({}),
124
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'),
125
139
  /**
126
140
  * Author and committer of SKILL improvement commits; omitted uses the git
127
141
  * identity configured on the host (`user.name` / `user.email`).
@@ -160,6 +174,7 @@ export const overlaySchema = z.object({
160
174
  dispatchTimeoutMs: editableFields.dispatchTimeoutMs.optional(),
161
175
  download: downloadSchema.optional(),
162
176
  skillUpdates: skillUpdatesSchema.optional(),
177
+ analyze: analyzeSchema.optional(),
163
178
  }).strict()
164
179
 
165
180
  /** Validated panel overlay. */
@@ -219,7 +234,12 @@ function copyRepos(repos: readonly RepoSpec[]): RepoSpec[] {
219
234
  }
220
235
 
221
236
  function copyCredentials(credentials: readonly GitCredentialSpec[]): GitCredentialSpec[] {
222
- return credentials.map(credential => ({ url: credential.url, username: credential.username, tokenEnv: credential.tokenEnv }))
237
+ return credentials.map(credential => ({
238
+ url: credential.url,
239
+ ...credential.alias === undefined ? {} : { alias: credential.alias },
240
+ username: credential.username,
241
+ tokenEnv: credential.tokenEnv,
242
+ }))
223
243
  }
224
244
 
225
245
  /**
@@ -239,6 +259,7 @@ export function defaultsOf(config: GuanxiangConfig, env: NodeJS.ProcessEnv = pro
239
259
  dispatchTimeoutMs: config.dispatchTimeoutMs,
240
260
  download: { ...config.download, allowedHosts: [...config.download.allowedHosts] },
241
261
  skillUpdates: { ...config.skillUpdates },
262
+ analyze: { ...config.analyze },
242
263
  }
243
264
  }
244
265
 
@@ -259,5 +280,6 @@ export function applyOverlay(defaults: EditableSettings, overlay: SettingsOverla
259
280
  dispatchTimeoutMs: overlay.dispatchTimeoutMs ?? defaults.dispatchTimeoutMs,
260
281
  download: overlay.download ?? defaults.download,
261
282
  skillUpdates: overlay.skillUpdates ?? defaults.skillUpdates,
283
+ analyze: overlay.analyze ?? defaults.analyze,
262
284
  }
263
285
  }
package/src/index.ts CHANGED
@@ -8,11 +8,13 @@
8
8
  * 改进时的 `guanxiang_propose_skill` 工具(经用户授权后把子 Agent 的 SKILL
9
9
  * 修改提议推送到新分支并创建合并请求)。应用仓库在设置
10
10
  * 面板中只填 git 地址,由插件拉取;应用描述来自各仓库的
11
- * `.dsh/guanxiang.yaml`。
11
+ * `.dsh/guanxiang.yaml`。另提供日志平台分析接口 `POST /open-api/logalyze`,
12
+ * 经 dsh-channels 的 `feishuTopics` 服务在飞书群话题中开始分析。
12
13
  *
13
14
  * @module dsh-guanxiang
14
15
  */
15
16
 
17
+ import { mountAnalyzeRoute } from './analyze/server.ts'
16
18
  import { resolveConfig } from './config.ts'
17
19
  import type { HostContext } from './host.ts'
18
20
  import { GuanxiangService } from './service.ts'
@@ -32,7 +34,9 @@ export { ENTRY_SKILL_NAME, renderEntrySkill } from './skill/entry.ts'
32
34
  export { RepoRegistry } from './repos/registry.ts'
33
35
  export { readAppManifest, MANIFEST_PATH } from './repos/manifest.ts'
34
36
  export { GuanxiangService } from './service.ts'
35
- export { SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE } from './settings/contract.ts'
37
+ export { ANALYZE_ROUTE, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE } from './settings/contract.ts'
38
+ export { analyzeRequestHash, parseAnalyzeRequest } from './analyze/contract.ts'
39
+ export type { AnalyzeAccepted, AnalyzeRefused, AnalyzeRequest } from './analyze/contract.ts'
36
40
 
37
41
  /** Cordis plugin name. */
38
42
  export const name = 'guanxiang'
@@ -50,8 +54,9 @@ export interface GuanxiangPluginOptions extends ServiceOptions {
50
54
  }
51
55
 
52
56
  /**
53
- * Install the entry skill, the dispatch and SKILL proposal tools, and the settings routes, then
54
- * clone or update the configured repositories in the background.
57
+ * Install the entry skill, the dispatch and SKILL proposal tools, the settings
58
+ * routes, and the analyze route, then clone or update the configured
59
+ * repositories in the background.
55
60
  * @param ctx - plugin context.
56
61
  * @param config - loader-validated configuration.
57
62
  * @param options - test seams.
@@ -61,6 +66,7 @@ export function apply(ctx: HostContext, config?: unknown, options: GuanxiangPlug
61
66
  service.registerSkill()
62
67
  service.registerTool()
63
68
  mountSettingsRoutes(ctx, service)
69
+ mountAnalyzeRoute(ctx, service.analyzeGate())
64
70
  ctx.effect(() => {
65
71
  void service.boot().catch((error: unknown) => {
66
72
  ctx.logger?.error?.('guanxiang: 启动失败:%s', error instanceof Error ? error.message : String(error))
@@ -9,6 +9,8 @@
9
9
 
10
10
  import type { GitCredentialSpec } from '../settings/contract.ts'
11
11
 
12
+ export { TOKEN_ENV_PATTERN } from '../settings/contract.ts'
13
+
12
14
  /** Environment variables the helper reads; set only on git child processes. */
13
15
  const USERNAME_VARIABLE = 'GUANXIANG_GIT_USERNAME'
14
16
  const TOKEN_VARIABLE = 'GUANXIANG_GIT_TOKEN'
@@ -16,9 +18,6 @@ const TOKEN_VARIABLE = 'GUANXIANG_GIT_TOKEN'
16
18
  /** Answers git's `get` request from the two variables and ignores `store` and `erase`. */
17
19
  const HELPER = `!f() { test "$1" = get || exit 0; printf 'username=%s\\npassword=%s\\n' "$${USERNAME_VARIABLE}" "$${TOKEN_VARIABLE}"; }; f`
18
20
 
19
- /** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier). */
20
- export const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
21
-
22
21
  /** Longest accepted token; personal access tokens are far shorter. */
23
22
  export const MAX_TOKEN_LENGTH = 4096
24
23
 
package/src/service.ts CHANGED
@@ -9,13 +9,16 @@ import { z } from 'zod'
9
9
  import { applyOverlay, defaultsOf, describeIssue, editableSettingsSchema, expandHome } from './config.ts'
10
10
  import type { GuanxiangConfig, SettingsOverlay } from './config.ts'
11
11
  import { abortable } from './abort.ts'
12
+ import type { AnalyzeGate } from './analyze/server.ts'
13
+ import { FEISHU_TOPICS_SERVICE, lookupTopics } from './analyze/topics.ts'
12
14
  import { Dispatcher, isDelegatedAgent, isInside } from './dispatch/tool.ts'
13
15
  import type { HostApproval, HostContext, HostCredentials, ToolActivationContext, WorkingDirectoryService } from './host.ts'
14
16
  import { credentialEnvironment, credentialFor, isAcceptedToken, MAX_TOKEN_LENGTH } from './repos/credentials.ts'
15
17
  import { runGit } from './repos/git.ts'
16
18
  import type { GitRunner } from './repos/git.ts'
17
19
  import { RepoRegistry } from './repos/registry.ts'
18
- import type { CredentialsWire, EditableSettings, StateWire, TokenStatusWire } from './settings/contract.ts'
20
+ import { ANALYZE_ROUTE } from './settings/contract.ts'
21
+ import type { AnalyzeWire, CredentialsWire, EditableSettings, StateWire, TokenStatusWire } from './settings/contract.ts'
19
22
  import { diffOverlay, overriddenKeys } from './settings/overlay.ts'
20
23
  import type { OverlayStore } from './settings/overlay.ts'
21
24
  import { createEntrySkillProvider } from './skill/entry.ts'
@@ -47,6 +50,7 @@ export class GuanxiangService {
47
50
  private readonly skill: ReturnType<typeof createEntrySkillProvider>
48
51
  private active = true
49
52
  private readonly refreshWaitMs: number
53
+ private readonly analyzeTokenRef: string
50
54
  private readonly booted = Promise.withResolvers<void>()
51
55
 
52
56
  /**
@@ -63,6 +67,7 @@ export class GuanxiangService {
63
67
  ) {
64
68
  this.defaults = defaultsOf(config)
65
69
  this.refreshWaitMs = config.refreshWaitMs
70
+ this.analyzeTokenRef = config.analyzeTokenRef
66
71
  this.current = this.defaults
67
72
  const git = options.git ?? runGit
68
73
  this.registry = new RepoRegistry({
@@ -195,6 +200,33 @@ export class GuanxiangService {
195
200
  ...this.overlayError === undefined ? {} : { overlayError: this.overlayError },
196
201
  repos: this.registry.status(),
197
202
  credentials: await this.credentialsState(),
203
+ analyze: await this.analyzeState(),
204
+ }
205
+ }
206
+
207
+ /** @returns the analyze route's switch and bearer token, read per request. */
208
+ analyzeGate(): AnalyzeGate {
209
+ return {
210
+ enabled: () => this.current.analyze.enabled,
211
+ tokenEnv: () => this.analyzeTokenRef,
212
+ token: async () => {
213
+ try {
214
+ return (await this.credentialStore()?.resolve(this.analyzeTokenRef))?.value
215
+ } catch (error) {
216
+ this.ctx.logger?.warn?.('guanxiang: 读取分析接口令牌 %s 失败:%s', this.analyzeTokenRef, error instanceof Error ? error.message : String(error))
217
+ return undefined
218
+ }
219
+ },
220
+ }
221
+ }
222
+
223
+ private async analyzeState(): Promise<AnalyzeWire> {
224
+ const store = this.credentialStore()
225
+ return {
226
+ route: ANALYZE_ROUTE,
227
+ tokenEnv: this.analyzeTokenRef,
228
+ ...store === undefined ? {} : { token: await describeToken(store, this.analyzeTokenRef) },
229
+ topics: lookupTopics(this.ctx.get?.(FEISHU_TOPICS_SERVICE)).availability,
198
230
  }
199
231
  }
200
232
 
@@ -206,14 +238,7 @@ export class GuanxiangService {
206
238
  const store = this.credentialStore()
207
239
  if (store === undefined) return { available: false, tokens: [] }
208
240
  const names = [...new Set(this.current.gitCredentials.map(credential => credential.tokenEnv))]
209
- const tokens = await Promise.all(names.map(async (tokenEnv): Promise<TokenStatusWire> => {
210
- try {
211
- const info = await store.describe(tokenEnv)
212
- return { tokenEnv, configured: info.configured, ...info.source === undefined ? {} : { source: info.source }, writable: info.writable }
213
- } catch (error) {
214
- return { tokenEnv, configured: false, writable: false, error: error instanceof Error ? error.message : String(error) }
215
- }
216
- }))
241
+ const tokens = await Promise.all(names.map(tokenEnv => describeToken(store, tokenEnv)))
217
242
  return { available: true, tokens }
218
243
  }
219
244
 
@@ -237,10 +262,11 @@ export class GuanxiangService {
237
262
 
238
263
  /**
239
264
  * Store the token of one access-token configuration in DSH's credential
240
- * store, then re-sync the repositories that use it.
265
+ * store, then re-sync the repositories that use it; or store the analyze
266
+ * API's bearer token (`analyzeTokenRef`), which applies from the next request.
241
267
  * @param raw - request body `{ tokenEnv, value }`.
242
268
  * @returns the new panel state.
243
- * @throws SettingsError when the body is invalid, the name is not referenced by the saved settings, or no credential store is loaded.
269
+ * @throws SettingsError when the body is invalid, the name is neither referenced by the saved settings nor `analyzeTokenRef`, or no credential store is loaded.
244
270
  */
245
271
  async setToken(raw: unknown): Promise<StateWire> {
246
272
  const parsed = tokenSetSchema.safeParse(raw)
@@ -262,7 +288,7 @@ export class GuanxiangService {
262
288
  }
263
289
 
264
290
  private async writeToken(tokenEnv: string, value: string | undefined): Promise<StateWire> {
265
- if (!this.current.gitCredentials.some(credential => credential.tokenEnv === tokenEnv)) throw new SettingsError(`${tokenEnv} 不是已保存的访问令牌配置引用的名称`)
291
+ if (tokenEnv !== this.analyzeTokenRef && !this.current.gitCredentials.some(credential => credential.tokenEnv === tokenEnv)) throw new SettingsError(`${tokenEnv} 不是已保存的访问令牌配置引用的名称`)
266
292
  const store = this.credentialStore()
267
293
  if (store === undefined) throw new SettingsError('当前 DSH 组合没有凭据服务(@deepseek-ai/dsh-credentials-local),无法保存令牌')
268
294
  if (value === undefined) await store.unset(tokenEnv)
@@ -329,3 +355,12 @@ export class GuanxiangService {
329
355
  this.skill.invalidate()
330
356
  }
331
357
  }
358
+
359
+ async function describeToken(store: HostCredentials, tokenEnv: string): Promise<TokenStatusWire> {
360
+ try {
361
+ const info = await store.describe(tokenEnv)
362
+ return { tokenEnv, configured: info.configured, ...info.source === undefined ? {} : { source: info.source }, writable: info.writable }
363
+ } catch (error) {
364
+ return { tokenEnv, configured: false, writable: false, error: error instanceof Error ? error.message : String(error) }
365
+ }
366
+ }