@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/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
|
|
|
@@ -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 => ({
|
|
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<
|
|
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
|
-
|
|
190
|
+
const next = await action()
|
|
191
|
+
adopt(next, resetDraft)
|
|
182
192
|
setNotice({ kind: 'ok', text: success })
|
|
183
|
-
return
|
|
193
|
+
return next
|
|
184
194
|
} catch (error) {
|
|
185
195
|
setNotice({ kind: 'error', text: messageOf(error) })
|
|
186
|
-
return
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
379
|
+
onClear: (tokenEnv: string) => void
|
|
335
380
|
}): JSX.Element {
|
|
336
381
|
const [url, setUrl] = useState('')
|
|
337
|
-
const [
|
|
338
|
-
const [
|
|
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(
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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">
|
|
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
|
|
419
|
+
{draft.gitCredentials.length === 0 ? <div className="gx__emptyRow">还没有访问令牌。用 https 地址拉取 GitLab 私有仓库时,在下面填入 GitLab 地址、令牌别名和令牌。</div> : null}
|
|
359
420
|
{draft.gitCredentials.map((credential, index) => {
|
|
360
|
-
const
|
|
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
|
|
367
|
-
|
|
368
|
-
{tokenPill(token)}
|
|
427
|
+
<span>{credential.alias ?? credential.url}</span>
|
|
428
|
+
{tokenPill(status)}
|
|
369
429
|
</div>
|
|
370
|
-
<div className="gx__rowSummary
|
|
371
|
-
{
|
|
372
|
-
{
|
|
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={
|
|
376
|
-
<button type="button" className="gx__btn" disabled={busy || value.trim() === ''} onClick={() => { save(credential.tokenEnv) }}>保存令牌</button>
|
|
377
|
-
{
|
|
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="
|
|
389
|
-
<
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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 <令牌></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
|
+
|
|
438
569
|
function AdvancedTab({ draft, setDraft, overridden, busy, onReset }: TabProps & { overridden: readonly EditableKey[]; busy: boolean; onReset: () => void }): JSX.Element {
|
|
439
570
|
return (
|
|
440
571
|
<>
|
package/src/client/styles.ts
CHANGED
|
@@ -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
|
|
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 => ({
|
|
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,
|
|
54
|
-
* clone or update the configured
|
|
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))
|
package/src/repos/credentials.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
+
}
|