@ai-wendao/ant 0.0.0-stage → 0.3.8
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/client.js +680 -0
- package/cordis.patch.yml +3 -0
- package/index.js +667 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +27 -4
- package/README.md +0 -3
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
export const name = 'ant'
|
|
2
|
+
|
|
3
|
+
export const inject = []
|
|
4
|
+
|
|
5
|
+
const MODEL_SYNC_PATH = '/api/ant-teams/models'
|
|
6
|
+
const SERVICE_INFO_PATH = '/api/ant-teams/service-info'
|
|
7
|
+
const BUNDLE_RELEASE_PATH = '/api/ant-teams/bundle-release'
|
|
8
|
+
const DISCONNECT_PATH = '/api/ant-teams/disconnect'
|
|
9
|
+
const REQUEST_CODE_PATH = '/api/ant-teams/request-code'
|
|
10
|
+
const CONNECT_PATH = '/api/ant-teams/connect'
|
|
11
|
+
const FEISHU_LINK_CODE_PATH = '/api/ant-teams/feishu/link-code'
|
|
12
|
+
const FEISHU_MEMBER_APPS_PATH = '/api/ant-teams/feishu/apps'
|
|
13
|
+
const FEISHU_CLAIM_PATH = '/api/ant-teams/feishu/tasks/claim'
|
|
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'
|
|
17
|
+
const MAX_MODEL_RESPONSE_BYTES = 1024 * 1024
|
|
18
|
+
const MODEL_TOKEN_REF = 'ANT_MODEL_API_KEY'
|
|
19
|
+
const SERVICE_RECORD_KEY = 'ant/team-service'
|
|
20
|
+
const SESSION_HEARTBEAT_INTERVAL_MS = 30_000
|
|
21
|
+
const FEISHU_POLL_INTERVAL_MS = 5_000
|
|
22
|
+
const FEISHU_TASK_HEARTBEAT_INTERVAL_MS = 30_000
|
|
23
|
+
const FEISHU_TASK_MAX_RUNTIME_MS = 30 * 60_000
|
|
24
|
+
|
|
25
|
+
function jsonResponse(status, value) {
|
|
26
|
+
return new Response(JSON.stringify(value), {
|
|
27
|
+
status,
|
|
28
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeServiceUrl(value) {
|
|
33
|
+
let url
|
|
34
|
+
try { url = new URL(value) } catch { return null }
|
|
35
|
+
const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
|
36
|
+
if ((url.protocol !== 'https:' && !localHttp) || url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '')) return null
|
|
37
|
+
return url.origin
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function serviceUrlFromModelSettings(settings) {
|
|
41
|
+
const descriptor = settings.describe().find((entry) => entry.ns === 'llm-pi-ai')
|
|
42
|
+
const providers = descriptor?.value?.providers
|
|
43
|
+
if (!providers || typeof providers !== 'object') return null
|
|
44
|
+
const origins = new Set()
|
|
45
|
+
for (const provider of Object.values(providers)) {
|
|
46
|
+
if (provider?.apiKeyEnv !== MODEL_TOKEN_REF || typeof provider.baseURL !== 'string') continue
|
|
47
|
+
let url
|
|
48
|
+
try { url = new URL(provider.baseURL) } catch { return null }
|
|
49
|
+
const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
|
50
|
+
if ((url.protocol !== 'https:' && !localHttp) || url.username || url.password || url.search || url.hash
|
|
51
|
+
|| !['/model-gateway/openai/v1', '/model-gateway/anthropic'].includes(url.pathname)) return null
|
|
52
|
+
origins.add(url.origin)
|
|
53
|
+
}
|
|
54
|
+
return origins.size === 1 ? [...origins][0] : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function pinServiceUrl(credentials, serviceUrl) {
|
|
58
|
+
await credentials.modifyRecord(SERVICE_RECORD_KEY, async () => ({
|
|
59
|
+
kind: 'grant',
|
|
60
|
+
payload: { service_url: serviceUrl },
|
|
61
|
+
}))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readMemberConnection(credentials, settings) {
|
|
65
|
+
const credential = await credentials.resolve(MODEL_TOKEN_REF)
|
|
66
|
+
const token = typeof credential?.value === 'string' ? credential.value : null
|
|
67
|
+
if (!token) return null
|
|
68
|
+
const record = await credentials.readRecord(SERVICE_RECORD_KEY)
|
|
69
|
+
const pinnedUrl = record?.kind === 'grant' && typeof record.payload?.service_url === 'string'
|
|
70
|
+
? normalizeServiceUrl(record.payload.service_url)
|
|
71
|
+
: null
|
|
72
|
+
const serviceUrl = pinnedUrl || serviceUrlFromModelSettings(settings)
|
|
73
|
+
return serviceUrl ? { serviceUrl, token } : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function updateModelSession(serviceUrl, token, action) {
|
|
77
|
+
return fetch(new URL(`/auth/dsh-session/${action}`, serviceUrl), {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { authorization: `Bearer ${token}`, accept: 'application/json' },
|
|
80
|
+
redirect: 'manual',
|
|
81
|
+
signal: AbortSignal.timeout(10_000),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readLimitedBody(response) {
|
|
86
|
+
if (!response.body) return ''
|
|
87
|
+
const reader = response.body.getReader()
|
|
88
|
+
const chunks = []
|
|
89
|
+
let size = 0
|
|
90
|
+
try {
|
|
91
|
+
while (true) {
|
|
92
|
+
const { done, value } = await reader.read()
|
|
93
|
+
if (done) break
|
|
94
|
+
size += value.byteLength
|
|
95
|
+
if (size > MAX_MODEL_RESPONSE_BYTES) {
|
|
96
|
+
await reader.cancel()
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
chunks.push(value)
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
reader.releaseLock()
|
|
103
|
+
}
|
|
104
|
+
const bytes = new Uint8Array(size)
|
|
105
|
+
let offset = 0
|
|
106
|
+
for (const chunk of chunks) {
|
|
107
|
+
bytes.set(chunk, offset)
|
|
108
|
+
offset += chunk.byteLength
|
|
109
|
+
}
|
|
110
|
+
return new TextDecoder().decode(bytes)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchMemberModels(request, credentials, settings) {
|
|
114
|
+
if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
|
|
115
|
+
let token
|
|
116
|
+
let pinnedUrl
|
|
117
|
+
let serviceUrl
|
|
118
|
+
try {
|
|
119
|
+
token = await credentials.resolve(MODEL_TOKEN_REF)
|
|
120
|
+
const record = await credentials.readRecord(SERVICE_RECORD_KEY)
|
|
121
|
+
pinnedUrl = record?.kind === 'grant' && typeof record.payload?.service_url === 'string'
|
|
122
|
+
? normalizeServiceUrl(record.payload.service_url)
|
|
123
|
+
: null
|
|
124
|
+
serviceUrl = pinnedUrl || serviceUrlFromModelSettings(settings)
|
|
125
|
+
} catch {
|
|
126
|
+
return jsonResponse(500, { message: 'DSH 无法读取 ANT 连接配置。' })
|
|
127
|
+
}
|
|
128
|
+
if (!token || !serviceUrl) return jsonResponse(401, { message: '当前 DSH 未连接 ANT,请验证工作邮箱。' })
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const session = await updateModelSession(serviceUrl, token.value, 'heartbeat')
|
|
132
|
+
if (session.status === 401 || session.status === 403) {
|
|
133
|
+
return jsonResponse(401, { message: 'ANT 成员凭证已失效,请重新验证工作邮箱。' })
|
|
134
|
+
}
|
|
135
|
+
if (!session.ok) return jsonResponse(502, { message: '无法激活 ANT 模型会话。' })
|
|
136
|
+
const upstream = await fetch(new URL('/api/me/models', serviceUrl), {
|
|
137
|
+
headers: { authorization: `Bearer ${token.value}`, accept: 'application/json' },
|
|
138
|
+
redirect: 'manual',
|
|
139
|
+
signal: AbortSignal.timeout(10_000),
|
|
140
|
+
})
|
|
141
|
+
const text = await readLimitedBody(upstream)
|
|
142
|
+
if (text === null) return jsonResponse(502, { message: '团队模型目录响应过大' })
|
|
143
|
+
if (upstream.status === 401 || upstream.status === 403) {
|
|
144
|
+
return jsonResponse(401, { message: 'ANT 成员凭证已失效,请重新验证工作邮箱。' })
|
|
145
|
+
}
|
|
146
|
+
if (!upstream.ok) return jsonResponse(502, { message: 'ANT 暂时无法提供团队模型目录。' })
|
|
147
|
+
let result
|
|
148
|
+
try { result = JSON.parse(text) } catch { return jsonResponse(502, { message: 'ANT 团队模型目录格式无效。' }) }
|
|
149
|
+
if (!Array.isArray(result?.models) || normalizeServiceUrl(result.service_url) !== serviceUrl) {
|
|
150
|
+
return jsonResponse(502, { message: 'ANT 团队模型目录与已连接服务不匹配。' })
|
|
151
|
+
}
|
|
152
|
+
if (!pinnedUrl) {
|
|
153
|
+
try { await pinServiceUrl(credentials, serviceUrl) } catch {
|
|
154
|
+
return jsonResponse(500, { message: 'DSH 无法保存已连接的 ANT 服务地址' })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return jsonResponse(200, { service_url: serviceUrl, models: result.models })
|
|
158
|
+
} catch {
|
|
159
|
+
return jsonResponse(502, { message: '无法连接 ANT 团队服务' })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function fetchServiceInfo(request, credentials, settings) {
|
|
164
|
+
if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
|
|
165
|
+
let body
|
|
166
|
+
try {
|
|
167
|
+
const text = await request.text()
|
|
168
|
+
if (text.length > 4096) return jsonResponse(413, { message: '版本协商请求过大。' })
|
|
169
|
+
body = JSON.parse(text)
|
|
170
|
+
} catch {
|
|
171
|
+
return jsonResponse(400, { message: '版本协商请求格式无效。' })
|
|
172
|
+
}
|
|
173
|
+
const hasServiceUrl = typeof body?.service_url === 'string'
|
|
174
|
+
const suppliedServiceUrl = hasServiceUrl && body.service_url.trim()
|
|
175
|
+
? normalizeServiceUrl(body.service_url)
|
|
176
|
+
: null
|
|
177
|
+
const bundleVersion = typeof body?.bundle_version === 'string'
|
|
178
|
+
&& /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/u.test(body.bundle_version)
|
|
179
|
+
? body.bundle_version
|
|
180
|
+
: null
|
|
181
|
+
const clientProtocols = Array.isArray(body?.api_protocol_versions)
|
|
182
|
+
&& body.api_protocol_versions.length <= 16
|
|
183
|
+
&& body.api_protocol_versions.every((version) => Number.isInteger(version) && version > 0 && version <= 65535)
|
|
184
|
+
? [...new Set(body.api_protocol_versions)]
|
|
185
|
+
: null
|
|
186
|
+
if ((hasServiceUrl && body.service_url.trim() && !suppliedServiceUrl) || !bundleVersion || !clientProtocols?.length) {
|
|
187
|
+
return jsonResponse(400, { message: '团队服务地址或客户端版本信息无效。' })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let serviceUrl = suppliedServiceUrl
|
|
191
|
+
if (!serviceUrl) {
|
|
192
|
+
try {
|
|
193
|
+
serviceUrl = (await readMemberConnection(credentials, settings))?.serviceUrl || null
|
|
194
|
+
} catch {
|
|
195
|
+
return jsonResponse(500, { message: 'DSH 无法读取已保存的 ANT 服务地址。' })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!serviceUrl) return jsonResponse(400, { message: '请先填写 ANT 服务地址或连接团队。' })
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const upstream = await fetch(new URL('/.well-known/ant', serviceUrl), {
|
|
202
|
+
headers: {
|
|
203
|
+
accept: 'application/json',
|
|
204
|
+
'x-ant-bundle-version': bundleVersion,
|
|
205
|
+
'x-ant-api-protocol-versions': clientProtocols.join(','),
|
|
206
|
+
},
|
|
207
|
+
redirect: 'manual',
|
|
208
|
+
signal: AbortSignal.timeout(10_000),
|
|
209
|
+
})
|
|
210
|
+
const text = await readLimitedBody(upstream)
|
|
211
|
+
if (text === null) return jsonResponse(502, { message: 'ANT 版本信息响应过大。' })
|
|
212
|
+
if (!upstream.ok) return jsonResponse(502, { message: `ANT 版本信息请求失败(${upstream.status})。` })
|
|
213
|
+
let info
|
|
214
|
+
try { info = JSON.parse(text) } catch { return jsonResponse(502, { message: 'ANT 版本信息格式无效。' }) }
|
|
215
|
+
if (info?.service !== 'ant-server' || typeof info.version !== 'string' || info.version.length > 64) {
|
|
216
|
+
return jsonResponse(502, { message: '目标地址未返回有效的 ANT 服务信息。' })
|
|
217
|
+
}
|
|
218
|
+
const rawServerProtocols = info.api_protocol_versions
|
|
219
|
+
const validServerProtocols = Array.isArray(rawServerProtocols)
|
|
220
|
+
&& rawServerProtocols.every((version) => Number.isInteger(version) && version > 0 && version <= 65535)
|
|
221
|
+
const serverProtocols = validServerProtocols ? [...new Set(rawServerProtocols)] : []
|
|
222
|
+
const sharedProtocols = serverProtocols.filter((version) => clientProtocols.includes(version))
|
|
223
|
+
const compatibility = validServerProtocols ? sharedProtocols.length > 0 : null
|
|
224
|
+
return jsonResponse(200, {
|
|
225
|
+
server_version: info.version,
|
|
226
|
+
bundle_version: bundleVersion,
|
|
227
|
+
server_api_protocol_versions: serverProtocols,
|
|
228
|
+
client_api_protocol_versions: clientProtocols,
|
|
229
|
+
shared_api_protocol_versions: sharedProtocols,
|
|
230
|
+
compatibility,
|
|
231
|
+
})
|
|
232
|
+
} catch {
|
|
233
|
+
return jsonResponse(502, { message: '无法连接 ANT 服务以检查版本兼容性。' })
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function fetchBundleRelease(request, credentials, settings) {
|
|
238
|
+
if (request.method !== 'GET') return jsonResponse(405, { message: '只支持 GET 请求' })
|
|
239
|
+
let connection
|
|
240
|
+
try { connection = await readMemberConnection(credentials, settings) } catch {
|
|
241
|
+
return jsonResponse(500, { message: 'DSH 无法读取 ANT 连接配置。' })
|
|
242
|
+
}
|
|
243
|
+
if (!connection) return jsonResponse(401, { message: '请先在 ANT 插件中连接团队。' })
|
|
244
|
+
try {
|
|
245
|
+
const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
|
|
246
|
+
if (session.status === 401 || session.status === 403) return jsonResponse(401, { message: 'ANT 成员凭证已失效。' })
|
|
247
|
+
if (!session.ok) return jsonResponse(502, { message: '无法激活 ANT 模型会话。' })
|
|
248
|
+
const upstream = await fetch(new URL('/api/me/bundle-release', connection.serviceUrl), {
|
|
249
|
+
headers: { authorization: `Bearer ${connection.token}`, accept: 'application/json' },
|
|
250
|
+
redirect: 'manual',
|
|
251
|
+
signal: AbortSignal.timeout(10_000),
|
|
252
|
+
})
|
|
253
|
+
const text = await readLimitedBody(upstream)
|
|
254
|
+
if (text === null) return jsonResponse(502, { message: 'ANT 更新信息响应过大。' })
|
|
255
|
+
if (upstream.status === 401 || upstream.status === 403) return jsonResponse(401, { message: 'ANT 成员凭证已失效。' })
|
|
256
|
+
if (!upstream.ok) return jsonResponse(502, { message: 'ANT 暂时无法提供插件更新信息。' })
|
|
257
|
+
let release
|
|
258
|
+
try { release = JSON.parse(text) } catch { return jsonResponse(502, { message: 'ANT 更新信息格式无效。' }) }
|
|
259
|
+
const validPackageName = typeof release?.package_name === 'string'
|
|
260
|
+
&& (release.package_name === 'ant' || /^@[a-z0-9][a-z0-9._-]*\/ant$/u.test(release.package_name))
|
|
261
|
+
const validVersion = typeof release?.version === 'string'
|
|
262
|
+
&& (release.version === '' || /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u.test(release.version))
|
|
263
|
+
const installSpec = release?.package_name === 'ant'
|
|
264
|
+
? `ant@${release?.version}`
|
|
265
|
+
: `ant@npm:${release?.package_name}@${release?.version}`
|
|
266
|
+
if (release?.bundle_name !== 'ant' || !validPackageName || !validVersion
|
|
267
|
+
|| typeof release?.install_spec !== 'string' || typeof release?.release_notes !== 'string'
|
|
268
|
+
|| (release.version && release.install_spec !== installSpec)
|
|
269
|
+
|| (!release.version && release.install_spec !== '')) {
|
|
270
|
+
return jsonResponse(502, { message: 'ANT 更新信息字段无效。' })
|
|
271
|
+
}
|
|
272
|
+
return jsonResponse(200, release)
|
|
273
|
+
} catch {
|
|
274
|
+
return jsonResponse(502, { message: '无法连接 ANT 更新服务。' })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function disconnectMember(request, credentials, settings) {
|
|
279
|
+
if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
|
|
280
|
+
let connection
|
|
281
|
+
try { connection = await readMemberConnection(credentials, settings) } catch {
|
|
282
|
+
return jsonResponse(500, { message: 'DSH 无法读取 ANT 连接配置。' })
|
|
283
|
+
}
|
|
284
|
+
if (!connection) return jsonResponse(200, { revoked: false })
|
|
285
|
+
try {
|
|
286
|
+
const response = await fetch(new URL('/auth/dsh-session/disconnect', connection.serviceUrl), {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: { authorization: `Bearer ${connection.token}`, accept: 'application/json' },
|
|
289
|
+
redirect: 'manual',
|
|
290
|
+
signal: AbortSignal.timeout(10_000),
|
|
291
|
+
})
|
|
292
|
+
if (response.status === 204 || response.status === 401 || response.status === 403) {
|
|
293
|
+
return jsonResponse(200, { revoked: response.status === 204 })
|
|
294
|
+
}
|
|
295
|
+
return jsonResponse(200, { revoked: false })
|
|
296
|
+
} catch {
|
|
297
|
+
return jsonResponse(200, { revoked: false })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function relayFeishuRequest(request, path, credentials, settings) {
|
|
302
|
+
if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
|
|
303
|
+
let connection
|
|
304
|
+
try { connection = await readMemberConnection(credentials, settings) } catch {
|
|
305
|
+
return jsonResponse(500, { message: 'DSH 无法读取 ANT 连接配置。' })
|
|
306
|
+
}
|
|
307
|
+
if (!connection) return jsonResponse(401, { message: '请先在 ANT 插件中连接团队。' })
|
|
308
|
+
try {
|
|
309
|
+
const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
|
|
310
|
+
if (session.status === 401 || session.status === 403) return jsonResponse(401, { message: 'ANT 成员凭证已失效。' })
|
|
311
|
+
if (!session.ok) return jsonResponse(502, { message: '无法激活 ANT 模型会话。' })
|
|
312
|
+
const body = await request.text()
|
|
313
|
+
if (body.length > 64 * 1024) return jsonResponse(413, { message: '飞书任务请求过大。' })
|
|
314
|
+
const upstream = await fetch(new URL(path, connection.serviceUrl), {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: {
|
|
317
|
+
authorization: `Bearer ${connection.token}`,
|
|
318
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
319
|
+
accept: 'application/json',
|
|
320
|
+
},
|
|
321
|
+
...(body ? { body } : {}),
|
|
322
|
+
redirect: 'manual',
|
|
323
|
+
signal: AbortSignal.timeout(15_000),
|
|
324
|
+
})
|
|
325
|
+
const text = await readLimitedBody(upstream)
|
|
326
|
+
if (text === null) return jsonResponse(502, { message: 'ANT 飞书任务响应过大。' })
|
|
327
|
+
return new Response(text, {
|
|
328
|
+
status: upstream.status,
|
|
329
|
+
headers: { 'content-type': upstream.headers.get('content-type') || 'application/json', 'cache-control': 'no-store' },
|
|
330
|
+
})
|
|
331
|
+
} catch {
|
|
332
|
+
return jsonResponse(502, { message: '无法连接 ANT 飞书任务服务。' })
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function requestFeishuJson(serviceUrl, token, path, body, timeout = 15_000) {
|
|
337
|
+
const response = await fetch(new URL(path, serviceUrl), {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: {
|
|
340
|
+
authorization: `Bearer ${token}`,
|
|
341
|
+
accept: 'application/json',
|
|
342
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
343
|
+
},
|
|
344
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
345
|
+
redirect: 'manual',
|
|
346
|
+
signal: AbortSignal.timeout(timeout),
|
|
347
|
+
})
|
|
348
|
+
const text = await readLimitedBody(response)
|
|
349
|
+
if (text === null) throw new Error('ANT 飞书任务响应过大')
|
|
350
|
+
if (!response.ok) throw new Error(`ANT 飞书任务请求失败(${response.status})`)
|
|
351
|
+
if (response.status === 204 || text === '') return null
|
|
352
|
+
try { return JSON.parse(text) } catch { throw new Error('ANT 飞书任务响应格式无效') }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function syncModelsToHost(settings, serviceUrl, models) {
|
|
356
|
+
if (typeof settings?.mutate !== 'function') throw new Error('当前 DSH 版本未提供模型设置接口。')
|
|
357
|
+
const grouped = {
|
|
358
|
+
openai: models.filter((model) => model.protocol === 'openai'),
|
|
359
|
+
anthropic: models.filter((model) => model.protocol === 'anthropic'),
|
|
360
|
+
}
|
|
361
|
+
const ops = []
|
|
362
|
+
for (const protocol of ['openai', 'anthropic']) {
|
|
363
|
+
const route = protocol === 'openai' ? 'ant-team-openai' : 'ant-team-anthropic'
|
|
364
|
+
const protocolModels = grouped[protocol]
|
|
365
|
+
if (protocolModels.length === 0) {
|
|
366
|
+
ops.push({ op: 'unset', path: ['providers', route] })
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
ops.push({ op: 'set', path: ['providers', route], value: {
|
|
370
|
+
displayName: protocol === 'openai' ? 'Lakala (OpenAI)' : 'Lakala (Anthropic)',
|
|
371
|
+
apiKeyEnv: 'ANT_MODEL_API_KEY',
|
|
372
|
+
api: protocol === 'openai' ? 'openai-completions' : 'anthropic-messages',
|
|
373
|
+
baseURL: protocol === 'openai'
|
|
374
|
+
? new URL('/model-gateway/openai/v1', serviceUrl).toString()
|
|
375
|
+
: new URL('/model-gateway/anthropic', serviceUrl).toString(),
|
|
376
|
+
models: protocolModels.map((model) => ({ id: model.model_id, name: model.display_name })),
|
|
377
|
+
} })
|
|
378
|
+
}
|
|
379
|
+
const result = await settings.mutate('llm-pi-ai', ops, undefined)
|
|
380
|
+
if (!result.ok) throw new Error('DSH 无法更新 ANT 获准模型。')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function assistantMessageText(event) {
|
|
384
|
+
const content = event?.data?.message?.content
|
|
385
|
+
if (!Array.isArray(content)) return ''
|
|
386
|
+
return content.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
387
|
+
.map((part) => part.text).join('')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function executeFeishuTask(task, connection, settings, sessionController, stoppedSignal) {
|
|
391
|
+
let sessionId
|
|
392
|
+
let promptAccepted = false
|
|
393
|
+
let followAbort
|
|
394
|
+
let taskHeartbeat
|
|
395
|
+
let runtimeTimeout
|
|
396
|
+
let timedOut = false
|
|
397
|
+
let stopSignalListener
|
|
398
|
+
try {
|
|
399
|
+
const catalogResponse = await fetch(new URL('/api/me/models', connection.serviceUrl), {
|
|
400
|
+
headers: { authorization: `Bearer ${connection.token}`, accept: 'application/json' },
|
|
401
|
+
redirect: 'manual',
|
|
402
|
+
signal: AbortSignal.timeout(10_000),
|
|
403
|
+
})
|
|
404
|
+
const text = await readLimitedBody(catalogResponse)
|
|
405
|
+
if (!catalogResponse.ok || text === null) throw new Error('ANT 当前无法返回获准模型。')
|
|
406
|
+
const catalog = JSON.parse(text)
|
|
407
|
+
if (normalizeServiceUrl(catalog.service_url) !== connection.serviceUrl || !Array.isArray(catalog.models)) {
|
|
408
|
+
throw new Error('ANT 返回的模型目录无效。')
|
|
409
|
+
}
|
|
410
|
+
await syncModelsToHost(settings, connection.serviceUrl, catalog.models)
|
|
411
|
+
const model = task.models?.[0]
|
|
412
|
+
if (!model || !catalog.models.some((item) => item.protocol === model.protocol && item.model_id === model.model_id)) {
|
|
413
|
+
throw new Error('任务模型已不在成员授权目录中。')
|
|
414
|
+
}
|
|
415
|
+
if (typeof sessionController?.create !== 'function' || typeof sessionController?.follow !== 'function'
|
|
416
|
+
|| typeof sessionController?.selectModel !== 'function' || typeof sessionController?.prompt !== 'function') {
|
|
417
|
+
throw new Error('当前 DSH 版本未提供 Session Controller 接口。')
|
|
418
|
+
}
|
|
419
|
+
sessionId = await sessionController.create(task.session_id ? { sessionId: task.session_id } : {})
|
|
420
|
+
const provider = model.protocol === 'openai' ? 'ant-team-openai' : 'ant-team-anthropic'
|
|
421
|
+
await sessionController.selectModel({ sessionId, provider, model: model.model_id })
|
|
422
|
+
followAbort = new AbortController()
|
|
423
|
+
stopSignalListener = () => followAbort.abort()
|
|
424
|
+
stoppedSignal.addEventListener('abort', stopSignalListener, { once: true })
|
|
425
|
+
const follow = sessionController.follow({ address: { kind: 'session', sessionId } }, followAbort.signal)
|
|
426
|
+
const iterator = follow[Symbol.asyncIterator]()
|
|
427
|
+
const opening = await iterator.next()
|
|
428
|
+
if (opening.done || opening.value?.type !== 'snapshot') throw new Error('DSH 会话无法开始跟随。')
|
|
429
|
+
if (stoppedSignal.aborted) throw new Error('DSH 客户端正在关闭。')
|
|
430
|
+
|
|
431
|
+
await sessionController.prompt({
|
|
432
|
+
requestId: globalThis.crypto.randomUUID(),
|
|
433
|
+
sessionId,
|
|
434
|
+
mode: 'queue',
|
|
435
|
+
content: [{ type: 'text', text: task.prompt }],
|
|
436
|
+
}, followAbort.signal)
|
|
437
|
+
promptAccepted = true
|
|
438
|
+
taskHeartbeat = setInterval(() => {
|
|
439
|
+
void updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
|
|
440
|
+
.then((response) => {
|
|
441
|
+
if (!response.ok) throw new Error('ANT model session expired')
|
|
442
|
+
return requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/heartbeat', { task_id: task.id })
|
|
443
|
+
})
|
|
444
|
+
.catch(() => followAbort.abort())
|
|
445
|
+
}, FEISHU_TASK_HEARTBEAT_INTERVAL_MS)
|
|
446
|
+
taskHeartbeat.unref?.()
|
|
447
|
+
runtimeTimeout = setTimeout(() => { timedOut = true; followAbort.abort() }, FEISHU_TASK_MAX_RUNTIME_MS)
|
|
448
|
+
runtimeTimeout.unref?.()
|
|
449
|
+
|
|
450
|
+
let result = ''
|
|
451
|
+
while (!stoppedSignal.aborted) {
|
|
452
|
+
const next = await iterator.next()
|
|
453
|
+
if (next.done) throw new Error('DSH 会话结果流意外结束。')
|
|
454
|
+
const frame = next.value
|
|
455
|
+
if (frame?.type !== 'event') continue
|
|
456
|
+
const event = frame.event
|
|
457
|
+
if (event?.type === 'assistant/message') {
|
|
458
|
+
const message = assistantMessageText(event)
|
|
459
|
+
if (message) result = message
|
|
460
|
+
}
|
|
461
|
+
if (event?.type === 'turn/end') {
|
|
462
|
+
if (event.data?.reason?.kind !== 'completed' || !result.trim()) throw new Error('DSH 任务未能正常完成。')
|
|
463
|
+
await requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/complete', {
|
|
464
|
+
task_id: task.id,
|
|
465
|
+
session_id: sessionId,
|
|
466
|
+
result,
|
|
467
|
+
})
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (stoppedSignal.aborted) throw new Error('DSH 客户端正在关闭。')
|
|
472
|
+
} catch {
|
|
473
|
+
const status = promptAccepted ? 'unknown' : 'failed'
|
|
474
|
+
try {
|
|
475
|
+
await requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/finish', {
|
|
476
|
+
task_id: task.id,
|
|
477
|
+
status: timedOut && promptAccepted ? 'unknown' : status,
|
|
478
|
+
})
|
|
479
|
+
} catch {}
|
|
480
|
+
} finally {
|
|
481
|
+
clearInterval(taskHeartbeat)
|
|
482
|
+
clearTimeout(runtimeTimeout)
|
|
483
|
+
if (stopSignalListener) stoppedSignal.removeEventListener('abort', stopSignalListener)
|
|
484
|
+
followAbort?.abort()
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function installFeishuTaskWorker(credentials, settings, sessionController) {
|
|
489
|
+
let stopped = false
|
|
490
|
+
let busy = false
|
|
491
|
+
let taskAbort
|
|
492
|
+
let work = Promise.resolve()
|
|
493
|
+
let pollWork = Promise.resolve()
|
|
494
|
+
const poll = async () => {
|
|
495
|
+
if (stopped || busy) return
|
|
496
|
+
busy = true
|
|
497
|
+
try {
|
|
498
|
+
const connection = await readMemberConnection(credentials, settings)
|
|
499
|
+
if (!connection) return
|
|
500
|
+
const session = await updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
|
|
501
|
+
if (!session.ok || stopped) return
|
|
502
|
+
const claim = await requestFeishuJson(connection.serviceUrl, connection.token, '/api/feishu/tasks/claim')
|
|
503
|
+
if (!claim?.task || stopped) return
|
|
504
|
+
taskAbort = new AbortController()
|
|
505
|
+
work = executeFeishuTask(claim.task, connection, settings, sessionController, taskAbort.signal)
|
|
506
|
+
await work
|
|
507
|
+
} catch {
|
|
508
|
+
// A transient polling error is retried on the next interval.
|
|
509
|
+
} finally {
|
|
510
|
+
busy = false
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const schedulePoll = () => {
|
|
514
|
+
if (stopped || busy) return
|
|
515
|
+
pollWork = poll()
|
|
516
|
+
}
|
|
517
|
+
const timer = setInterval(schedulePoll, FEISHU_POLL_INTERVAL_MS)
|
|
518
|
+
timer.unref?.()
|
|
519
|
+
schedulePoll()
|
|
520
|
+
return async () => {
|
|
521
|
+
stopped = true
|
|
522
|
+
clearInterval(timer)
|
|
523
|
+
taskAbort?.abort()
|
|
524
|
+
await pollWork
|
|
525
|
+
await work
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function fetchAuthRequest(request, endpoint, credentials) {
|
|
530
|
+
if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求' })
|
|
531
|
+
let body
|
|
532
|
+
try { body = await request.json() } catch { return jsonResponse(400, { message: '请求格式无效' }) }
|
|
533
|
+
const serviceUrl = typeof body?.service_url === 'string' ? normalizeServiceUrl(body.service_url) : null
|
|
534
|
+
const payload = endpoint === 'request-code'
|
|
535
|
+
? { email: body?.email }
|
|
536
|
+
: { email: body?.email, code: body?.code, device_id: body?.device_id }
|
|
537
|
+
if (!serviceUrl || typeof payload.email !== 'string' || payload.email.length > 320
|
|
538
|
+
|| (endpoint !== 'request-code' && (typeof payload.code !== 'string' || payload.code.length !== 8
|
|
539
|
+
|| typeof payload.device_id !== 'string' || payload.device_id.length > 64))) {
|
|
540
|
+
return jsonResponse(400, { message: '团队服务地址或邮箱验证信息无效' })
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
const upstream = await fetch(new URL(`/auth/${endpoint}`, serviceUrl), {
|
|
545
|
+
method: 'POST',
|
|
546
|
+
headers: {
|
|
547
|
+
accept: 'application/json',
|
|
548
|
+
'content-type': 'application/json',
|
|
549
|
+
origin: serviceUrl,
|
|
550
|
+
},
|
|
551
|
+
body: JSON.stringify(payload),
|
|
552
|
+
redirect: 'manual',
|
|
553
|
+
signal: AbortSignal.timeout(10_000),
|
|
554
|
+
})
|
|
555
|
+
const text = await readLimitedBody(upstream)
|
|
556
|
+
if (text === null) return jsonResponse(502, { message: 'ANT 服务响应过大' })
|
|
557
|
+
if (endpoint === 'dsh-connect' && upstream.ok) {
|
|
558
|
+
let result
|
|
559
|
+
try { result = JSON.parse(text) } catch { return jsonResponse(502, { message: 'ANT 连接响应格式无效' }) }
|
|
560
|
+
if (typeof result?.token !== 'string' || !result.token.startsWith('antm_') || !Array.isArray(result.models)) {
|
|
561
|
+
return jsonResponse(502, { message: 'ANT 连接响应格式无效' })
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
await pinServiceUrl(credentials, serviceUrl)
|
|
565
|
+
} catch {
|
|
566
|
+
return jsonResponse(500, { message: 'DSH 无法保存已连接的 ANT 服务地址' })
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
const session = await updateModelSession(serviceUrl, result.token, 'heartbeat')
|
|
570
|
+
if (!session.ok) return jsonResponse(502, { message: '无法激活 ANT 模型会话。' })
|
|
571
|
+
} catch {
|
|
572
|
+
return jsonResponse(502, { message: '无法连接 ANT 模型会话服务。' })
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return new Response(text, {
|
|
576
|
+
status: upstream.status,
|
|
577
|
+
headers: { 'content-type': upstream.headers.get('content-type') || 'application/json', 'cache-control': 'no-store' },
|
|
578
|
+
})
|
|
579
|
+
} catch {
|
|
580
|
+
return jsonResponse(502, { message: '无法连接 ANT 团队服务' })
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function apply(ctx) {
|
|
585
|
+
ctx.inject(['connection', 'credentials', 'settings', 'sessionController'], (connectionCtx) => {
|
|
586
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
587
|
+
path: SERVICE_INFO_PATH,
|
|
588
|
+
methods: ['POST'],
|
|
589
|
+
requestBody: 'buffered',
|
|
590
|
+
fetch: (request) => fetchServiceInfo(request, connectionCtx.credentials, connectionCtx.settings),
|
|
591
|
+
}), 'ant-teams: ANT client-server version handshake')
|
|
592
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
593
|
+
path: MODEL_SYNC_PATH,
|
|
594
|
+
methods: ['POST'],
|
|
595
|
+
requestBody: 'buffered',
|
|
596
|
+
fetch: (request) => fetchMemberModels(request, connectionCtx.credentials, connectionCtx.settings),
|
|
597
|
+
}), 'ant-teams: authenticated model catalog relay')
|
|
598
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
599
|
+
path: BUNDLE_RELEASE_PATH,
|
|
600
|
+
methods: ['GET'],
|
|
601
|
+
fetch: (request) => fetchBundleRelease(request, connectionCtx.credentials, connectionCtx.settings),
|
|
602
|
+
}), 'ant-teams: authenticated Bundle release relay')
|
|
603
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
604
|
+
path: DISCONNECT_PATH,
|
|
605
|
+
methods: ['POST'],
|
|
606
|
+
fetch: (request) => disconnectMember(request, connectionCtx.credentials, connectionCtx.settings),
|
|
607
|
+
}), 'ant-teams: revoke this device connection')
|
|
608
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
609
|
+
path: REQUEST_CODE_PATH,
|
|
610
|
+
methods: ['POST'],
|
|
611
|
+
requestBody: 'buffered',
|
|
612
|
+
fetch: (request) => fetchAuthRequest(request, 'request-code', connectionCtx.credentials),
|
|
613
|
+
}), 'ant-teams: relay email verification code request')
|
|
614
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
615
|
+
path: CONNECT_PATH,
|
|
616
|
+
methods: ['POST'],
|
|
617
|
+
requestBody: 'buffered',
|
|
618
|
+
fetch: (request) => fetchAuthRequest(request, 'dsh-connect', connectionCtx.credentials),
|
|
619
|
+
}), 'ant-teams: relay DSH account connection')
|
|
620
|
+
for (const [path, endpoint] of [
|
|
621
|
+
[FEISHU_MEMBER_APPS_PATH, '/api/feishu/member-apps'],
|
|
622
|
+
[FEISHU_LINK_CODE_PATH, '/api/feishu/link-code'],
|
|
623
|
+
[FEISHU_CLAIM_PATH, '/api/feishu/tasks/claim'],
|
|
624
|
+
[FEISHU_HEARTBEAT_PATH, '/api/feishu/tasks/heartbeat'],
|
|
625
|
+
[FEISHU_COMPLETE_PATH, '/api/feishu/tasks/complete'],
|
|
626
|
+
[FEISHU_FINISH_PATH, '/api/feishu/tasks/finish'],
|
|
627
|
+
]) {
|
|
628
|
+
connectionCtx.effect(() => connectionCtx.connection.fetch.register({
|
|
629
|
+
path,
|
|
630
|
+
methods: ['POST'],
|
|
631
|
+
requestBody: 'buffered',
|
|
632
|
+
fetch: (request) => relayFeishuRequest(request, endpoint, connectionCtx.credentials, connectionCtx.settings),
|
|
633
|
+
}), `ant-teams: relay Feishu ${endpoint.split('/').at(-1)}`)
|
|
634
|
+
}
|
|
635
|
+
connectionCtx.effect(() => {
|
|
636
|
+
let stopped = false
|
|
637
|
+
let inFlight = Promise.resolve()
|
|
638
|
+
let refreshing = false
|
|
639
|
+
const heartbeat = () => {
|
|
640
|
+
if (stopped || refreshing) return
|
|
641
|
+
refreshing = true
|
|
642
|
+
inFlight = readMemberConnection(connectionCtx.credentials, connectionCtx.settings)
|
|
643
|
+
.then((connection) => connection
|
|
644
|
+
? updateModelSession(connection.serviceUrl, connection.token, 'heartbeat')
|
|
645
|
+
: undefined)
|
|
646
|
+
.catch(() => undefined)
|
|
647
|
+
.finally(() => { refreshing = false })
|
|
648
|
+
}
|
|
649
|
+
heartbeat()
|
|
650
|
+
const timer = setInterval(heartbeat, SESSION_HEARTBEAT_INTERVAL_MS)
|
|
651
|
+
timer.unref?.()
|
|
652
|
+
return async () => {
|
|
653
|
+
stopped = true
|
|
654
|
+
clearInterval(timer)
|
|
655
|
+
await inFlight
|
|
656
|
+
try {
|
|
657
|
+
const connection = await readMemberConnection(connectionCtx.credentials, connectionCtx.settings)
|
|
658
|
+
if (connection) await updateModelSession(connection.serviceUrl, connection.token, 'revoke')
|
|
659
|
+
} catch {}
|
|
660
|
+
}
|
|
661
|
+
}, 'ant-teams: ANT model activity session')
|
|
662
|
+
connectionCtx.effect(
|
|
663
|
+
() => installFeishuTaskWorker(connectionCtx.credentials, connectionCtx.settings, connectionCtx.sessionController),
|
|
664
|
+
'ant-teams: DSH-owned Feishu task worker',
|
|
665
|
+
)
|
|
666
|
+
})
|
|
667
|
+
}
|