@dcrays/dcgchat 0.4.29 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,212 +0,0 @@
1
- import { stat } from 'node:fs/promises'
2
- import { extname } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- // @ts-ignore
5
- import OSS from 'ali-oss'
6
- import { getStsToken, getUserToken } from './api.js'
7
- import { dcgLogger } from '../utils/log.js'
8
-
9
- /** 仅对内存 Buffer 超过此大小使用分片(本地路径一律走 put,避免 multipart 对类型的限制) */
10
- const MULTIPART_THRESHOLD_BYTES = 1024 * 1024
11
-
12
- /** 分片大小:OSS 要求每片 ≥100 KB(最后一片可更小) */
13
- const MULTIPART_PART_SIZE = 1024 * 1024
14
-
15
- /** ali-oss 默认 timeout 为 60s,大文件单 PUT 或慢网易触发 ResponseTimeoutError */
16
- const OSS_HTTP_TIMEOUT_MS = 15 * 60 * 1000
17
-
18
- /** 归一化入参,避免 file://、包装对象、TypedArray 等导致 SDK 识别失败 */
19
- function coerceOssFileInput(input: File | string | Buffer): File | string | Buffer {
20
- if (typeof input === 'string') {
21
- const t = input.trim()
22
- if (t.startsWith('file:')) {
23
- try {
24
- return fileURLToPath(t)
25
- } catch {
26
- return input
27
- }
28
- }
29
- return input
30
- }
31
- if (Buffer.isBuffer(input)) {
32
- return input
33
- }
34
- if (input && typeof input === 'object') {
35
- if (ArrayBuffer.isView(input) && !(input instanceof DataView) && !Buffer.isBuffer(input)) {
36
- const v = input as ArrayBufferView
37
- return Buffer.from(v.buffer, v.byteOffset, v.byteLength)
38
- }
39
- const o = input as unknown as Record<string, unknown>
40
- const p = o.path ?? o.filePath
41
- if (typeof p === 'string' && p.trim()) return p.trim()
42
- }
43
- return input
44
- }
45
-
46
- async function getUploadByteLength(input: File | string | Buffer): Promise<number> {
47
- if (Buffer.isBuffer(input)) return input.length
48
- if (typeof input === 'string') {
49
- const s = await stat(input)
50
- return s.size
51
- }
52
- return input.size
53
- }
54
-
55
- /** 常见可在浏览器内联预览的类型(避免一律 application/octet-stream 触发下载) */
56
- const PREVIEW_EXT_MIME: Record<string, string> = {
57
- '.jpg': 'image/jpeg',
58
- '.jpeg': 'image/jpeg',
59
- '.png': 'image/png',
60
- '.gif': 'image/gif',
61
- '.webp': 'image/webp',
62
- '.bmp': 'image/bmp',
63
- '.svg': 'image/svg+xml',
64
- '.ico': 'image/x-icon',
65
- '.avif': 'image/avif',
66
- '.heic': 'image/heic',
67
- '.heif': 'image/heif',
68
- '.pdf': 'application/pdf',
69
- '.mp4': 'video/mp4',
70
- '.webm': 'video/webm',
71
- '.mov': 'video/quicktime',
72
- '.mp3': 'audio/mpeg',
73
- '.wav': 'audio/wav',
74
- '.ogg': 'audio/ogg',
75
- '.opus': 'audio/opus',
76
- '.m4a': 'audio/mp4',
77
- '.aac': 'audio/aac',
78
- '.flac': 'audio/flac',
79
- '.txt': 'text/plain; charset=utf-8',
80
- '.log': 'text/plain; charset=utf-8',
81
- '.csv': 'text/csv; charset=utf-8',
82
- '.html': 'text/html; charset=utf-8',
83
- '.htm': 'text/html; charset=utf-8',
84
- '.css': 'text/css; charset=utf-8',
85
- '.js': 'text/javascript; charset=utf-8',
86
- '.mjs': 'text/javascript; charset=utf-8',
87
- '.json': 'application/json; charset=utf-8',
88
- '.xml': 'application/xml; charset=utf-8',
89
- '.md': 'text/markdown; charset=utf-8',
90
- '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
91
- '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
92
- '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
93
- }
94
-
95
- function mimeFromPathOrName(pathOrName: string): string | undefined {
96
- const base = pathOrName.split(/[/\\]/).pop() ?? pathOrName
97
- const ext = extname(base).toLowerCase()
98
- return ext ? PREVIEW_EXT_MIME[ext] : undefined
99
- }
100
-
101
- /** 解析上传 Content-Type,并配合 Content-Disposition: inline 便于直链预览 */
102
- function resolveMime(input: File | string | Buffer, fileNameHint?: string): string {
103
- if (typeof input === 'string') {
104
- return mimeFromPathOrName(input) ?? 'application/octet-stream'
105
- }
106
- if (Buffer.isBuffer(input)) {
107
- if (fileNameHint) {
108
- const fromName = mimeFromPathOrName(fileNameHint)
109
- if (fromName) return fromName
110
- }
111
- return 'application/octet-stream'
112
- }
113
- const declared = input.type?.trim()
114
- if (declared && declared !== 'application/octet-stream') {
115
- return declared
116
- }
117
- const name = typeof input.name === 'string' && input.name ? input.name : ''
118
- if (name) {
119
- const fromName = mimeFromPathOrName(name)
120
- if (fromName) return fromName
121
- }
122
- return declared || 'application/octet-stream'
123
- }
124
-
125
- /**
126
- * 将 File/路径/Buffer 转为 ali-oss 接受的类型。
127
- * 本地路径保持为字符串:put 内部用 contentLength + ReadStream,大文件也稳定。
128
- */
129
- async function toUploadContent(input: File | string | Buffer): Promise<{ content: Buffer | string; fileName: string }> {
130
- if (Buffer.isBuffer(input)) {
131
- return { content: input, fileName: 'file' }
132
- }
133
- if (typeof input === 'string') {
134
- return {
135
- content: input,
136
- fileName: input.split(/[/\\]/).pop() ?? 'file'
137
- }
138
- }
139
- const buf = Buffer.from(await input.arrayBuffer())
140
- const n = (input as { name?: string }).name
141
- return { content: buf, fileName: typeof n === 'string' && n ? n : 'file' }
142
- }
143
-
144
- export type OssUploadOptions = {
145
- /** 分片上传进度,p 为 0~1(仅大 Buffer 分片时触发) */
146
- onProgress?: (p: number) => void
147
- /** HTTP 超时(毫秒),覆盖默认 15 分钟;可传 `30 * 60 * 1000` 等 */
148
- timeoutMs?: number
149
- }
150
-
151
- export const ossUpload = async (
152
- rawFile: File | string | Buffer,
153
- botToken: string,
154
- isPrivate: 0 | 1 = 1,
155
- uploadOptions?: OssUploadOptions
156
- ) => {
157
- await getUserToken(botToken)
158
-
159
- const file = coerceOssFileInput(rawFile)
160
- const { content, fileName } = await toUploadContent(file)
161
- const data = await getStsToken(fileName, botToken, isPrivate)
162
- const mime = resolveMime(file, fileName)
163
- const onProgress = uploadOptions?.onProgress
164
-
165
- const options: OSS.Options = {
166
- // 从STS服务获取的临时访问密钥(AccessKey ID和AccessKey Secret)。
167
- accessKeyId: data.tempAccessKeyId,
168
- accessKeySecret: data.tempAccessKeySecret,
169
- // 从STS服务获取的安全令牌(SecurityToken)。
170
- stsToken: data.tempSecurityToken,
171
- // 填写Bucket名称。
172
- bucket: data.bucket,
173
- endpoint: data.endPoint,
174
- region: data.region,
175
- secure: true,
176
- cname: true,
177
- authorizationV4: true,
178
- timeout: uploadOptions?.timeoutMs ?? OSS_HTTP_TIMEOUT_MS
179
- }
180
-
181
- const client = new OSS(options)
182
-
183
- const name = `${data.uploadDir}${data.ossFileKey}`
184
-
185
- try {
186
- let objectResult: OSS.PutObjectResult | OSS.CompleteMultipartUploadResult
187
-
188
- const multipartUploadOptions: OSS.MultipartUploadOptions = {
189
- progress: (p: number) => {
190
- onProgress?.(p)
191
- },
192
- parallel: 4,
193
- partSize: MULTIPART_PART_SIZE,
194
- mime,
195
- /** 直链打开时优先内联展示,而非附件下载 */
196
- headers: {
197
- 'Content-Disposition': 'inline'
198
- }
199
- }
200
- objectResult = await client.multipartUpload(name, content, multipartUploadOptions)
201
-
202
- if (objectResult?.res?.status !== 200) {
203
- dcgLogger(`OSS 上传失败, ${objectResult?.res?.status}`)
204
- }
205
- const requestUrls = objectResult?.res?.requestUrls || []
206
- const url = requestUrls[0] || ''
207
- dcgLogger(`OSS 上传成功, ${isPrivate === 1 ? objectResult.name || url : url}`)
208
- return isPrivate === 1 ? objectResult.name || url : url
209
- } catch (error) {
210
- dcgLogger(`OSS 上传失败: ${error}`, 'error')
211
- }
212
- }
@@ -1,192 +0,0 @@
1
- import axios from 'axios'
2
- // @ts-ignore
3
- import md5 from 'md5'
4
- import type { IResponse } from '../types.js'
5
- import { getUserTokenCache } from './userInfo.js'
6
- import { getEffectiveMsgParams } from '../utils/params.js'
7
- import { ENV } from '../utils/constant.js'
8
- import { dcgLogger } from '../utils/log.js'
9
-
10
- export const apiUrlMap = {
11
- production: 'https://api-gateway.shuwenda.com',
12
- test: 'https://api-gateway.shuwenda.icu',
13
- develop: 'https://shenyu-dev.shuwenda.icu'
14
- }
15
-
16
- export const appKey = {
17
- production: '2A1C74D315CB4A01BF3DA8983695AFE2',
18
- test: '7374A073CCBD4C8CA84FAD33896F0B69',
19
- develop: '7374A073CCBD4C8CA84FAD33896F0B69'
20
- }
21
-
22
- export const signKey = {
23
- production: '34E9023008EA445AAE6CC075CC954F46',
24
- test: 'FE93D3322CB94E978CE95BD4AA2A37D7',
25
- develop: 'FE93D3322CB94E978CE95BD4AA2A37D7'
26
- }
27
-
28
- export const version = '1.0.0'
29
-
30
- /**
31
- * 根据 axios 请求配置生成等价 curl,便于复制给后端排查
32
- */
33
- function toCurl(config: {
34
- baseURL?: string
35
- url?: string
36
- method?: string
37
- headers?: Record<string, string | number | undefined>
38
- data?: unknown
39
- }): string {
40
- const base = config.baseURL ?? ''
41
- const path = config.url ?? ''
42
- const url = path.startsWith('http') ? path : `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
43
- const method = (config.method ?? 'GET').toUpperCase()
44
- const headers = config.headers ?? {}
45
- const parts = ['curl', '-X', method, `'${url}'`]
46
- for (const [k, v] of Object.entries(headers)) {
47
- if (v !== undefined && v !== '') {
48
- parts.push('-H', `'${k}: ${v}'`)
49
- }
50
- }
51
- if (method !== 'GET' && config.data !== undefined) {
52
- const body = typeof config.data === 'string' ? config.data : JSON.stringify(config.data)
53
- parts.push('-d', `'${body.replace(/'/g, "'\\''")}'`)
54
- }
55
- return parts.join(' ')
56
- }
57
-
58
- /**
59
- * 生成签名
60
- * @param {Object} body 请求体
61
- * @param {number} timestamp 时间戳
62
- * @param {string} path 请求地址
63
- * @param {'production' | 'test' | 'develop'} ENV 请求环境
64
- * @param {string} version 版本号
65
- * @returns {string} 大写 MD5 签名
66
- */
67
- export function getSignature(
68
- body: Record<string, unknown>,
69
- timestamp: number,
70
- path: string,
71
- ENV: 'production' | 'test' | 'develop',
72
- version: string = '1.0.0'
73
- ) {
74
- // 1. 构造 map
75
- const map = { timestamp, path, version, ...body }
76
- // 2. 按 key 进行自然排序
77
- const sortedKeys = Object.keys(map).sort()
78
- // 3. 拼接 key + value
79
- const signStr =
80
- sortedKeys
81
- .map((key) => {
82
- const val = map[key as keyof typeof map]
83
- return val === undefined ? '' : `${key}${typeof val === 'object' ? JSON.stringify(val) : val}`
84
- })
85
- .join('') + signKey[ENV]
86
- // 4. MD5 加密并转大写
87
- return md5(signStr).toUpperCase()
88
- }
89
-
90
- function buildHeaders(data: Record<string, unknown>, url: string, userToken?: string) {
91
- const timestamp = Date.now()
92
-
93
- const headers: Record<string, string | number> = {
94
- 'Content-Type': 'application/json',
95
- appKey: appKey[ENV],
96
- sign: getSignature(data, timestamp, url, ENV, version),
97
- timestamp,
98
- version
99
- }
100
-
101
- // 如果提供了 userToken,添加到 headers
102
- if (userToken) {
103
- headers.authorization = userToken
104
- }
105
-
106
- return headers
107
- }
108
-
109
- const axiosInstance = axios.create({
110
- baseURL: apiUrlMap[ENV],
111
- timeout: 10000
112
- })
113
-
114
- // 请求拦截器:自动注入 userToken
115
- axiosInstance.interceptors.request.use(
116
- (config) => {
117
- // 如果请求配置中已经有 authorization,优先使用
118
- if (config.headers?.authorization) {
119
- return config
120
- }
121
-
122
- // 从请求上下文中获取 botToken(需要在调用时设置)
123
- const botToken = (config as any).__botToken as string | undefined
124
- if (botToken) {
125
- const cachedToken = getUserTokenCache(botToken)
126
- if (cachedToken) {
127
- config.headers = config.headers || {}
128
- config.headers.authorization = cachedToken
129
- dcgLogger(`[request] auto-injected userToken from cache for botToken=${botToken.slice(0, 10)}...`)
130
- }
131
- }
132
-
133
- return config
134
- },
135
- (error) => {
136
- return Promise.reject(error)
137
- }
138
- )
139
-
140
- // 响应拦截器:打印 curl 便于调试
141
- axiosInstance.interceptors.response.use(
142
- (response) => {
143
- return response.data
144
- },
145
- (error) => {
146
- const config = error.config ?? {}
147
- const curl = toCurl(config)
148
- dcgLogger(`[request] curl for backend (failed request): ${curl}`)
149
- return Promise.reject(error)
150
- }
151
- )
152
-
153
- /**
154
- * POST 请求(支持可选的 userToken 和 botToken)
155
- * @param url 请求路径
156
- * @param data 请求体
157
- * @param options 可选配置
158
- * @param options.userToken 直接提供的 userToken(优先级最高)
159
- * @param options.botToken 用于从缓存获取 userToken 的 botToken
160
- */
161
- export function post<T = Record<string, unknown>, R = unknown>(
162
- url: string,
163
- data: T,
164
- options?: {
165
- userToken?: string
166
- botToken?: string
167
- }
168
- ): Promise<IResponse<R>> {
169
- const params = getEffectiveMsgParams() || { appId: 100 }
170
- const config: any = {
171
- method: 'POST',
172
- url,
173
- data: {
174
- ...data,
175
- _appId: params.appId
176
- },
177
- headers: buildHeaders(
178
- {
179
- ...data,
180
- _appId: params.appId
181
- } as Record<string, unknown>,
182
- url,
183
- options?.userToken
184
- )
185
- }
186
-
187
- // 将 botToken 附加到配置中,供请求拦截器使用
188
- if (options?.botToken) {
189
- config.__botToken = options.botToken
190
- }
191
- return axiosInstance.request(config)
192
- }
@@ -1,99 +0,0 @@
1
- /**
2
- * userToken 缓存管理模块
3
- * 负责维护 botToken -> userToken 的映射关系,支持自动过期
4
- */
5
-
6
- import { dcgLogger } from '../utils/log.js'
7
-
8
- // userToken 缓存配置
9
- const TOKEN_CACHE_DURATION = 60 * 60 * 1000 // 1小时
10
-
11
- type TokenCacheEntry = {
12
- token: string
13
- expiresAt: number
14
- }
15
-
16
- // 内存缓存:botToken -> { token, expiresAt }
17
- const tokenCache = new Map<string, TokenCacheEntry>()
18
-
19
- /**
20
- * 设置 userToken 缓存
21
- * @param botToken 机器人 token
22
- * @param userToken 用户 token
23
- */
24
- export function setUserTokenCache(botToken: string, userToken: string): void {
25
- const expiresAt = Date.now() + TOKEN_CACHE_DURATION
26
- tokenCache.set(botToken, { token: userToken, expiresAt })
27
- dcgLogger(
28
- `[token-cache] cached userToken for botToken=${botToken.slice(0, 10)}..., expires at ${new Date(expiresAt).toISOString()}`
29
- )
30
- }
31
-
32
- /**
33
- * 获取 userToken 缓存(自动检查过期)
34
- * @param botToken 机器人 token
35
- * @returns userToken 或 null(未找到或已过期)
36
- */
37
- export function getUserTokenCache(botToken: string): string | null {
38
- const entry = tokenCache.get(botToken)
39
- if (!entry) {
40
- dcgLogger(`[token-cache] no cache found for botToken=${botToken.slice(0, 10)}...`)
41
- return null
42
- }
43
-
44
- // 检查是否过期
45
- if (Date.now() >= entry.expiresAt) {
46
- dcgLogger(`[token-cache] cache expired for botToken=${botToken.slice(0, 10)}..., removing`)
47
- tokenCache.delete(botToken)
48
- return null
49
- }
50
-
51
- dcgLogger(
52
- `[token-cache] cache hit for botToken=${botToken.slice(0, 10)}..., valid until ${new Date(entry.expiresAt).toISOString()}`
53
- )
54
- return entry.token
55
- }
56
-
57
- /**
58
- * 清除指定 botToken 的缓存
59
- * @param botToken 机器人 token
60
- */
61
- export function clearUserTokenCache(botToken: string): void {
62
- tokenCache.delete(botToken)
63
- dcgLogger(`[token-cache] cleared cache for botToken=${botToken.slice(0, 10)}...`)
64
- }
65
-
66
- /**
67
- * 清除所有缓存
68
- */
69
- export function clearAllUserTokenCache(): void {
70
- tokenCache.clear()
71
- dcgLogger(`[token-cache] cleared all token cache`)
72
- }
73
-
74
- /**
75
- * 获取缓存统计信息(用于调试)
76
- */
77
- export function getTokenCacheStats(): {
78
- total: number
79
- valid: number
80
- expired: number
81
- } {
82
- const now = Date.now()
83
- let valid = 0
84
- let expired = 0
85
-
86
- for (const entry of tokenCache.values()) {
87
- if (now < entry.expiresAt) {
88
- valid++
89
- } else {
90
- expired++
91
- }
92
- }
93
-
94
- return {
95
- total: tokenCache.size,
96
- valid,
97
- expired
98
- }
99
- }
package/src/session.ts DELETED
@@ -1,19 +0,0 @@
1
- import { sendMessageToGateway } from './gateway/socket.js'
2
- import { getSessionKey } from './utils/global.js'
3
- import { dcgLogger } from './utils/log.js'
4
-
5
- interface TSession {
6
- agent_id: string
7
- session_id: string
8
- agent_clone_code?: string
9
- account_id: string
10
- }
11
-
12
- export const onRemoveSession = async ({ agent_id, session_id, agent_clone_code, account_id }: TSession) => {
13
- const sessionKey = getSessionKey({ agent_id, session_id, agent_clone_code }, account_id)
14
- if (!session_id) {
15
- dcgLogger('onRemoveSession: empty session_id', 'error')
16
- return
17
- }
18
- sendMessageToGateway(JSON.stringify({ method: 'sessions.delete', params: { key: sessionKey, deleteTranscript: true } }))
19
- }
@@ -1,154 +0,0 @@
1
- /**
2
- * 会话终止 / 抢占 / 网关 abort 的集中实现,便于单独调整策略与观测。
3
- * 与 bot 的 generation、流式分片序号、入站业务上下文分离,仅负责:本地 AbortController、流抑制标记、
4
- * 入站串行队尾、activeRunId、chat.abort 子会话→主会话。
5
- */
6
- import { sendGatewayRpc } from './gateway/socket.js'
7
- import { sendFinal } from './transport.js'
8
- import type { IMsgParams } from './types.js'
9
- import { dcgLogger } from './utils/log.js'
10
- import { getDescendantSessionKeysForRequester, resetSubagentStateForRequesterSession } from './tool.js'
11
-
12
- // --- 状态(仅本模块内修改,供 bot 通过下方 API 使用)---
13
-
14
- /** 当前会话最近一次 agent run 的 runId(网关 chat.abort 主会话时携带) */
15
- const activeRunIdBySessionKey = new Map<string, string>()
16
-
17
- /** dispatchReplyFromConfig 使用的 AbortSignal,用于真正掐断工具与模型 */
18
- const dispatchAbortBySessionKey = new Map<string, AbortController>()
19
-
20
- /** 打断后抑制 deliver / onPartialReply 继续下发 */
21
- const sessionStreamSuppressed = new Set<string>()
22
-
23
- /**
24
- * 同 sessionKey 入站串行队尾;与 Core session lane 对齐,避免并发 dispatch 过早返回。
25
- */
26
- const inboundTurnTailBySessionKey = new Map<string, Promise<void>>()
27
-
28
- // --- activeRunId(供 bot 在 onAgentRunStart / 错误收尾时同步)---
29
-
30
- export function setActiveRunIdForSession(sessionKey: string, runId: string): void {
31
- activeRunIdBySessionKey.set(sessionKey, runId)
32
- }
33
-
34
- export function clearActiveRunIdForSession(sessionKey: string): void {
35
- activeRunIdBySessionKey.delete(sessionKey)
36
- }
37
-
38
- // --- 流抑制 ---
39
-
40
- export function isSessionStreamSuppressed(sessionKey: string): boolean {
41
- return sessionStreamSuppressed.has(sessionKey)
42
- }
43
-
44
- export function clearSessionStreamSuppression(sessionKey: string): void {
45
- sessionStreamSuppressed.delete(sessionKey)
46
- }
47
-
48
- export function markSessionStreamSuppressed(sessionKey: string): void {
49
- sessionStreamSuppressed.add(sessionKey)
50
- }
51
-
52
- // --- 入站串行队列 ---
53
-
54
- /** /stop 入队前:掐断当前 in-process,并重置队尾,使本条 stop 不必等待已被 abort 的长 turn */
55
- export function preemptInboundQueueForStop(sessionKey: string): void {
56
- const c = dispatchAbortBySessionKey.get(sessionKey)
57
- if (c) {
58
- c.abort()
59
- dispatchAbortBySessionKey.delete(sessionKey)
60
- }
61
- inboundTurnTailBySessionKey.set(sessionKey, Promise.resolve())
62
- dcgLogger(`inbound queue: reset tail for /stop sessionKey=${sessionKey}`)
63
- }
64
-
65
- /** 将本轮入站处理挂到 sessionKey 队尾,保证同会话顺序执行 */
66
- export async function runInboundTurnSequenced(sessionKey: string, run: () => Promise<void>): Promise<void> {
67
- const prev = inboundTurnTailBySessionKey.get(sessionKey) ?? Promise.resolve()
68
- const next = prev.catch(() => {}).then(run)
69
- inboundTurnTailBySessionKey.set(sessionKey, next.catch(() => {}))
70
- await next
71
- }
72
-
73
- // --- 网关 abort ---
74
-
75
- /**
76
- * 终止网关上仍可能活跃的 run(子会话自深到浅,再主会话)。
77
- * supersede:仅当有子会话或 mainRunId 时发 RPC;interrupt:主会话始终 chat.abort。
78
- */
79
- export async function abortGatewayRunsForSession(sessionKey: string, reason: 'interrupt' | 'supersede'): Promise<void> {
80
- const prefix = reason === 'interrupt' ? 'interrupt' : 'supersede'
81
- const descendantKeys = getDescendantSessionKeysForRequester(sessionKey)
82
- const abortSubKeys = [...descendantKeys].reverse()
83
- const mainRunId = activeRunIdBySessionKey.get(sessionKey)
84
-
85
- if (reason === 'supersede' && abortSubKeys.length === 0 && !mainRunId) {
86
- return
87
- }
88
-
89
- if (abortSubKeys.length > 0) {
90
- dcgLogger(`${prefix}: chat.abort ${abortSubKeys.length} subagent session(s) (nested incl.)`)
91
- }
92
- for (const subKey of abortSubKeys) {
93
- try {
94
- await sendGatewayRpc({ method: 'chat.abort', params: { sessionKey: subKey } })
95
- } catch (e) {
96
- dcgLogger(`${prefix}: chat.abort subagent ${subKey}: ${String(e)}`, 'error')
97
- }
98
- }
99
-
100
- const shouldMainAbort = reason === 'interrupt' || Boolean(mainRunId)
101
- if (shouldMainAbort) {
102
- try {
103
- await sendGatewayRpc({
104
- method: 'chat.abort',
105
- params: mainRunId ? { sessionKey, runId: mainRunId } : { sessionKey }
106
- })
107
- } catch (e) {
108
- dcgLogger(`${prefix}: chat.abort main ${sessionKey}: ${String(e)}`, 'error')
109
- }
110
- }
111
-
112
- activeRunIdBySessionKey.delete(sessionKey)
113
- resetSubagentStateForRequesterSession(sessionKey)
114
- }
115
-
116
- // --- 本地 dispatch AbortController ---
117
-
118
- /**
119
- * 新一轮非 /stop 用户消息:清除流抑制、abort 上一轮 controller、网关 supersede,并安装新的 AbortController。
120
- * 调用方须在之前或之后自行 `streamChunkIdxBySessionKey.set(sessionKey, 0)`。
121
- */
122
- export async function beginSupersedingUserTurn(sessionKey: string): Promise<AbortController> {
123
- sessionStreamSuppressed.delete(sessionKey)
124
- dispatchAbortBySessionKey.get(sessionKey)?.abort()
125
- await abortGatewayRunsForSession(sessionKey, 'supersede')
126
- const ac = new AbortController()
127
- dispatchAbortBySessionKey.set(sessionKey, ac)
128
- return ac
129
- }
130
-
131
- /** try/finally 中:仅当仍是当前 controller 时从 map 移除 */
132
- export function releaseDispatchAbortIfCurrent(sessionKey: string, controller: AbortController | undefined): void {
133
- if (controller && dispatchAbortBySessionKey.get(sessionKey) === controller) {
134
- dispatchAbortBySessionKey.delete(sessionKey)
135
- }
136
- }
137
-
138
- /**
139
- * 用户发送 /stop:本地 abort、对「上一轮对话」发 abort final、标记流抑制、网关 interrupt。
140
- * 之后的 clearSentMedia、params、UI「已终止」等由 bot 继续处理。
141
- */
142
- export async function interruptLocalDispatchAndGateway(sessionKey: string, ctxForAbort: IMsgParams): Promise<void> {
143
- dcgLogger(`interrupt command: sessionKey=${sessionKey}`)
144
- const inFlight = dispatchAbortBySessionKey.get(sessionKey)
145
- if (inFlight) {
146
- dcgLogger(`interrupt: AbortController.abort() in-process run sessionKey=${sessionKey}`)
147
- inFlight.abort()
148
- dispatchAbortBySessionKey.delete(sessionKey)
149
- }
150
- const finalCtx = ctxForAbort.messageId?.trim() ? ctxForAbort : { ...ctxForAbort, messageId: `${Date.now()}` }
151
- sendFinal(finalCtx, 'abort')
152
- markSessionStreamSuppressed(sessionKey)
153
- await abortGatewayRunsForSession(sessionKey, 'interrupt')
154
- }