@ai-wendao/ant 0.3.9 → 0.3.11
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/MIGRATION.md +6 -6
- package/README.md +9 -1
- package/client.js +74 -123
- package/index.js +59 -245
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +4 -4
- package/skill-install.js +337 -0
package/MIGRATION.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
此方式适用于 DSH Desktop RC2 使用默认、启用 HMR 的 Desktop profile,且目标 ANT 版本已发布到 npm:
|
|
14
14
|
|
|
15
15
|
1. 打开 **插件 → ANT**,点击 **卸载** 并确认。
|
|
16
|
-
2. 回到插件列表,点击 **添加插件**,输入已发布的直接包名,例如 `@ai-wendao/ant@0.3.
|
|
16
|
+
2. 回到插件列表,点击 **添加插件**,输入已发布的直接包名,例如 `@ai-wendao/ant@0.3.11`,然后点击 **安装**。
|
|
17
17
|
3. 安装完成后启用 ANT,打开设置页,检查服务连接与获准模型。
|
|
18
18
|
|
|
19
19
|
RC2 的 Desktop 基础 profile 默认启用 HMR;官方管理器在该模式下会先卸载 Bundle,再用随 DSH 提供的包管理器移除依赖。包卸载本身不会调用 ANT 的“断开设备”操作,也不会清空 DSH 凭证存储。若 profile 禁用了 HMR,管理器会提示先停止 profile;此时改用下方 CLI 备用流程。真实成员 profile 的凭证保留仍需在目标 RC2 客户端上验收。
|
|
@@ -59,14 +59,14 @@ node packages/ant-bundle/tools/migrate-legacy.mjs migrate --apply
|
|
|
59
59
|
安装了系统 Node.js 的成员也可以从已发布 npm 包运行:
|
|
60
60
|
|
|
61
61
|
```sh
|
|
62
|
-
npm exec --yes --package @ai-wendao/ant@0.3.
|
|
63
|
-
npm exec --yes --package @ai-wendao/ant@0.3.
|
|
62
|
+
npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate inspect
|
|
63
|
+
npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate migrate
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
退出 DSH 后执行变更:
|
|
67
67
|
|
|
68
68
|
```sh
|
|
69
|
-
npm exec --yes --package @ai-wendao/ant@0.3.
|
|
69
|
+
npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate migrate --apply
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
若 CLI 不在 PATH,可追加 `--dsh-cli "<Desktop CLI 完整路径>"`;若 DSH 使用自定义数据目录,可追加 `--dsh-home "<DSH_HOME 路径>"`。
|
|
@@ -76,8 +76,8 @@ npm exec --yes --package @ai-wendao/ant@0.3.9 -- ant-migrate migrate --apply
|
|
|
76
76
|
`migrate --apply` 会输出受限权限的备份目录,备份包含 profile 的 `package.json`、`pnpm-lock.yaml`、`pnpm-workspace.yaml`(若存在)及其 SHA-256 校验值,不包含密钥库。备份仅用于迁移状态核验和保留原始清单,不等于离线恢复包。确认 DSH 已退出后,通过同一工具调用:
|
|
77
77
|
|
|
78
78
|
```sh
|
|
79
|
-
npm exec --yes --package @ai-wendao/ant@0.3.
|
|
80
|
-
npm exec --yes --package @ai-wendao/ant@0.3.
|
|
79
|
+
npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate rollback --backup "<备份目录>"
|
|
80
|
+
npm exec --yes --package @ai-wendao/ant@0.3.11 -- ant-migrate rollback --backup "<备份目录>" --apply
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
回退只在 scoped 包已安装、旧 alias 尚未移除的中间态工作;它会卸载 scoped 包并保留旧 alias。已完成迁移的 profile 不接受该回退,避免自动恢复已知有加载问题的 alias。若安装包时 npm registry 不可用,保持 DSH 关闭,稍后重新运行 `migrate --apply`。
|
package/README.md
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
# ANT for DeepSeek Harness
|
|
2
2
|
|
|
3
|
-
ANT connects a DeepSeek Harness Desktop profile to its team's
|
|
3
|
+
ANT (AI-native Teams) connects a DeepSeek Harness Desktop profile to its team's membership and approved models.
|
|
4
|
+
|
|
5
|
+
飞书和企业微信的机器人连接、消息收发由 DSH-IM 负责;ANT Bundle 不再提供自己的 IM 接入。ANT 继续负责团队成员身份和模型授权。
|
|
6
|
+
|
|
7
|
+
## 企业精选技能
|
|
8
|
+
|
|
9
|
+
团队管理员可在 ANT 管理台从 GitHub 目录、ZIP、TAR、TAR.GZ/TGZ 或单份 `SKILL.md` 导入技能,核对来源、文件和依赖后发布。已连接团队的成员在 ANT 插件页面点击“安装到这台 DSH”,由 ANT Host 将固定版本的完整文件集放进 DSH 的用户技能目录;随后由 DSH 原生技能提供者发现和使用。
|
|
10
|
+
|
|
11
|
+
安装不会自动运行技能脚本或安装 Python、Node 等系统依赖。ANT 只更新或卸载自己管理且本机内容未被修改的技能目录;发现同名个人技能或本机改动时会停止覆盖。
|
|
4
12
|
|
|
5
13
|
## Existing `ant` alias installations
|
|
6
14
|
|
package/client.js
CHANGED
|
@@ -144,6 +144,78 @@ window.__ModuleLoader__.load({
|
|
|
144
144
|
h(ModelSyncFooter, { remote, serviceUrl, onServiceUrlChange: setServiceUrl }))
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
function SkillCenter({ connected }) {
|
|
148
|
+
const [skills, setSkills] = useState([])
|
|
149
|
+
const [loading, setLoading] = useState(false)
|
|
150
|
+
const [workingId, setWorkingId] = useState('')
|
|
151
|
+
const [message, setMessage] = useState('')
|
|
152
|
+
const refresh = async () => {
|
|
153
|
+
if (!connected) return
|
|
154
|
+
setLoading(true)
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetch('/api/ant-teams/skills', { cache: 'no-store' })
|
|
157
|
+
const result = await readLocalJsonResponse(response)
|
|
158
|
+
if (!response.ok) throw new Error(result.message || `读取技能失败(${response.status})。`)
|
|
159
|
+
if (!Array.isArray(result.skills)) throw new Error('企业技能目录格式无效。')
|
|
160
|
+
setSkills(result.skills)
|
|
161
|
+
setMessage(result.skills.length ? '' : '团队暂未发布精选技能。')
|
|
162
|
+
} catch (error) {
|
|
163
|
+
setSkills([])
|
|
164
|
+
setMessage(error instanceof Error ? error.message : '无法读取企业精选技能。')
|
|
165
|
+
}
|
|
166
|
+
finally { setLoading(false) }
|
|
167
|
+
}
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (connected) void refresh()
|
|
170
|
+
else { setSkills([]); setMessage('') }
|
|
171
|
+
}, [connected])
|
|
172
|
+
const changeInstall = async (skill, remove = false) => {
|
|
173
|
+
const action = remove ? '卸载' : skill.local_status === 'update-available' ? '更新' : '安装'
|
|
174
|
+
const prompt = remove
|
|
175
|
+
? `从这台 DSH 卸载 ${skill.title}?已修改的本地文件不会被删除。`
|
|
176
|
+
: `将 ${skill.title} ${action}到这台 DSH?\n\n本机依赖:${skill.requirements}\n\n技能可能让 DSH 读取文件或执行脚本,仍受 DSH 的工具授权控制。ANT 不会自动安装系统依赖。`
|
|
177
|
+
if (!window.confirm(prompt)) return
|
|
178
|
+
setWorkingId(skill.id)
|
|
179
|
+
setMessage(`正在${action} ${skill.title}…`)
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetch(`/api/ant-teams/skills/${remove ? 'uninstall' : 'install'}`, {
|
|
182
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
183
|
+
body: JSON.stringify(remove ? { dsh_name: skill.dsh_name } : { skill_id: skill.id }),
|
|
184
|
+
})
|
|
185
|
+
const result = await readLocalJsonResponse(response)
|
|
186
|
+
if (!response.ok) throw new Error(result.message || `${action}失败(${response.status})。`)
|
|
187
|
+
await refresh()
|
|
188
|
+
setMessage(`${skill.title} 已${action}。DSH 将从本机技能目录发现它。`)
|
|
189
|
+
} catch (error) { setMessage(error instanceof Error ? error.message : `${action}失败。`) }
|
|
190
|
+
finally { setWorkingId('') }
|
|
191
|
+
}
|
|
192
|
+
return h('section', { style: panelStyle },
|
|
193
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 } },
|
|
194
|
+
h('div', null,
|
|
195
|
+
h('h3', { style: { margin: 0, fontSize: 16, fontWeight: 650 } }, '企业精选技能'),
|
|
196
|
+
h('p', { style: { ...mutedTextStyle, marginTop: 4 } }, '管理员审核后发布;点击安装即可交给 DSH 原生技能系统使用。')),
|
|
197
|
+
h('button', { type: 'button', disabled: !connected || loading || Boolean(workingId), onClick: () => void refresh(), style: secondaryButtonStyle }, loading ? '读取中…' : '刷新')),
|
|
198
|
+
!connected ? h('p', { style: mutedTextStyle }, '连接团队后可浏览和安装精选技能。') : null,
|
|
199
|
+
message ? h('p', { role: 'status', style: mutedTextStyle }, message) : null,
|
|
200
|
+
...skills.map((skill) => h('div', { key: skill.id, style: { display: 'grid', gap: 7, padding: '12px 0', borderTop: '1px solid var(--dsw-alias-border, #8c99994d)' } },
|
|
201
|
+
h('strong', { style: { fontSize: 14 } }, skill.title),
|
|
202
|
+
h('p', { style: mutedTextStyle }, skill.description),
|
|
203
|
+
h('p', { style: mutedTextStyle }, `来源:${skill.source_kind === 'github' ? 'GitHub' : '管理员上传'} · 许可证:${skill.license} · ${skill.file_count} 个文件`),
|
|
204
|
+
h('p', { style: mutedTextStyle }, `本机依赖:${skill.requirements}`),
|
|
205
|
+
skill.local_status === 'conflict'
|
|
206
|
+
? h('p', { role: 'status', style: mutedTextStyle }, '本机同名技能有未受 ANT 管理的内容、文件已被修改或备份损坏;请联系管理员处理。')
|
|
207
|
+
: skill.local_status === 'not-discovered'
|
|
208
|
+
? h('div', null,
|
|
209
|
+
h('p', { role: 'status', style: mutedTextStyle }, '文件已在本机,但 DSH 技能服务没有发现它;请检查 DSH 技能目录配置。'),
|
|
210
|
+
h('button', { type: 'button', disabled: Boolean(workingId), onClick: () => void changeInstall(skill, true), style: secondaryButtonStyle }, '卸载本机副本'))
|
|
211
|
+
: h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8 } },
|
|
212
|
+
h('button', { type: 'button', disabled: !connected || Boolean(workingId) || skill.local_status === 'installed',
|
|
213
|
+
onClick: () => void changeInstall(skill), style: primaryButtonStyle },
|
|
214
|
+
skill.local_status === 'installed' ? '已安装' : skill.local_status === 'update-available' ? '更新技能' : '安装到这台 DSH'),
|
|
215
|
+
skill.local_status !== 'not-installed' ? h('button', { type: 'button', disabled: Boolean(workingId),
|
|
216
|
+
onClick: () => void changeInstall(skill, true), style: secondaryButtonStyle }, '卸载') : null))))
|
|
217
|
+
}
|
|
218
|
+
|
|
147
219
|
function ModelSyncFooter({ remote, serviceUrl, onServiceUrlChange }) {
|
|
148
220
|
const [email, setEmail] = useState('')
|
|
149
221
|
const [code, setCode] = useState('')
|
|
@@ -153,14 +225,6 @@ window.__ModuleLoader__.load({
|
|
|
153
225
|
const [credentialConfigured, setCredentialConfigured] = useState(null)
|
|
154
226
|
const [reauthenticate, setReauthenticate] = useState(false)
|
|
155
227
|
const [needsReconnect, setNeedsReconnect] = useState(false)
|
|
156
|
-
const [feishuCode, setFeishuCode] = useState('')
|
|
157
|
-
const [feishuCodeMessage, setFeishuCodeMessage] = useState('')
|
|
158
|
-
const [feishuApps, setFeishuApps] = useState([])
|
|
159
|
-
const [feishuAppId, setFeishuAppId] = useState('')
|
|
160
|
-
const [feishuAppSecret, setFeishuAppSecret] = useState('')
|
|
161
|
-
const [feishuVerificationToken, setFeishuVerificationToken] = useState('')
|
|
162
|
-
const [feishuEncryptKey, setFeishuEncryptKey] = useState('')
|
|
163
|
-
const [feishuAppStatus, setFeishuAppStatus] = useState('')
|
|
164
228
|
const [bundleRelease, setBundleRelease] = useState(null)
|
|
165
229
|
const [bundleUpdate, setBundleUpdate] = useState({ state: 'idle', currentVersion: '', message: '' })
|
|
166
230
|
const [approvedBuilds, setApprovedBuilds] = useState([])
|
|
@@ -439,7 +503,6 @@ window.__ModuleLoader__.load({
|
|
|
439
503
|
if (configured) {
|
|
440
504
|
void refreshModels()
|
|
441
505
|
void checkBundleUpdate()
|
|
442
|
-
void refreshFeishuApps().catch((error) => setFeishuAppStatus(error.message))
|
|
443
506
|
}
|
|
444
507
|
}).catch(() => { if (current) setCredentialConfigured(null) })
|
|
445
508
|
return () => { current = false }
|
|
@@ -524,7 +587,6 @@ window.__ModuleLoader__.load({
|
|
|
524
587
|
setCredentialConfigured(true)
|
|
525
588
|
setNeedsReconnect(false)
|
|
526
589
|
setReauthenticate(false)
|
|
527
|
-
await refreshFeishuApps().catch((error) => setFeishuAppStatus(error.message))
|
|
528
590
|
setMessage(`已连接 ${result.email || normalizedEmail},同步 ${result.models.length} 个获准模型。它们现已出现在 DSH 模型选择器中。`)
|
|
529
591
|
} catch (error) {
|
|
530
592
|
setMessage(error instanceof Error ? error.message : '连接失败,请稍后重试。')
|
|
@@ -533,82 +595,6 @@ window.__ModuleLoader__.load({
|
|
|
533
595
|
}
|
|
534
596
|
}
|
|
535
597
|
|
|
536
|
-
const manageFeishuApps = async (body) => {
|
|
537
|
-
const response = await fetch('/api/ant-teams/feishu/apps', {
|
|
538
|
-
method: 'POST',
|
|
539
|
-
headers: { 'Content-Type': 'application/json' },
|
|
540
|
-
body: JSON.stringify(body),
|
|
541
|
-
})
|
|
542
|
-
const result = await readLocalJsonResponse(response)
|
|
543
|
-
if (!response.ok) throw new Error(result.message || `飞书操作失败(${response.status})`)
|
|
544
|
-
return result
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const refreshFeishuApps = async () => {
|
|
548
|
-
const result = await manageFeishuApps({ action: 'list' })
|
|
549
|
-
if (!Array.isArray(result.apps)) throw new Error('ANT 返回的飞书应用列表格式无效。')
|
|
550
|
-
setFeishuApps(result.apps)
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const saveFeishuApp = async (event) => {
|
|
554
|
-
event.preventDefault()
|
|
555
|
-
setBusy(true)
|
|
556
|
-
setFeishuAppStatus('正在验证飞书应用并保存密钥…')
|
|
557
|
-
setFeishuCode('')
|
|
558
|
-
setFeishuCodeMessage('')
|
|
559
|
-
try {
|
|
560
|
-
const result = await manageFeishuApps({
|
|
561
|
-
action: 'save',
|
|
562
|
-
app_id: feishuAppId.trim(),
|
|
563
|
-
app_secret: feishuAppSecret,
|
|
564
|
-
verification_token: feishuVerificationToken,
|
|
565
|
-
encrypt_key: feishuEncryptKey,
|
|
566
|
-
})
|
|
567
|
-
if (!result.app?.id || !result.app?.callback_url) throw new Error('ANT 未返回飞书回调信息。')
|
|
568
|
-
setFeishuAppId('')
|
|
569
|
-
setFeishuAppSecret('')
|
|
570
|
-
setFeishuVerificationToken('')
|
|
571
|
-
setFeishuEncryptKey('')
|
|
572
|
-
setFeishuApps((apps) => [...apps.filter((app) => app.id !== result.app.id), result.app])
|
|
573
|
-
setFeishuAppStatus('App ID 和 App Secret 已验证;回调密钥已加密保存。请把下方回调地址填入该飞书应用的事件订阅设置。')
|
|
574
|
-
} catch (error) {
|
|
575
|
-
setFeishuAppStatus(error instanceof Error ? error.message : '飞书应用保存失败,请稍后重试。')
|
|
576
|
-
} finally {
|
|
577
|
-
setBusy(false)
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
const createFeishuLinkCode = async (app) => {
|
|
582
|
-
setBusy(true)
|
|
583
|
-
setFeishuCode('')
|
|
584
|
-
setFeishuCodeMessage('正在生成一次性飞书绑定码…')
|
|
585
|
-
try {
|
|
586
|
-
const result = await manageFeishuApps({ action: 'link-code', id: app.id })
|
|
587
|
-
if (typeof result.code !== 'string') throw new Error(result.message || 'ANT 未返回绑定码。')
|
|
588
|
-
setFeishuCode(result.code)
|
|
589
|
-
setFeishuCodeMessage(`有效期 ${Math.ceil(result.expires_in_seconds / 60)} 分钟。请在飞书中私聊该机器人,发送:绑定 ${result.code}`)
|
|
590
|
-
} catch (error) {
|
|
591
|
-
setFeishuCodeMessage(error instanceof Error ? error.message : '飞书绑定码生成失败,请稍后重试。')
|
|
592
|
-
} finally {
|
|
593
|
-
setBusy(false)
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const disableFeishuApp = async (app) => {
|
|
598
|
-
if (!window.confirm(`停用飞书应用 ${app.app_id}?停用后它不能再触发新任务,已接收的任务仍会尝试完成并回复。`)) return
|
|
599
|
-
setBusy(true)
|
|
600
|
-
setFeishuAppStatus('正在停用飞书应用…')
|
|
601
|
-
try {
|
|
602
|
-
await manageFeishuApps({ action: 'disable', id: app.id })
|
|
603
|
-
await refreshFeishuApps()
|
|
604
|
-
setFeishuAppStatus('飞书应用已停用,并解除其成员身份绑定。')
|
|
605
|
-
} catch (error) {
|
|
606
|
-
setFeishuAppStatus(error instanceof Error ? error.message : '飞书应用停用失败。')
|
|
607
|
-
} finally {
|
|
608
|
-
setBusy(false)
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
598
|
const showConnectForm = credentialConfigured !== true || reauthenticate || needsReconnect
|
|
613
599
|
return h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 400px), 1fr))', alignItems: 'start', gap: 16 } },
|
|
614
600
|
h('section', { style: panelStyle },
|
|
@@ -669,43 +655,8 @@ window.__ModuleLoader__.load({
|
|
|
669
655
|
}),
|
|
670
656
|
h('button', { type: 'submit', disabled: busy || code.length !== 8, style: primaryButtonStyle }, busy ? '连接中…' : '验证并连接')) : null,
|
|
671
657
|
message ? h('p', { role: 'status', style: mutedTextStyle }, message) : null),
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
h('h3', { style: { margin: 0, fontSize: 16, fontWeight: 650 } }, '个人飞书应用'),
|
|
675
|
-
h('p', { style: { ...mutedTextStyle, marginTop: 4 } }, '每位成员自行管理飞书应用和身份绑定。群 ID 不参与授权;只有绑定到此应用的本人可以触发自己的 DSH。')),
|
|
676
|
-
h('form', { onSubmit: saveFeishuApp, style: { display: 'grid', gap: 9 } },
|
|
677
|
-
h('label', { htmlFor: 'ant-feishu-app-id', style: { fontSize: 13, fontWeight: 550 } }, '飞书 App ID'),
|
|
678
|
-
h('input', { id: 'ant-feishu-app-id', value: feishuAppId, required: true, maxLength: 128, placeholder: 'cli_…', onChange: (event) => setFeishuAppId(event.target.value), style: inputStyle }),
|
|
679
|
-
h('p', { style: mutedTextStyle }, '在飞书开放平台的应用凭证页查看,通常以 cli_ 开头。'),
|
|
680
|
-
h('label', { htmlFor: 'ant-feishu-app-secret', style: { fontSize: 13, fontWeight: 550 } }, 'App Secret'),
|
|
681
|
-
h('input', { id: 'ant-feishu-app-secret', type: 'password', autoComplete: 'new-password', value: feishuAppSecret, required: true, maxLength: 512, onChange: (event) => setFeishuAppSecret(event.target.value), style: inputStyle }),
|
|
682
|
-
h('p', { style: mutedTextStyle }, '同一应用凭证页中的密钥;ANT 用它验证 App ID 并读取机器人身份。'),
|
|
683
|
-
h('label', { htmlFor: 'ant-feishu-verification-token', style: { fontSize: 13, fontWeight: 550 } }, 'Verification Token'),
|
|
684
|
-
h('input', { id: 'ant-feishu-verification-token', type: 'password', autoComplete: 'new-password', value: feishuVerificationToken, required: true, maxLength: 512, onChange: (event) => setFeishuVerificationToken(event.target.value), style: inputStyle }),
|
|
685
|
-
h('p', { style: mutedTextStyle }, '在事件订阅设置中生成,用来校验飞书回调是否属于此应用。'),
|
|
686
|
-
h('label', { htmlFor: 'ant-feishu-encrypt-key', style: { fontSize: 13, fontWeight: 550 } }, 'Encrypt Key'),
|
|
687
|
-
h('input', { id: 'ant-feishu-encrypt-key', type: 'password', autoComplete: 'new-password', value: feishuEncryptKey, required: true, maxLength: 512, onChange: (event) => setFeishuEncryptKey(event.target.value), style: inputStyle }),
|
|
688
|
-
h('p', { style: mutedTextStyle }, '在事件订阅设置中生成;启用事件加密时,ANT 用它校验签名并解密回调。'),
|
|
689
|
-
h('p', { style: mutedTextStyle }, '保存时 ANT 会验证 App ID 和 App Secret,并读取该应用机器人的身份。所有密钥只在服务端加密保存。'),
|
|
690
|
-
h('button', { type: 'submit', disabled: busy, style: primaryButtonStyle }, busy ? '正在验证并保存…' : '验证并保存应用')),
|
|
691
|
-
feishuAppStatus ? h('p', { role: 'status', style: mutedTextStyle }, feishuAppStatus) : null,
|
|
692
|
-
feishuApps.length ? h('div', { style: { display: 'grid', gap: 10 } }, ...feishuApps.map((app) => h('article', { key: app.id, style: { ...panelStyle, padding: 14, gap: 8 } },
|
|
693
|
-
h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12 } },
|
|
694
|
-
h('strong', null, app.app_id),
|
|
695
|
-
h('span', { style: mutedTextStyle }, !app.active ? '已停用' : app.linked ? '已绑定飞书身份' : '尚未绑定')),
|
|
696
|
-
h('p', { style: mutedTextStyle }, '飞书事件回调地址:'),
|
|
697
|
-
h('code', { style: { overflowWrap: 'anywhere', padding: 8, borderRadius: 6, backgroundColor: 'var(--dsw-alias-bg-layer-2)' } }, app.callback_url),
|
|
698
|
-
app.active ? h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8 } },
|
|
699
|
-
h('button', { type: 'button', disabled: busy, onClick: () => void createFeishuLinkCode(app), style: primaryButtonStyle }, '生成此机器人的绑定码'),
|
|
700
|
-
h('button', { type: 'button', disabled: busy, onClick: () => void disableFeishuApp(app), style: secondaryButtonStyle }, '停用应用'))
|
|
701
|
-
: h('button', { type: 'button', disabled: busy, onClick: () => { setFeishuAppId(app.app_id); setFeishuAppStatus('请重新填写此应用的三个密钥以启用。') }, style: secondaryButtonStyle }, '重新配置启用')))) : null,
|
|
702
|
-
feishuCode ? h('code', { style: {
|
|
703
|
-
display: 'block', padding: '12px 14px', borderRadius: 9,
|
|
704
|
-
color: 'var(--dsw-alias-label-primary)',
|
|
705
|
-
backgroundColor: 'var(--dsw-alias-bg-layer-2)', fontSize: 18, letterSpacing: '.12em',
|
|
706
|
-
} }, feishuCode) : null,
|
|
707
|
-
feishuCodeMessage ? h('p', { role: 'status', style: mutedTextStyle }, feishuCodeMessage) : null,
|
|
708
|
-
h('p', { style: mutedTextStyle }, '保存后,把对应应用显示的回调地址填入飞书事件订阅,订阅 im.message.receive_v1。私聊绑定和机器人收发消息需要 im:message;群内 @ 机器人触发还需要 im:message.group_at_msg。验证回调后生成绑定码,再在飞书中私聊该机器人发送“绑定 <验证码>”。之后可在任意群中 @ 该机器人;ANT 会核对机器人和发送者身份,再把任务交给你的 DSH。')) : null)
|
|
658
|
+
h(SkillCenter, { connected: credentialConfigured === true }))
|
|
659
|
+
|
|
709
660
|
}
|
|
710
661
|
|
|
711
662
|
function apply(ctx) {
|
package/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export const name = 'ant'
|
|
2
2
|
|
|
3
|
+
import { installSkill, listSkills, uninstallSkill } from './skill-install.js'
|
|
4
|
+
|
|
3
5
|
export const inject = []
|
|
4
6
|
|
|
5
7
|
const MODEL_SYNC_PATH = '/api/ant-teams/models'
|
|
@@ -8,20 +10,14 @@ const BUNDLE_RELEASE_PATH = '/api/ant-teams/bundle-release'
|
|
|
8
10
|
const DISCONNECT_PATH = '/api/ant-teams/disconnect'
|
|
9
11
|
const REQUEST_CODE_PATH = '/api/ant-teams/request-code'
|
|
10
12
|
const CONNECT_PATH = '/api/ant-teams/connect'
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const FEISHU_HEARTBEAT_PATH = '/api/ant-teams/feishu/tasks/heartbeat'
|
|
15
|
-
const FEISHU_COMPLETE_PATH = '/api/ant-teams/feishu/tasks/complete'
|
|
16
|
-
const FEISHU_FINISH_PATH = '/api/ant-teams/feishu/tasks/finish'
|
|
13
|
+
const SKILLS_PATH = '/api/ant-teams/skills'
|
|
14
|
+
const SKILL_INSTALL_PATH = '/api/ant-teams/skills/install'
|
|
15
|
+
const SKILL_UNINSTALL_PATH = '/api/ant-teams/skills/uninstall'
|
|
17
16
|
const ANT_NPM_PACKAGE_NAME = '@ai-wendao/ant'
|
|
18
17
|
const MAX_MODEL_RESPONSE_BYTES = 1024 * 1024
|
|
19
18
|
const MODEL_TOKEN_REF = 'ANT_MODEL_API_KEY'
|
|
20
19
|
const SERVICE_RECORD_KEY = 'ant/team-service'
|
|
21
20
|
const SESSION_HEARTBEAT_INTERVAL_MS = 30_000
|
|
22
|
-
const FEISHU_POLL_INTERVAL_MS = 5_000
|
|
23
|
-
const FEISHU_TASK_HEARTBEAT_INTERVAL_MS = 30_000
|
|
24
|
-
const FEISHU_TASK_MAX_RUNTIME_MS = 30 * 60_000
|
|
25
21
|
|
|
26
22
|
function jsonResponse(status, value) {
|
|
27
23
|
return new Response(JSON.stringify(value), {
|
|
@@ -309,231 +305,53 @@ async function disconnectMember(request, credentials, settings) {
|
|
|
309
305
|
}
|
|
310
306
|
}
|
|
311
307
|
|
|
312
|
-
async function
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (!connection) return jsonResponse(401, { message: '请先在 ANT 插件中连接团队。' })
|
|
319
|
-
try {
|
|
320
|
-
const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
|
|
321
|
-
if (session.status === 401 || session.status === 403) return jsonResponse(401, { message: 'ANT 成员凭证已失效。' })
|
|
322
|
-
if (!session.ok) return jsonResponse(502, { message: '无法激活 ANT 模型会话。' })
|
|
323
|
-
const body = await request.text()
|
|
324
|
-
if (body.length > 64 * 1024) return jsonResponse(413, { message: '飞书任务请求过大。' })
|
|
325
|
-
const upstream = await fetch(new URL(path, connection.serviceUrl), {
|
|
326
|
-
method: 'POST',
|
|
327
|
-
headers: {
|
|
328
|
-
authorization: `Bearer ${connection.token}`,
|
|
329
|
-
...(body ? { 'content-type': 'application/json' } : {}),
|
|
330
|
-
accept: 'application/json',
|
|
331
|
-
},
|
|
332
|
-
...(body ? { body } : {}),
|
|
333
|
-
redirect: 'manual',
|
|
334
|
-
signal: AbortSignal.timeout(15_000),
|
|
335
|
-
})
|
|
336
|
-
const text = await readLimitedBody(upstream)
|
|
337
|
-
if (text === null) return jsonResponse(502, { message: 'ANT 飞书任务响应过大。' })
|
|
338
|
-
return new Response(text, {
|
|
339
|
-
status: upstream.status,
|
|
340
|
-
headers: { 'content-type': upstream.headers.get('content-type') || 'application/json', 'cache-control': 'no-store' },
|
|
341
|
-
})
|
|
342
|
-
} catch {
|
|
343
|
-
return jsonResponse(502, { message: '无法连接 ANT 飞书任务服务。' })
|
|
344
|
-
}
|
|
308
|
+
async function memberSkillConnection(credentials, settings) {
|
|
309
|
+
const connection = await readMemberConnection(credentials, settings)
|
|
310
|
+
if (!connection) return null
|
|
311
|
+
const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
|
|
312
|
+
if (!session.ok) return null
|
|
313
|
+
return connection
|
|
345
314
|
}
|
|
346
315
|
|
|
347
|
-
async function
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
356
|
-
redirect: 'manual',
|
|
357
|
-
signal: AbortSignal.timeout(timeout),
|
|
358
|
-
})
|
|
359
|
-
const text = await readLimitedBody(response)
|
|
360
|
-
if (text === null) throw new Error('ANT 飞书任务响应过大')
|
|
361
|
-
if (!response.ok) throw new Error(`ANT 飞书任务请求失败(${response.status})`)
|
|
362
|
-
if (response.status === 204 || text === '') return null
|
|
363
|
-
try { return JSON.parse(text) } catch { throw new Error('ANT 飞书任务响应格式无效') }
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
async function syncModelsToHost(settings, serviceUrl, models) {
|
|
367
|
-
if (typeof settings?.mutate !== 'function') throw new Error('当前 DSH 版本未提供模型设置接口。')
|
|
368
|
-
const grouped = {
|
|
369
|
-
openai: models.filter((model) => model.protocol === 'openai'),
|
|
370
|
-
anthropic: models.filter((model) => model.protocol === 'anthropic'),
|
|
371
|
-
}
|
|
372
|
-
const ops = []
|
|
373
|
-
for (const protocol of ['openai', 'anthropic']) {
|
|
374
|
-
const route = protocol === 'openai' ? 'ant-team-openai' : 'ant-team-anthropic'
|
|
375
|
-
const protocolModels = grouped[protocol]
|
|
376
|
-
if (protocolModels.length === 0) {
|
|
377
|
-
ops.push({ op: 'unset', path: ['providers', route] })
|
|
378
|
-
continue
|
|
379
|
-
}
|
|
380
|
-
ops.push({ op: 'set', path: ['providers', route], value: {
|
|
381
|
-
displayName: protocol === 'openai' ? 'Lakala (OpenAI)' : 'Lakala (Anthropic)',
|
|
382
|
-
apiKeyEnv: 'ANT_MODEL_API_KEY',
|
|
383
|
-
api: protocol === 'openai' ? 'openai-completions' : 'anthropic-messages',
|
|
384
|
-
baseURL: protocol === 'openai'
|
|
385
|
-
? new URL('/model-gateway/openai/v1', serviceUrl).toString()
|
|
386
|
-
: new URL('/model-gateway/anthropic', serviceUrl).toString(),
|
|
387
|
-
models: protocolModels.map((model) => ({ id: model.model_id, name: model.display_name })),
|
|
388
|
-
} })
|
|
316
|
+
async function fetchMemberSkills(request, credentials, settings, registry) {
|
|
317
|
+
if (request.method !== 'GET') return jsonResponse(405, { message: '只支持 GET 请求' })
|
|
318
|
+
try {
|
|
319
|
+
const connection = await memberSkillConnection(credentials, settings)
|
|
320
|
+
if (!connection) return jsonResponse(401, { message: '请先连接 ANT 团队,或重新验证工作邮箱。' })
|
|
321
|
+
return jsonResponse(200, { skills: await listSkills(connection, registry) })
|
|
322
|
+
} catch (error) {
|
|
323
|
+
return jsonResponse(502, { message: error instanceof Error ? error.message : '无法读取企业精选技能。' })
|
|
389
324
|
}
|
|
390
|
-
const result = await settings.mutate('llm-pi-ai', ops, undefined)
|
|
391
|
-
if (!result.ok) throw new Error('DSH 无法更新 ANT 获准模型。')
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function assistantMessageText(event) {
|
|
395
|
-
const content = event?.data?.message?.content
|
|
396
|
-
if (!Array.isArray(content)) return ''
|
|
397
|
-
return content.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
398
|
-
.map((part) => part.text).join('')
|
|
399
325
|
}
|
|
400
326
|
|
|
401
|
-
async function
|
|
402
|
-
|
|
403
|
-
let
|
|
404
|
-
let followAbort
|
|
405
|
-
let taskHeartbeat
|
|
406
|
-
let runtimeTimeout
|
|
407
|
-
let timedOut = false
|
|
408
|
-
let stopSignalListener
|
|
327
|
+
async function installMemberSkill(request, credentials, settings, registry) {
|
|
328
|
+
if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
|
|
329
|
+
let body
|
|
409
330
|
try {
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
if (!
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
await syncModelsToHost(settings, connection.serviceUrl, catalog.models)
|
|
422
|
-
const model = task.models?.[0]
|
|
423
|
-
if (!model || !catalog.models.some((item) => item.protocol === model.protocol && item.model_id === model.model_id)) {
|
|
424
|
-
throw new Error('任务模型已不在成员授权目录中。')
|
|
425
|
-
}
|
|
426
|
-
if (typeof sessionController?.create !== 'function' || typeof sessionController?.follow !== 'function'
|
|
427
|
-
|| typeof sessionController?.selectModel !== 'function' || typeof sessionController?.prompt !== 'function') {
|
|
428
|
-
throw new Error('当前 DSH 版本未提供 Session Controller 接口。')
|
|
429
|
-
}
|
|
430
|
-
sessionId = await sessionController.create(task.session_id ? { sessionId: task.session_id } : {})
|
|
431
|
-
const provider = model.protocol === 'openai' ? 'ant-team-openai' : 'ant-team-anthropic'
|
|
432
|
-
await sessionController.selectModel({ sessionId, provider, model: model.model_id })
|
|
433
|
-
followAbort = new AbortController()
|
|
434
|
-
stopSignalListener = () => followAbort.abort()
|
|
435
|
-
stoppedSignal.addEventListener('abort', stopSignalListener, { once: true })
|
|
436
|
-
const follow = sessionController.follow({ address: { kind: 'session', sessionId } }, followAbort.signal)
|
|
437
|
-
const iterator = follow[Symbol.asyncIterator]()
|
|
438
|
-
const opening = await iterator.next()
|
|
439
|
-
if (opening.done || opening.value?.type !== 'snapshot') throw new Error('DSH 会话无法开始跟随。')
|
|
440
|
-
if (stoppedSignal.aborted) throw new Error('DSH 客户端正在关闭。')
|
|
441
|
-
|
|
442
|
-
await sessionController.prompt({
|
|
443
|
-
requestId: globalThis.crypto.randomUUID(),
|
|
444
|
-
sessionId,
|
|
445
|
-
mode: 'queue',
|
|
446
|
-
content: [{ type: 'text', text: task.prompt }],
|
|
447
|
-
}, followAbort.signal)
|
|
448
|
-
promptAccepted = true
|
|
449
|
-
taskHeartbeat = setInterval(() => {
|
|
450
|
-
void updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
|
|
451
|
-
.then((response) => {
|
|
452
|
-
if (!response.ok) throw new Error('ANT model session expired')
|
|
453
|
-
return requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/heartbeat', { task_id: task.id })
|
|
454
|
-
})
|
|
455
|
-
.catch(() => followAbort.abort())
|
|
456
|
-
}, FEISHU_TASK_HEARTBEAT_INTERVAL_MS)
|
|
457
|
-
taskHeartbeat.unref?.()
|
|
458
|
-
runtimeTimeout = setTimeout(() => { timedOut = true; followAbort.abort() }, FEISHU_TASK_MAX_RUNTIME_MS)
|
|
459
|
-
runtimeTimeout.unref?.()
|
|
460
|
-
|
|
461
|
-
let result = ''
|
|
462
|
-
while (!stoppedSignal.aborted) {
|
|
463
|
-
const next = await iterator.next()
|
|
464
|
-
if (next.done) throw new Error('DSH 会话结果流意外结束。')
|
|
465
|
-
const frame = next.value
|
|
466
|
-
if (frame?.type !== 'event') continue
|
|
467
|
-
const event = frame.event
|
|
468
|
-
if (event?.type === 'assistant/message') {
|
|
469
|
-
const message = assistantMessageText(event)
|
|
470
|
-
if (message) result = message
|
|
471
|
-
}
|
|
472
|
-
if (event?.type === 'turn/end') {
|
|
473
|
-
if (event.data?.reason?.kind !== 'completed' || !result.trim()) throw new Error('DSH 任务未能正常完成。')
|
|
474
|
-
await requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/complete', {
|
|
475
|
-
task_id: task.id,
|
|
476
|
-
session_id: sessionId,
|
|
477
|
-
result,
|
|
478
|
-
})
|
|
479
|
-
return
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (stoppedSignal.aborted) throw new Error('DSH 客户端正在关闭。')
|
|
483
|
-
} catch {
|
|
484
|
-
const status = promptAccepted ? 'unknown' : 'failed'
|
|
485
|
-
try {
|
|
486
|
-
await requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/finish', {
|
|
487
|
-
task_id: task.id,
|
|
488
|
-
status: timedOut && promptAccepted ? 'unknown' : status,
|
|
489
|
-
})
|
|
490
|
-
} catch {}
|
|
491
|
-
} finally {
|
|
492
|
-
clearInterval(taskHeartbeat)
|
|
493
|
-
clearTimeout(runtimeTimeout)
|
|
494
|
-
if (stopSignalListener) stoppedSignal.removeEventListener('abort', stopSignalListener)
|
|
495
|
-
followAbort?.abort()
|
|
331
|
+
const text = await request.text()
|
|
332
|
+
if (text.length > 4096) throw new Error()
|
|
333
|
+
body = JSON.parse(text)
|
|
334
|
+
} catch { return jsonResponse(400, { message: '技能安装请求无效。' }) }
|
|
335
|
+
try {
|
|
336
|
+
const connection = await memberSkillConnection(credentials, settings)
|
|
337
|
+
if (!connection) return jsonResponse(401, { message: '请先连接 ANT 团队,或重新验证工作邮箱。' })
|
|
338
|
+
return jsonResponse(200, await installSkill(connection, body?.skill_id, registry))
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return jsonResponse(409, { message: error instanceof Error ? error.message : '技能安装失败。' })
|
|
496
341
|
}
|
|
497
342
|
}
|
|
498
343
|
|
|
499
|
-
function
|
|
500
|
-
|
|
501
|
-
let
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (!connection) return
|
|
511
|
-
const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
|
|
512
|
-
if (!session.ok || stopped) return
|
|
513
|
-
const claim = await requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/claim')
|
|
514
|
-
if (!claim?.task || stopped) return
|
|
515
|
-
taskAbort = new AbortController()
|
|
516
|
-
work = executeFeishuTask(claim.task, connection, settings, sessionController, taskAbort.signal)
|
|
517
|
-
await work
|
|
518
|
-
} catch {
|
|
519
|
-
// A transient polling error is retried on the next interval.
|
|
520
|
-
} finally {
|
|
521
|
-
busy = false
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
const schedulePoll = () => {
|
|
525
|
-
if (stopped || busy) return
|
|
526
|
-
pollWork = poll()
|
|
527
|
-
}
|
|
528
|
-
const timer = setInterval(schedulePoll, FEISHU_POLL_INTERVAL_MS)
|
|
529
|
-
timer.unref?.()
|
|
530
|
-
schedulePoll()
|
|
531
|
-
return async () => {
|
|
532
|
-
stopped = true
|
|
533
|
-
clearInterval(timer)
|
|
534
|
-
taskAbort?.abort()
|
|
535
|
-
await pollWork
|
|
536
|
-
await work
|
|
344
|
+
async function uninstallMemberSkill(request) {
|
|
345
|
+
if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
|
|
346
|
+
let body
|
|
347
|
+
try {
|
|
348
|
+
const text = await request.text()
|
|
349
|
+
if (text.length > 4096) throw new Error()
|
|
350
|
+
body = JSON.parse(text)
|
|
351
|
+
} catch { return jsonResponse(400, { message: '技能卸载请求无效。' }) }
|
|
352
|
+
try { return jsonResponse(200, await uninstallSkill(body?.dsh_name)) }
|
|
353
|
+
catch (error) {
|
|
354
|
+
return jsonResponse(409, { message: error instanceof Error ? error.message : '技能卸载失败。' })
|
|
537
355
|
}
|
|
538
356
|
}
|
|
539
357
|
|
|
@@ -593,7 +411,9 @@ async function fetchAuthRequest(request, endpoint, credentials) {
|
|
|
593
411
|
}
|
|
594
412
|
|
|
595
413
|
export function apply(ctx) {
|
|
596
|
-
|
|
414
|
+
let skillRegistry
|
|
415
|
+
ctx.inject(['skills'], (skillCtx) => { skillRegistry = skillCtx.skills })
|
|
416
|
+
ctx.inject(['connection', 'credentials', 'settings'], (connectionCtx) => {
|
|
597
417
|
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
598
418
|
path: SERVICE_INFO_PATH,
|
|
599
419
|
methods: ['POST'],
|
|
@@ -609,8 +429,21 @@ export function apply(ctx) {
|
|
|
609
429
|
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
610
430
|
path: BUNDLE_RELEASE_PATH,
|
|
611
431
|
methods: ['GET'],
|
|
432
|
+
requestBody: 'buffered',
|
|
612
433
|
fetch: (request) => fetchBundleRelease(request, connectionCtx.credentials, connectionCtx.settings),
|
|
613
434
|
}), 'ant-teams: authenticated Bundle release relay')
|
|
435
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
436
|
+
path: SKILLS_PATH, methods: ['GET'], requestBody: 'buffered',
|
|
437
|
+
fetch: (request) => fetchMemberSkills(request, connectionCtx.credentials, connectionCtx.settings, skillRegistry),
|
|
438
|
+
}), 'ant-teams: enterprise skill catalog')
|
|
439
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
440
|
+
path: SKILL_INSTALL_PATH, methods: ['POST'], requestBody: 'buffered',
|
|
441
|
+
fetch: (request) => installMemberSkill(request, connectionCtx.credentials, connectionCtx.settings, skillRegistry),
|
|
442
|
+
}), 'ant-teams: install approved skill into DSH')
|
|
443
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
444
|
+
path: SKILL_UNINSTALL_PATH, methods: ['POST'], requestBody: 'buffered',
|
|
445
|
+
fetch: (request) => uninstallMemberSkill(request),
|
|
446
|
+
}), 'ant-teams: remove managed DSH skill')
|
|
614
447
|
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
615
448
|
path: DISCONNECT_PATH,
|
|
616
449
|
methods: ['POST'],
|
|
@@ -628,21 +461,6 @@ export function apply(ctx) {
|
|
|
628
461
|
requestBody: 'buffered',
|
|
629
462
|
fetch: (request) => fetchAuthRequest(request, 'dsh-connect', connectionCtx.credentials),
|
|
630
463
|
}), 'ant-teams: relay DSH account connection')
|
|
631
|
-
for (const [path, endpoint] of [
|
|
632
|
-
[FEISHU_MEMBER_APPS_PATH, '/api/feishu/member-apps'],
|
|
633
|
-
[FEISHU_LINK_CODE_PATH, '/api/feishu/link-code'],
|
|
634
|
-
[FEISHU_CLAIM_PATH, '/api/feishu/tasks/claim'],
|
|
635
|
-
[FEISHU_HEARTBEAT_PATH, '/api/feishu/tasks/heartbeat'],
|
|
636
|
-
[FEISHU_COMPLETE_PATH, '/api/feishu/tasks/complete'],
|
|
637
|
-
[FEISHU_FINISH_PATH, '/api/feishu/tasks/finish'],
|
|
638
|
-
]) {
|
|
639
|
-
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
640
|
-
path,
|
|
641
|
-
methods: ['POST'],
|
|
642
|
-
requestBody: 'buffered',
|
|
643
|
-
fetch: (request) => relayFeishuRequest(request, endpoint, connectionCtx.credentials, connectionCtx.settings),
|
|
644
|
-
}), `ant-teams: relay Feishu ${endpoint.split('/').at(-1)}`)
|
|
645
|
-
}
|
|
646
464
|
connectionCtx.effect(() => {
|
|
647
465
|
let stopped = false
|
|
648
466
|
let inFlight = Promise.resolve()
|
|
@@ -670,9 +488,5 @@ export function apply(ctx) {
|
|
|
670
488
|
} catch {}
|
|
671
489
|
}
|
|
672
490
|
}, 'ant-teams: ANT model activity session')
|
|
673
|
-
connectionCtx.effect(
|
|
674
|
-
() => installFeishuTaskWorker(connectionCtx.credentials, connectionCtx.settings, connectionCtx.sessionController),
|
|
675
|
-
'ant-teams: DSH-owned Feishu task worker',
|
|
676
|
-
)
|
|
677
491
|
})
|
|
678
492
|
}
|
package/locale/en.json
CHANGED
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-wendao/ant",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "AI-native
|
|
3
|
+
"version": "0.3.11",
|
|
4
|
+
"description": "ANT (AI-native Teams) connects a DeepSeek Harness Desktop profile to team identity and approved models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node --test client.test.js index.test.js tools/migrate-legacy.test.mjs"
|
|
7
|
+
"test": "node --test client.test.js index.test.js skill-install.test.js tools/migrate-legacy.test.mjs"
|
|
8
8
|
},
|
|
9
9
|
"bin": {
|
|
10
10
|
"ant-migrate": "tools/migrate-legacy.mjs"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"./client": "./client.js",
|
|
17
17
|
"./locale/*.json": "./locale/*.json"
|
|
18
18
|
},
|
|
19
|
-
"files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json", "tools/migrate-legacy.mjs", "MIGRATION.md", "README.md"],
|
|
19
|
+
"files": ["index.js", "client.js", "skill-install.js", "cordis.patch.yml", "locale/*.json", "tools/migrate-legacy.mjs", "MIGRATION.md", "README.md"],
|
|
20
20
|
"dsh": {
|
|
21
21
|
"bundle": {
|
|
22
22
|
"patch": "./cordis.patch.yml"
|
package/skill-install.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { gunzipSync } from 'node:zlib'
|
|
6
|
+
|
|
7
|
+
const MARKER = '.ant-managed.json'
|
|
8
|
+
const MAX_FILES = 20_000
|
|
9
|
+
const MAX_FILE_BYTES = 8 * 1024 * 1024
|
|
10
|
+
const MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
|
11
|
+
const MAX_PACKAGE_BYTES = 80 * 1024 * 1024
|
|
12
|
+
const MAX_JSON_BYTES = 4 * 1024 * 1024
|
|
13
|
+
const activeSkills = new Set()
|
|
14
|
+
|
|
15
|
+
async function assertDiscovered(registry, root, name) {
|
|
16
|
+
if (typeof registry?.list !== 'function') throw new Error('DSH 技能服务不可用,无法确认安装结果。')
|
|
17
|
+
const expected = resolve(root, name, 'SKILL.md')
|
|
18
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
19
|
+
const entries = await registry.list()
|
|
20
|
+
if (Array.isArray(entries) && entries.some((entry) => entry.name === name
|
|
21
|
+
&& typeof entry.path === 'string'
|
|
22
|
+
&& (process.platform === 'win32' ? resolve(entry.path).toLowerCase() === expected.toLowerCase() : resolve(entry.path) === expected))) return
|
|
23
|
+
if (attempt < 5) await new Promise((done) => setTimeout(done, 300))
|
|
24
|
+
}
|
|
25
|
+
throw new Error('DSH 没有发现此技能;请检查 DSH 技能目录配置。')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
|
29
|
+
const safeName = (name) => typeof name === 'string' && /^ant-[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name) && name.length <= 64
|
|
30
|
+
const safePath = (name) => typeof name === 'string' && name.length > 0 && name.length <= 1024
|
|
31
|
+
&& !name.includes('\\') && !name.includes('\0') && name !== MARKER
|
|
32
|
+
&& name.split('/').every((part) => part && part !== '.' && part !== '..' && part.length <= 255
|
|
33
|
+
&& !/[<>:"|?*]/u.test(part) && !/[. ]$/u.test(part)
|
|
34
|
+
&& !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part))
|
|
35
|
+
const validHash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value)
|
|
36
|
+
const skillRoot = () => {
|
|
37
|
+
const configured = process.env.DSH_HOME?.trim()
|
|
38
|
+
const home = !configured ? join(homedir(), '.dsh')
|
|
39
|
+
: configured === '~' ? homedir()
|
|
40
|
+
: configured.startsWith('~/') || configured.startsWith('~\\')
|
|
41
|
+
? join(homedir(), configured.slice(2)) : configured
|
|
42
|
+
return join(resolve(home), 'skills')
|
|
43
|
+
}
|
|
44
|
+
const validManagedMarker = (marker, name) => marker?.dsh_name === name && validHash(marker.content_sha256)
|
|
45
|
+
&& Array.isArray(marker.files) && marker.files.length > 0 && marker.files.length <= MAX_FILES
|
|
46
|
+
&& marker.files.every((file) => safePath(file.path) && validHash(file.sha256) && typeof file.executable === 'boolean')
|
|
47
|
+
&& new Set(marker.files.map((file) => file.path)).size === marker.files.length
|
|
48
|
+
|
|
49
|
+
async function isPresent(path) {
|
|
50
|
+
try { return await lstat(path) } catch (error) {
|
|
51
|
+
if (error?.code === 'ENOENT') return null
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function safeRoot() {
|
|
57
|
+
const root = skillRoot()
|
|
58
|
+
const existing = await isPresent(root)
|
|
59
|
+
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) throw new Error('DSH 技能目录不是普通目录,无法安全安装。')
|
|
60
|
+
if (!existing) await mkdir(root, { recursive: true, mode: 0o700 })
|
|
61
|
+
const conflicts = await recoverInterruptedUpdates(root)
|
|
62
|
+
return { root, conflicts }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function recoverInterruptedUpdates(root) {
|
|
66
|
+
const conflicts = new Set()
|
|
67
|
+
const stagingRoot = join(dirname(root), '.ant-skill-staging')
|
|
68
|
+
const stat = await isPresent(stagingRoot)
|
|
69
|
+
if (!stat) return conflicts
|
|
70
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('ANT 暂存目录不是普通目录。')
|
|
71
|
+
for (const entry of await readdir(stagingRoot, { withFileTypes: true })) {
|
|
72
|
+
const removedName = entry.name.match(/^removed-(ant-[a-z0-9-]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u)?.[1]
|
|
73
|
+
if (entry.isDirectory() && !entry.isSymbolicLink() && safeName(removedName)
|
|
74
|
+
&& !activeSkills.has(removedName)) {
|
|
75
|
+
await rm(join(stagingRoot, entry.name), { recursive: true }).catch(() => {})
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !entry.name.startsWith('old-ant-')) continue
|
|
79
|
+
const backup = join(stagingRoot, entry.name)
|
|
80
|
+
const recoveredName = entry.name.match(/^old-(ant-[a-z0-9-]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u)?.[1]
|
|
81
|
+
if (!safeName(recoveredName)) continue
|
|
82
|
+
const target = join(root, recoveredName)
|
|
83
|
+
if (await isPresent(target)) continue
|
|
84
|
+
const markerStat = await isPresent(join(backup, MARKER))
|
|
85
|
+
if (!markerStat?.isFile() || markerStat.isSymbolicLink() || markerStat.size > MAX_JSON_BYTES) {
|
|
86
|
+
conflicts.add(recoveredName); continue
|
|
87
|
+
}
|
|
88
|
+
let marker
|
|
89
|
+
try { marker = JSON.parse(await readFile(join(backup, MARKER), 'utf8')) }
|
|
90
|
+
catch { conflicts.add(recoveredName); continue }
|
|
91
|
+
if (marker?.dsh_name !== recoveredName || !validManagedMarker(marker, recoveredName)) {
|
|
92
|
+
conflicts.add(recoveredName); continue
|
|
93
|
+
}
|
|
94
|
+
if (activeSkills.has(recoveredName)) continue
|
|
95
|
+
try {
|
|
96
|
+
await verifyManagedTree(stagingRoot, entry.name, marker)
|
|
97
|
+
await rename(backup, target)
|
|
98
|
+
} catch { conflicts.add(marker.dsh_name) }
|
|
99
|
+
}
|
|
100
|
+
return conflicts
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function readManaged(root, name) {
|
|
104
|
+
if (!safeName(name)) throw new Error('技能名称无效。')
|
|
105
|
+
const target = join(root, name)
|
|
106
|
+
const stat = await isPresent(target)
|
|
107
|
+
if (!stat) return null
|
|
108
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('同名目录不是 ANT 管理的普通技能目录。')
|
|
109
|
+
const markerStat = await isPresent(join(target, MARKER))
|
|
110
|
+
if (!markerStat || !markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.size > MAX_JSON_BYTES) {
|
|
111
|
+
throw new Error('同名技能已有本地内容;ANT 不会覆盖。')
|
|
112
|
+
}
|
|
113
|
+
let marker
|
|
114
|
+
try { marker = JSON.parse(await readFile(join(target, MARKER), 'utf8')) } catch {
|
|
115
|
+
throw new Error('同名技能的管理记录无效;ANT 不会覆盖。')
|
|
116
|
+
}
|
|
117
|
+
if (!validManagedMarker(marker, name)) {
|
|
118
|
+
throw new Error('同名技能的管理记录无效;ANT 不会覆盖。')
|
|
119
|
+
}
|
|
120
|
+
return marker
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function verifyManagedTree(root, name, marker) {
|
|
124
|
+
const target = join(root, name)
|
|
125
|
+
const expected = new Set([MARKER])
|
|
126
|
+
for (const file of marker.files) {
|
|
127
|
+
const parts = file.path.split('/')
|
|
128
|
+
for (let index = 1; index < parts.length; index++) expected.add(parts.slice(0, index).join('/'))
|
|
129
|
+
expected.add(file.path)
|
|
130
|
+
const fullPath = join(target, ...file.path.split('/'))
|
|
131
|
+
const stat = await isPresent(fullPath)
|
|
132
|
+
if (!stat?.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE_BYTES
|
|
133
|
+
|| (process.platform !== 'win32' && Boolean(stat.mode & 0o111) !== file.executable)
|
|
134
|
+
|| hash(await readFile(fullPath)) !== file.sha256) {
|
|
135
|
+
throw new Error('技能文件已被本机修改;ANT 不会覆盖或删除。')
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const walk = async (directory, prefix = '') => {
|
|
139
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
140
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
141
|
+
if (!expected.has(relative) || entry.isSymbolicLink()) throw new Error('技能目录有额外文件或链接;ANT 不会覆盖或删除。')
|
|
142
|
+
if (entry.isDirectory()) await walk(join(directory, entry.name), relative)
|
|
143
|
+
else if (!entry.isFile()) throw new Error('技能目录有无法识别的内容;ANT 不会覆盖或删除。')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
await walk(target)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function boundedBytes(response, maxBytes) {
|
|
150
|
+
if (!response.ok || !response.body) throw new Error(`团队服务无法提供技能文件(HTTP ${response.status})。`)
|
|
151
|
+
const chunks = []
|
|
152
|
+
let total = 0
|
|
153
|
+
for await (const chunk of response.body) {
|
|
154
|
+
total += chunk.length
|
|
155
|
+
if (total > maxBytes) throw new Error('技能文件超过大小上限。')
|
|
156
|
+
chunks.push(chunk)
|
|
157
|
+
}
|
|
158
|
+
return Buffer.concat(chunks, total)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function fetchJson(url, token) {
|
|
162
|
+
const response = await fetch(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, redirect: 'manual', signal: AbortSignal.timeout(20_000) })
|
|
163
|
+
const bytes = await boundedBytes(response, MAX_JSON_BYTES)
|
|
164
|
+
try { return JSON.parse(bytes.toString('utf8')) } catch { throw new Error('团队技能目录格式无效。') }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function listSkills(connection, registry) {
|
|
168
|
+
const skills = await fetchJson(new URL('/api/me/skills', connection.serviceUrl), connection.token)
|
|
169
|
+
if (!Array.isArray(skills)) throw new Error('团队技能目录格式无效。')
|
|
170
|
+
const { root, conflicts } = await safeRoot()
|
|
171
|
+
return Promise.all(skills.map(async (skill) => {
|
|
172
|
+
if (!safeName(skill.dsh_name) || !validHash(skill.content_sha256)) throw new Error('团队技能目录含无效条目。')
|
|
173
|
+
let local_status = conflicts.has(skill.dsh_name) ? 'conflict' : 'not-installed'
|
|
174
|
+
if (local_status === 'conflict') return { ...skill, local_status }
|
|
175
|
+
try {
|
|
176
|
+
const marker = await readManaged(root, skill.dsh_name)
|
|
177
|
+
if (marker) {
|
|
178
|
+
await verifyManagedTree(root, skill.dsh_name, marker)
|
|
179
|
+
if (marker.service_url !== connection.serviceUrl) throw new Error('该目录属于另一个 ANT 服务。')
|
|
180
|
+
local_status = marker.content_sha256 === skill.content_sha256 ? 'installed' : 'update-available'
|
|
181
|
+
}
|
|
182
|
+
} catch { local_status = 'conflict' }
|
|
183
|
+
if (local_status === 'installed' || local_status === 'update-available') {
|
|
184
|
+
try { await assertDiscovered(registry, root, skill.dsh_name) }
|
|
185
|
+
catch { local_status = 'not-discovered' }
|
|
186
|
+
}
|
|
187
|
+
return { ...skill, local_status }
|
|
188
|
+
}))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function validateManifest(manifest, skillId) {
|
|
192
|
+
if (manifest?.skill_id !== skillId || !safeName(manifest.dsh_name) || !validHash(manifest.content_sha256)
|
|
193
|
+
|| !validHash(manifest.package_sha256) || !Number.isInteger(manifest.package_size)
|
|
194
|
+
|| manifest.package_size < 1 || manifest.package_size > MAX_PACKAGE_BYTES
|
|
195
|
+
|| !Array.isArray(manifest.files) || manifest.files.length < 1 || manifest.files.length > MAX_FILES) {
|
|
196
|
+
throw new Error('团队技能文件清单无效。')
|
|
197
|
+
}
|
|
198
|
+
const seen = new Set()
|
|
199
|
+
let total = 0
|
|
200
|
+
for (const file of manifest.files) {
|
|
201
|
+
if (!safePath(file.path) || !validHash(file.sha256) || !Number.isInteger(file.file_size)
|
|
202
|
+
|| file.file_size < 0 || file.file_size > MAX_FILE_BYTES || typeof file.executable !== 'boolean'
|
|
203
|
+
|| seen.has(file.path)) throw new Error('团队技能文件清单无效。')
|
|
204
|
+
seen.add(file.path)
|
|
205
|
+
total += file.file_size
|
|
206
|
+
if (total > MAX_TOTAL_BYTES) throw new Error('技能总大小超过上限。')
|
|
207
|
+
}
|
|
208
|
+
if (!seen.has('SKILL.md')) throw new Error('技能目录缺少 SKILL.md。')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function contentDigest(files) {
|
|
212
|
+
const digest = createHash('sha256')
|
|
213
|
+
for (const file of [...files].sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)))) {
|
|
214
|
+
const pathBytes = Buffer.from(file.path)
|
|
215
|
+
const length = Buffer.alloc(8)
|
|
216
|
+
length.writeBigUInt64BE(BigInt(pathBytes.length)); digest.update(length); digest.update(pathBytes)
|
|
217
|
+
length.writeBigUInt64BE(BigInt(file.content.length)); digest.update(length)
|
|
218
|
+
digest.update(Buffer.from([Number(file.executable)])); digest.update(file.content)
|
|
219
|
+
}
|
|
220
|
+
return digest.digest('hex')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function installSkill(connection, skillId, registry) {
|
|
224
|
+
if (typeof skillId !== 'string' || !/^[0-9a-f-]{36}$/iu.test(skillId)) throw new Error('技能编号无效。')
|
|
225
|
+
const manifest = await fetchJson(new URL(`/api/me/skills/${skillId}/files`, connection.serviceUrl), connection.token)
|
|
226
|
+
validateManifest(manifest, skillId)
|
|
227
|
+
const { root, conflicts } = await safeRoot()
|
|
228
|
+
if (conflicts.has(manifest.dsh_name)) throw new Error('本机技能备份已损坏;请联系管理员处理,ANT 不会覆盖。')
|
|
229
|
+
if (activeSkills.has(manifest.dsh_name)) throw new Error('这个技能正在安装或卸载,请稍后重试。')
|
|
230
|
+
activeSkills.add(manifest.dsh_name)
|
|
231
|
+
try { return await installValidatedSkill(connection, skillId, manifest, root, registry) }
|
|
232
|
+
finally { activeSkills.delete(manifest.dsh_name) }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function installValidatedSkill(connection, skillId, manifest, root, registry) {
|
|
236
|
+
const target = join(root, manifest.dsh_name)
|
|
237
|
+
const oldMarker = await readManaged(root, manifest.dsh_name)
|
|
238
|
+
if (oldMarker) {
|
|
239
|
+
if (oldMarker.service_url !== connection.serviceUrl) throw new Error('同名技能属于另一个 ANT 服务,无法覆盖。')
|
|
240
|
+
await verifyManagedTree(root, manifest.dsh_name, oldMarker)
|
|
241
|
+
if (oldMarker.content_sha256 === manifest.content_sha256) {
|
|
242
|
+
await assertDiscovered(registry, root, manifest.dsh_name)
|
|
243
|
+
return { status: 'installed', dsh_name: manifest.dsh_name }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const packageUrl = new URL(`/api/me/skills/${skillId}/package`, connection.serviceUrl)
|
|
248
|
+
const packageResponse = await fetch(packageUrl, { headers: { authorization: `Bearer ${connection.token}` }, redirect: 'manual', signal: AbortSignal.timeout(120_000) })
|
|
249
|
+
const packageBytes = await boundedBytes(packageResponse, manifest.package_size)
|
|
250
|
+
if (packageBytes.length !== manifest.package_size || hash(packageBytes) !== manifest.package_sha256) {
|
|
251
|
+
throw new Error('技能包校验失败,未安装。')
|
|
252
|
+
}
|
|
253
|
+
let unpacked
|
|
254
|
+
try { unpacked = gunzipSync(packageBytes, { maxOutputLength: MAX_TOTAL_BYTES }) }
|
|
255
|
+
catch { throw new Error('技能包解压失败或超过大小上限,未安装。') }
|
|
256
|
+
const files = []
|
|
257
|
+
let offset = 0
|
|
258
|
+
for (const file of manifest.files) {
|
|
259
|
+
const content = unpacked.subarray(offset, offset + file.file_size)
|
|
260
|
+
if (content.length !== file.file_size || hash(content) !== file.sha256) throw new Error('技能文件校验失败,未安装。')
|
|
261
|
+
files.push({ ...file, content })
|
|
262
|
+
offset += file.file_size
|
|
263
|
+
}
|
|
264
|
+
if (offset !== unpacked.length) throw new Error('技能包文件数量与清单不符,未安装。')
|
|
265
|
+
if (contentDigest(files) !== manifest.content_sha256) throw new Error('技能整体校验失败,未安装。')
|
|
266
|
+
|
|
267
|
+
const stagingRoot = join(dirname(root), '.ant-skill-staging')
|
|
268
|
+
const stagingStat = await isPresent(stagingRoot)
|
|
269
|
+
if (stagingStat && (!stagingStat.isDirectory() || stagingStat.isSymbolicLink())) throw new Error('ANT 暂存目录不是普通目录。')
|
|
270
|
+
await mkdir(stagingRoot, { recursive: true, mode: 0o700 })
|
|
271
|
+
const staging = join(stagingRoot, `new-${randomUUID()}`)
|
|
272
|
+
const backup = join(stagingRoot, `old-${manifest.dsh_name}-${randomUUID()}`)
|
|
273
|
+
await mkdir(staging, { mode: 0o700 })
|
|
274
|
+
let backedUp = false
|
|
275
|
+
let installedNew = false
|
|
276
|
+
try {
|
|
277
|
+
for (const file of files) {
|
|
278
|
+
const destination = join(staging, ...file.path.split('/'))
|
|
279
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o700 })
|
|
280
|
+
await writeFile(destination, file.content, { flag: 'wx', mode: file.executable ? 0o700 : 0o600 })
|
|
281
|
+
if (file.executable && process.platform !== 'win32') await chmod(destination, 0o700)
|
|
282
|
+
}
|
|
283
|
+
await writeFile(join(staging, MARKER), JSON.stringify({
|
|
284
|
+
dsh_name: manifest.dsh_name, skill_id: skillId, service_url: connection.serviceUrl,
|
|
285
|
+
content_sha256: manifest.content_sha256,
|
|
286
|
+
files: manifest.files.map(({ path, sha256, executable }) => ({ path, sha256, executable })),
|
|
287
|
+
}), { flag: 'wx', mode: 0o600 })
|
|
288
|
+
if (oldMarker) { await verifyManagedTree(root, manifest.dsh_name, oldMarker); await rename(target, backup); backedUp = true }
|
|
289
|
+
else if (await isPresent(target)) throw new Error('同名技能已经存在,ANT 不会覆盖。')
|
|
290
|
+
await rename(staging, target)
|
|
291
|
+
installedNew = true
|
|
292
|
+
await assertDiscovered(registry, root, manifest.dsh_name)
|
|
293
|
+
if (backedUp) await rm(backup, { recursive: true }).catch(() => {})
|
|
294
|
+
return { status: oldMarker ? 'updated' : 'installed', dsh_name: manifest.dsh_name }
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (backedUp && await isPresent(backup)) {
|
|
297
|
+
if (await isPresent(target)) {
|
|
298
|
+
await verifyManagedTree(root, manifest.dsh_name, {
|
|
299
|
+
dsh_name: manifest.dsh_name,
|
|
300
|
+
files: manifest.files.map(({ path, sha256, executable }) => ({ path, sha256, executable })),
|
|
301
|
+
}).then(() => rename(target, staging)).catch(() => {})
|
|
302
|
+
}
|
|
303
|
+
if (!(await isPresent(target))) await rename(backup, target).catch(() => {})
|
|
304
|
+
} else if (installedNew) {
|
|
305
|
+
await verifyManagedTree(root, manifest.dsh_name, {
|
|
306
|
+
dsh_name: manifest.dsh_name,
|
|
307
|
+
files: manifest.files.map(({ path, sha256, executable }) => ({ path, sha256, executable })),
|
|
308
|
+
}).then(() => rm(target, { recursive: true })).catch(() => {})
|
|
309
|
+
}
|
|
310
|
+
throw error
|
|
311
|
+
} finally { await rm(staging, { recursive: true, force: true }).catch(() => {}) }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function uninstallSkill(name) {
|
|
315
|
+
if (!safeName(name)) throw new Error('技能名称无效。')
|
|
316
|
+
const { root, conflicts } = await safeRoot()
|
|
317
|
+
if (conflicts.has(name)) throw new Error('本机技能备份已损坏;请联系管理员处理,ANT 不会删除。')
|
|
318
|
+
if (activeSkills.has(name)) throw new Error('这个技能正在安装或卸载,请稍后重试。')
|
|
319
|
+
activeSkills.add(name)
|
|
320
|
+
try { return await uninstallManagedSkill(root, name) }
|
|
321
|
+
finally { activeSkills.delete(name) }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function uninstallManagedSkill(root, name) {
|
|
325
|
+
const marker = await readManaged(root, name)
|
|
326
|
+
if (!marker) return { status: 'not-installed', dsh_name: name }
|
|
327
|
+
await verifyManagedTree(root, name, marker)
|
|
328
|
+
const stagingRoot = join(dirname(root), '.ant-skill-staging')
|
|
329
|
+
const stagingStat = await isPresent(stagingRoot)
|
|
330
|
+
if (stagingStat && (!stagingStat.isDirectory() || stagingStat.isSymbolicLink())) throw new Error('ANT 暂存目录不是普通目录。')
|
|
331
|
+
await mkdir(stagingRoot, { recursive: true, mode: 0o700 })
|
|
332
|
+
const removed = join(stagingRoot, `removed-${name}-${randomUUID()}`)
|
|
333
|
+
await rename(join(root, name), removed)
|
|
334
|
+
try { await rm(removed, { recursive: true }) }
|
|
335
|
+
catch { throw new Error('技能已从 DSH 目录移出,但暂存文件清理失败;请联系管理员处理。') }
|
|
336
|
+
return { status: 'uninstalled', dsh_name: name }
|
|
337
|
+
}
|