@dcrays/dcgchat-test 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/index.ts +31 -0
- package/openclaw.plugin.json +12 -41
- package/package.json +38 -11
- package/src/agent.ts +592 -0
- package/src/bot.ts +676 -0
- package/src/channel/index.ts +329 -0
- package/src/channel/outboundMedia.ts +144 -0
- package/src/channel/outboundTarget.ts +24 -0
- package/src/channel/uploadMediaUrl.ts +76 -0
- package/src/cron/message.ts +109 -0
- package/src/cron/params.ts +166 -0
- package/src/cron/toolGuard.ts +285 -0
- package/src/cron/types.ts +15 -0
- package/src/gateway/index.ts +430 -0
- package/src/gateway/security.ts +95 -0
- package/src/gateway/socket.ts +256 -0
- package/src/libs/ali-oss-6.23.0.tgz +0 -0
- package/src/libs/axios-1.13.6.tgz +0 -0
- package/src/libs/md5-2.3.0.tgz +0 -0
- package/src/libs/mime-types-3.0.2.tgz +0 -0
- package/src/libs/unzipper-0.12.3.tgz +0 -0
- package/src/libs/ws-8.19.0.tgz +0 -0
- package/src/monitor.ts +269 -0
- package/src/request/api.ts +80 -0
- package/src/request/oss.ts +200 -0
- package/src/request/request.ts +191 -0
- package/src/request/userInfo.ts +44 -0
- package/src/session.ts +28 -0
- package/src/skill.ts +114 -0
- package/src/tool.ts +625 -0
- package/src/tools/messageTool.ts +334 -0
- package/src/tools/toolCallGuard.ts +323 -0
- package/src/transport.ts +217 -0
- package/src/types.ts +139 -0
- package/src/utils/agentErrors.ts +23 -0
- package/src/utils/constant.ts +60 -0
- package/src/utils/env-config.ts +19 -0
- package/src/utils/formatLlmInputEvent.ts +48 -0
- package/src/utils/gatewayMsgHandler.ts +89 -0
- package/src/utils/global.ts +306 -0
- package/src/utils/inboundTurnState.ts +66 -0
- package/src/utils/log.ts +77 -0
- package/src/utils/mediaAttached.ts +244 -0
- package/src/utils/mediaEmitter.ts +54 -0
- package/src/utils/outboundAssistantText.ts +110 -0
- package/src/utils/params.ts +104 -0
- package/src/utils/passTxt.ts +4 -0
- package/src/utils/resolveRegisterConfig.ts +33 -0
- package/src/utils/searchFile.ts +228 -0
- package/src/utils/sessionState.ts +137 -0
- package/src/utils/sessionTermination.ts +228 -0
- package/src/utils/streamMerge.ts +150 -0
- package/src/utils/subagentRunMap.ts +71 -0
- package/src/utils/workspaceFilePaths.ts +18 -0
- package/src/utils/wsMessageHandler.ts +124 -0
- package/src/utils/zipExtract.ts +97 -0
- package/src/utils/zipPath.ts +24 -0
- package/index.js +0 -304
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReplyPayload } from 'openclaw/plugin-sdk'
|
|
2
|
+
import { mediaPathDedupeKey } from './global.js'
|
|
3
|
+
import { normalizeOutboundMediaPaths, sendDcgchatMedia } from '../channel/index.js'
|
|
4
|
+
|
|
5
|
+
export interface MediaEmitter {
|
|
6
|
+
emitFromPayload(payload: Partial<ReplyPayload>): Promise<boolean>
|
|
7
|
+
readonly sentKeys: ReadonlySet<string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CreateMediaEmitterOptions {
|
|
11
|
+
sessionKey: string
|
|
12
|
+
onEmit?: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveReplyMediaList(payload: Partial<ReplyPayload>): string[] {
|
|
16
|
+
if ('mediaUrls' in payload && Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0) {
|
|
17
|
+
return normalizeOutboundMediaPaths(payload.mediaUrls)
|
|
18
|
+
}
|
|
19
|
+
const single = 'mediaUrl' in payload ? payload.mediaUrl : null
|
|
20
|
+
return normalizeOutboundMediaPaths(single ?? null)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createMediaEmitter(options: CreateMediaEmitterOptions): MediaEmitter {
|
|
24
|
+
const { sessionKey, onEmit } = options
|
|
25
|
+
const sentKeys = new Set<string>()
|
|
26
|
+
|
|
27
|
+
const emit = async (mediaUrls: string[]): Promise<boolean> => {
|
|
28
|
+
if (mediaUrls.length === 0) return false
|
|
29
|
+
await sendDcgchatMedia({ sessionKey, mediaUrls, text: '' })
|
|
30
|
+
onEmit?.()
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const emitFromPayload = async (payload: Partial<ReplyPayload>): Promise<boolean> => {
|
|
35
|
+
const list = resolveReplyMediaList(payload)
|
|
36
|
+
const urls: string[] = []
|
|
37
|
+
for (const url of list) {
|
|
38
|
+
const key = mediaPathDedupeKey(url)
|
|
39
|
+
if (sentKeys.has(key)) continue
|
|
40
|
+
sentKeys.add(key)
|
|
41
|
+
urls.push(url)
|
|
42
|
+
}
|
|
43
|
+
if (urls.length === 0) return false
|
|
44
|
+
await emit(urls)
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
emitFromPayload,
|
|
50
|
+
get sentKeys(): ReadonlySet<string> {
|
|
51
|
+
return new Set(sentKeys)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { IMsgParams } from '../types.js'
|
|
2
|
+
import { sendChunk } from '../transport.js'
|
|
3
|
+
import { takeStreamChunkIdxAndAdvance } from './inboundTurnState.js'
|
|
4
|
+
|
|
5
|
+
type OutboundTextState = {
|
|
6
|
+
inboundGen: number
|
|
7
|
+
lastStreamSnapshot: string
|
|
8
|
+
/** 仅 onPartialReply 成功下发过正文时为 true,用于 deliver 兜底判断 */
|
|
9
|
+
hasStreamedAssistantText: boolean
|
|
10
|
+
hasEmittedAssistantText: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const stateBySessionKey = new Map<string, OutboundTextState>()
|
|
14
|
+
|
|
15
|
+
/** 每轮入站 dispatch 开始前初始化,与 inboundGen 绑定供调试对齐 */
|
|
16
|
+
export function resetOutboundTextState(sessionKey: string, inboundGen: number): void {
|
|
17
|
+
const k = sessionKey.trim()
|
|
18
|
+
if (!k) return
|
|
19
|
+
stateBySessionKey.set(k, {
|
|
20
|
+
inboundGen,
|
|
21
|
+
lastStreamSnapshot: '',
|
|
22
|
+
hasStreamedAssistantText: false,
|
|
23
|
+
hasEmittedAssistantText: false
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearOutboundTextState(sessionKey: string): void {
|
|
28
|
+
const k = sessionKey.trim()
|
|
29
|
+
if (!k) return
|
|
30
|
+
stateBySessionKey.delete(k)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getState(sessionKey: string): OutboundTextState | undefined {
|
|
34
|
+
const k = sessionKey.trim()
|
|
35
|
+
if (!k) return undefined
|
|
36
|
+
return stateBySessionKey.get(k)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 子 agent 等路径可能未走 bot reset;懒初始化保证 dcgchat_message 仍可出站且参与快照去重 */
|
|
40
|
+
function ensureState(sessionKey: string): OutboundTextState | undefined {
|
|
41
|
+
const k = sessionKey.trim()
|
|
42
|
+
if (!k) return undefined
|
|
43
|
+
let state = stateBySessionKey.get(k)
|
|
44
|
+
if (!state) {
|
|
45
|
+
state = {
|
|
46
|
+
inboundGen: 0,
|
|
47
|
+
lastStreamSnapshot: '',
|
|
48
|
+
hasStreamedAssistantText: false,
|
|
49
|
+
hasEmittedAssistantText: false
|
|
50
|
+
}
|
|
51
|
+
stateBySessionKey.set(k, state)
|
|
52
|
+
}
|
|
53
|
+
return state
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function outboundHasStreamedAssistantText(sessionKey: string): boolean {
|
|
57
|
+
return getState(sessionKey)?.hasStreamedAssistantText ?? false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function outboundHasEmittedAssistantText(sessionKey: string): boolean {
|
|
61
|
+
return getState(sessionKey)?.hasEmittedAssistantText ?? false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 仅重置快照(用于 `replace` 流式片段),保留 generation 与已下发标记 */
|
|
65
|
+
export function resetOutboundTextSnapshot(sessionKey: string): void {
|
|
66
|
+
const k = sessionKey.trim()
|
|
67
|
+
if (!k) return
|
|
68
|
+
const state = ensureState(k)
|
|
69
|
+
if (state) {
|
|
70
|
+
state.lastStreamSnapshot = ''
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 统一助手正文出站:bot 流式/deliver 与 dcgchat_message 共用快照增量,避免双发或漏发。
|
|
76
|
+
* @param markStreamed 为 true 时表示来自 onPartialReply,会置 hasStreamedAssistantText
|
|
77
|
+
*/
|
|
78
|
+
export function emitOutboundAssistantText(
|
|
79
|
+
sessionKey: string,
|
|
80
|
+
raw: string | undefined,
|
|
81
|
+
outboundCtx: IMsgParams,
|
|
82
|
+
options?: { markStreamed?: boolean; emitDelta?: (delta: string) => boolean | void }
|
|
83
|
+
): boolean {
|
|
84
|
+
if (!raw) return false
|
|
85
|
+
const state = ensureState(sessionKey)
|
|
86
|
+
if (!state) return false
|
|
87
|
+
|
|
88
|
+
const t = raw
|
|
89
|
+
let delta = ''
|
|
90
|
+
if (t.startsWith(state.lastStreamSnapshot)) {
|
|
91
|
+
delta = t.slice(state.lastStreamSnapshot.length)
|
|
92
|
+
state.lastStreamSnapshot = t
|
|
93
|
+
} else if (state.lastStreamSnapshot.startsWith(t)) {
|
|
94
|
+
// 快照缩短(模型修订等):不重复下发
|
|
95
|
+
} else {
|
|
96
|
+
delta = t
|
|
97
|
+
state.lastStreamSnapshot = t
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!delta.trim()) return false
|
|
101
|
+
|
|
102
|
+
const emitted =
|
|
103
|
+
options?.emitDelta != null ? options.emitDelta(delta) !== false : sendChunk(delta, outboundCtx, takeStreamChunkIdxAndAdvance(sessionKey))
|
|
104
|
+
if (!emitted) return false
|
|
105
|
+
state.hasEmittedAssistantText = true
|
|
106
|
+
if (options?.markStreamed) {
|
|
107
|
+
state.hasStreamedAssistantText = true
|
|
108
|
+
}
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CHANNEL_ID } from './constant.js'
|
|
2
|
+
import { getOpenClawConfig } from './global.js'
|
|
3
|
+
import type { DcgchatConfig, IMsgParams } from '../types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* map key 是 session_key,value 为该会话下已 merge 后的消息参数。
|
|
7
|
+
*/
|
|
8
|
+
const paramsMessageMap = new Map<string, IMsgParams>()
|
|
9
|
+
|
|
10
|
+
/** 从 OpenClaw 配置读取当前 channel 的基础参数(唯一来源,供 transport / resolve 等复用) */
|
|
11
|
+
export function getParamsDefaults(): IMsgParams {
|
|
12
|
+
const ch = (getOpenClawConfig()?.channels?.[CHANNEL_ID] as DcgchatConfig | undefined) ?? {}
|
|
13
|
+
return {
|
|
14
|
+
userId: Number(ch.userId ?? 0),
|
|
15
|
+
botToken: ch.botToken ?? '',
|
|
16
|
+
sessionId: '',
|
|
17
|
+
messageId: '',
|
|
18
|
+
domainId: String(ch.domainId ?? '1000'),
|
|
19
|
+
appId: String(ch.appId ?? '100'),
|
|
20
|
+
botId: '',
|
|
21
|
+
agentId: '',
|
|
22
|
+
sessionKey: ''
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveParamsMessage(params: Partial<IMsgParams>): IMsgParams {
|
|
27
|
+
const defaults = getParamsDefaults()
|
|
28
|
+
return {
|
|
29
|
+
userId: Number(params.userId ?? defaults.userId),
|
|
30
|
+
botToken: params.botToken ?? defaults.botToken,
|
|
31
|
+
sessionId: params.sessionId ?? defaults.sessionId,
|
|
32
|
+
messageId: params.messageId ?? defaults.messageId,
|
|
33
|
+
domainId: String(params.domainId ?? defaults.domainId),
|
|
34
|
+
appId: String(params.appId ?? defaults.appId),
|
|
35
|
+
botId: params.botId ?? defaults.botId,
|
|
36
|
+
agentId: params.agentId ?? defaults.agentId,
|
|
37
|
+
sessionKey: params.sessionKey ?? defaults.sessionKey
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 统一取值入口:显式 sessionKey,或回落到当前会话;再与配置缺省 merge,保证字段完整。
|
|
43
|
+
*/
|
|
44
|
+
export function getEffectiveMsgParams(sessionKey?: string): IMsgParams {
|
|
45
|
+
const stored = sessionKey ? paramsMessageMap.get(sessionKey) : undefined
|
|
46
|
+
return stored ? resolveParamsMessage(stored) : getParamsDefaults()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Agent `dcgchat_message` / 出站 `target` 应为 `dcgSessionKey`(如 `agent:main:mobook:direct:...`)。
|
|
51
|
+
* `setParamsMessage` 的 key 与此一致;查不到 map 时回落到配置缺省(无会话级 messageId/sessionId)。
|
|
52
|
+
*/
|
|
53
|
+
export function getOutboundMsgParams(preferredKey: string): IMsgParams {
|
|
54
|
+
const k = preferredKey?.trim()
|
|
55
|
+
if (k && paramsMessageMap.has(k)) {
|
|
56
|
+
return getEffectiveMsgParams(k)
|
|
57
|
+
}
|
|
58
|
+
return getEffectiveMsgParams()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function setParamsMessage(sessionKey: string, params: Partial<IMsgParams>) {
|
|
62
|
+
if (!sessionKey) return
|
|
63
|
+
const previous = paramsMessageMap.get(sessionKey)
|
|
64
|
+
const base = previous ? resolveParamsMessage(previous) : getParamsDefaults()
|
|
65
|
+
paramsMessageMap.set(sessionKey, resolveParamsMessage({ ...base, ...params, sessionKey }))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getParamsMessage(sessionKey: string): IMsgParams | undefined {
|
|
69
|
+
return paramsMessageMap.get(sessionKey)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function clearParamsMessage(sessionKey: string): void {
|
|
73
|
+
const k = sessionKey?.trim()
|
|
74
|
+
if (!k) return
|
|
75
|
+
paramsMessageMap.delete(k)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 用于网关重启时持久化/恢复的参数模块状态快照 */
|
|
79
|
+
export type ParamsStateSnapshot = {
|
|
80
|
+
paramsMessageMap: Array<[string, Partial<IMsgParams>]>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function snapshotMsgParams(params: IMsgParams): Partial<IMsgParams> {
|
|
84
|
+
const { botToken: _botToken, ...rest } = params
|
|
85
|
+
return rest
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function snapshotParamsState(sessionKeys?: ReadonlySet<string>): ParamsStateSnapshot {
|
|
89
|
+
return {
|
|
90
|
+
paramsMessageMap: [...paramsMessageMap.entries()]
|
|
91
|
+
.filter(([k]) => !sessionKeys || sessionKeys.has(k))
|
|
92
|
+
.map(([k, v]) => [k, snapshotMsgParams(v)])
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function restoreParamsState(snap: ParamsStateSnapshot | null | undefined): void {
|
|
97
|
+
if (!snap) return
|
|
98
|
+
paramsMessageMap.clear()
|
|
99
|
+
for (const [k, v] of snap.paramsMessageMap ?? []) {
|
|
100
|
+
if (typeof k === 'string' && k && v && typeof v === 'object') {
|
|
101
|
+
paramsMessageMap.set(k, resolveParamsMessage(v))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk'
|
|
2
|
+
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'
|
|
3
|
+
import { CHANNEL_ID } from './constant.js'
|
|
4
|
+
|
|
5
|
+
function isEmptyObject(value: unknown): boolean {
|
|
6
|
+
return value !== null && typeof value === 'object' && Object.keys(value as object).length === 0
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function openclawConfigLooksUsable(cfg: OpenClawConfig | undefined | null): cfg is OpenClawConfig {
|
|
10
|
+
if (!cfg || typeof cfg !== 'object' || isEmptyObject(cfg)) return false
|
|
11
|
+
const ch = cfg.channels
|
|
12
|
+
if (!ch || typeof ch !== 'object' || Object.keys(ch).length === 0) return false
|
|
13
|
+
return ch[CHANNEL_ID as keyof typeof ch] != null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `registerFull` 里 OpenClaw 传入的 `api.config` 在部分加载路径下会是 `{}` 或缺本通道段落,
|
|
18
|
+
* 应改用 `api.runtime.config.loadConfig()` 读盘上完整配置,避免 `getOpenClawConfig()` 不可靠。
|
|
19
|
+
*
|
|
20
|
+
* (`setRuntime` 在 `registerFull` 之前已执行,故此处可安全使用 `api.runtime`。)
|
|
21
|
+
*/
|
|
22
|
+
export function resolveOpenClawConfigAtRegister(api: OpenClawPluginApi): OpenClawConfig {
|
|
23
|
+
const snap = api.config as OpenClawConfig | undefined
|
|
24
|
+
if (openclawConfigLooksUsable(snap)) {
|
|
25
|
+
return snap
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const full = api.runtime.config.loadConfig() as OpenClawConfig
|
|
29
|
+
return structuredClone(full)
|
|
30
|
+
} catch {
|
|
31
|
+
return (snap ?? {}) as OpenClawConfig
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { dcgLogger } from './log.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 从文本中提取 /mobook 目录下的文件
|
|
5
|
+
* @param {string} text
|
|
6
|
+
* @returns {string[]}
|
|
7
|
+
*/
|
|
8
|
+
const EXT_LIST = [
|
|
9
|
+
// 文档类
|
|
10
|
+
'doc',
|
|
11
|
+
'docx',
|
|
12
|
+
'xls',
|
|
13
|
+
'xlsx',
|
|
14
|
+
'ppt',
|
|
15
|
+
'pptx',
|
|
16
|
+
'pdf',
|
|
17
|
+
'txt',
|
|
18
|
+
'rtf',
|
|
19
|
+
'odt',
|
|
20
|
+
|
|
21
|
+
// 数据/开发
|
|
22
|
+
'json',
|
|
23
|
+
'xml',
|
|
24
|
+
'csv',
|
|
25
|
+
'yaml',
|
|
26
|
+
'yml',
|
|
27
|
+
|
|
28
|
+
// 前端/文本
|
|
29
|
+
'html',
|
|
30
|
+
'htm',
|
|
31
|
+
'md',
|
|
32
|
+
'markdown',
|
|
33
|
+
'css',
|
|
34
|
+
'js',
|
|
35
|
+
'ts',
|
|
36
|
+
|
|
37
|
+
// 图片
|
|
38
|
+
'png',
|
|
39
|
+
'jpg',
|
|
40
|
+
'jpeg',
|
|
41
|
+
'gif',
|
|
42
|
+
'bmp',
|
|
43
|
+
'webp',
|
|
44
|
+
'svg',
|
|
45
|
+
'ico',
|
|
46
|
+
'tiff',
|
|
47
|
+
|
|
48
|
+
// 音频
|
|
49
|
+
'mp3',
|
|
50
|
+
'wav',
|
|
51
|
+
'ogg',
|
|
52
|
+
'aac',
|
|
53
|
+
'flac',
|
|
54
|
+
'm4a',
|
|
55
|
+
|
|
56
|
+
// 视频
|
|
57
|
+
'mp4',
|
|
58
|
+
'avi',
|
|
59
|
+
'mov',
|
|
60
|
+
'wmv',
|
|
61
|
+
'flv',
|
|
62
|
+
'mkv',
|
|
63
|
+
'webm',
|
|
64
|
+
|
|
65
|
+
// 压缩包
|
|
66
|
+
'zip',
|
|
67
|
+
'rar',
|
|
68
|
+
'7z',
|
|
69
|
+
'tar',
|
|
70
|
+
'gz',
|
|
71
|
+
'bz2',
|
|
72
|
+
'xz',
|
|
73
|
+
|
|
74
|
+
// 可执行/程序
|
|
75
|
+
'exe',
|
|
76
|
+
'dmg',
|
|
77
|
+
'pkg',
|
|
78
|
+
'apk',
|
|
79
|
+
'ipa',
|
|
80
|
+
|
|
81
|
+
// 其他常见
|
|
82
|
+
'log',
|
|
83
|
+
'dat',
|
|
84
|
+
'bin'
|
|
85
|
+
]
|
|
86
|
+
/**
|
|
87
|
+
* 扩展名按长度降序,用于正则交替,避免 xls 抢先匹配 xlsx、htm 抢先匹配 html 等
|
|
88
|
+
*/
|
|
89
|
+
const EXT_SORTED_FOR_REGEX = [...EXT_LIST].sort((a, b) => b.length - a.length)
|
|
90
|
+
|
|
91
|
+
/** 去除控制符、零宽字符等常见脏值 */
|
|
92
|
+
function stripMobookNoise(s: string) {
|
|
93
|
+
return s.replace(/[\u0000-\u001F\u007F\u200B-\u200D\u200E\u200F\uFEFF]/g, '')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 从文本中扫描 `.../mobook/...` 或 `...\mobook\...` 片段,按最长后缀匹配合法扩展名(兜底)
|
|
98
|
+
*/
|
|
99
|
+
function collectMobookPathsAfterNeedle(text: string, lower: string, needle: string, result: Set<string>): void {
|
|
100
|
+
let from = 0
|
|
101
|
+
while (from < text.length) {
|
|
102
|
+
const i = lower.indexOf(needle, from)
|
|
103
|
+
if (i < 0) break
|
|
104
|
+
const start = i + needle.length
|
|
105
|
+
const tail = text.slice(start)
|
|
106
|
+
const seg = tail.match(/^([^\s\]\)'"}\u3002,,]+)/)
|
|
107
|
+
if (!seg) {
|
|
108
|
+
from = start + 1
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
let raw = stripMobookNoise(seg[1]).trim()
|
|
112
|
+
if (!raw || raw.includes('\uFFFD')) {
|
|
113
|
+
from = start + 1
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
const low = raw.toLowerCase()
|
|
117
|
+
let matchedExt: string | undefined
|
|
118
|
+
for (const ext of EXT_SORTED_FOR_REGEX) {
|
|
119
|
+
if (low.endsWith(`.${ext}`)) {
|
|
120
|
+
matchedExt = ext
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!matchedExt) {
|
|
125
|
+
from = start + 1
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const base = raw.slice(0, -(matchedExt.length + 1))
|
|
129
|
+
const fileName = `${base}.${matchedExt}`
|
|
130
|
+
if (isValidFileName(fileName)) {
|
|
131
|
+
result.add(normalizePath(`/mobook/${fileName}`))
|
|
132
|
+
}
|
|
133
|
+
from = start + 1
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function collectMobookPathsByScan(text: string, result: Set<string>): void {
|
|
138
|
+
const lower = text.toLowerCase()
|
|
139
|
+
collectMobookPathsAfterNeedle(text, lower, '/mobook/', result)
|
|
140
|
+
collectMobookPathsAfterNeedle(text, lower, '\\mobook\\', result)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function extractMobookFiles(text = '') {
|
|
144
|
+
if (typeof text !== 'string' || !text.trim()) return []
|
|
145
|
+
// 全角冒号(中文输入常见)→ 半角,便于匹配 c:\mobook\
|
|
146
|
+
text = text.replace(/\uFF1A/g, ':')
|
|
147
|
+
const result = new Set<string>()
|
|
148
|
+
// ✅ 扩展名(必须长扩展名优先,见 EXT_SORTED_FOR_REGEX)
|
|
149
|
+
const EXT = `(${EXT_SORTED_FOR_REGEX.join('|')})`
|
|
150
|
+
// ✅ 文件名字符(增强:支持中文、符号)
|
|
151
|
+
const FILE_NAME = `[\\w\\u4e00-\\u9fa5::《》()()\\-\\s]+?`
|
|
152
|
+
try {
|
|
153
|
+
// 1️⃣ `xxx.xxx`
|
|
154
|
+
const backtickReg = new RegExp(`\`([^\\\`]+?\\.${EXT})\``, 'gi')
|
|
155
|
+
;(text.match(backtickReg) || []).forEach((item) => {
|
|
156
|
+
const name = item.replace(/`/g, '').trim()
|
|
157
|
+
if (isValidFileName(name)) {
|
|
158
|
+
result.add(`/mobook/${name}`)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
// 2️⃣ /mobook/xxx.xxx
|
|
162
|
+
const fullPathReg = new RegExp(`/mobook/${FILE_NAME}\\.${EXT}`, 'gi')
|
|
163
|
+
;(text.match(fullPathReg) || []).forEach((p) => {
|
|
164
|
+
result.add(normalizePath(p))
|
|
165
|
+
})
|
|
166
|
+
// 2️⃣b Windows 实际保存路径:C:\mobook\xxx、c:/mobook/xxx、\mobook\xxx(模型常写反斜杠,原先无法识别)
|
|
167
|
+
const winMobookReg = new RegExp(`(?:[a-zA-Z]:)?[/\\\\]mobook[/\\\\]${FILE_NAME}\\.${EXT}`, 'gi')
|
|
168
|
+
;(text.match(winMobookReg) || []).forEach((full) => {
|
|
169
|
+
const name = full.replace(/^(?:[a-zA-Z]:)?[/\\\\]mobook[/\\\\]/i, '').trim()
|
|
170
|
+
if (isValidFileName(name)) {
|
|
171
|
+
result.add(normalizePath(`/mobook/${name}`))
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
// 3️⃣ mobook下的 xxx.xxx
|
|
175
|
+
const inlineReg = new RegExp(`mobook下的\\s*(${FILE_NAME}\\.${EXT})`, 'gi')
|
|
176
|
+
;(text.match(inlineReg) || []).forEach((item) => {
|
|
177
|
+
const match = item.match(new RegExp(`${FILE_NAME}\\.${EXT}`, 'i'))
|
|
178
|
+
if (match && isValidFileName(match[0])) {
|
|
179
|
+
result.add(`/mobook/${match[0].trim()}`)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
// 🆕 4️⃣ **xxx.xxx**
|
|
183
|
+
const boldReg = new RegExp(`\\*\\*(${FILE_NAME}\\.${EXT})\\*\\*`, 'gi')
|
|
184
|
+
;(text.match(boldReg) || []).forEach((item) => {
|
|
185
|
+
const name = item.replace(/\*\*/g, '').trim()
|
|
186
|
+
if (isValidFileName(name)) {
|
|
187
|
+
result.add(`/mobook/${name}`)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
// 🆕 5️⃣ xxx.xxx (123字节)
|
|
191
|
+
const looseReg = new RegExp(`(${FILE_NAME}\\.${EXT})\\s*\\(`, 'gi')
|
|
192
|
+
;(text.match(looseReg) || []).forEach((item) => {
|
|
193
|
+
const name = item.replace(/\s*\(.+$/, '').trim()
|
|
194
|
+
if (isValidFileName(name)) {
|
|
195
|
+
result.add(`/mobook/${name}`)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
// 6️⃣ 兜底:绝对路径等 `.../mobook/<文件名>.<扩展名>` + 最长后缀匹配 + 去脏字符
|
|
199
|
+
collectMobookPathsByScan(text, result)
|
|
200
|
+
} catch (e) {
|
|
201
|
+
dcgLogger(`extractMobookFiles error:${e}`, 'error')
|
|
202
|
+
}
|
|
203
|
+
return [...result]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 校验文件名是否合法(避免脏数据)
|
|
208
|
+
*/
|
|
209
|
+
function isValidFileName(name: string) {
|
|
210
|
+
if (!name) return false
|
|
211
|
+
const cleaned = stripMobookNoise(name).trim()
|
|
212
|
+
if (!cleaned) return false
|
|
213
|
+
if (cleaned.includes('\uFFFD')) return false
|
|
214
|
+
// 过滤异常字符
|
|
215
|
+
if (/[\/\\<>:"|?*]/.test(cleaned)) return false
|
|
216
|
+
// 长度限制(防止异常长字符串)
|
|
217
|
+
if (cleaned.length > 200) return false
|
|
218
|
+
return true
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 规范路径(去重用)
|
|
223
|
+
*/
|
|
224
|
+
function normalizePath(path: string) {
|
|
225
|
+
return path
|
|
226
|
+
.replace(/\/+/g, '/') // 多斜杠 → 单斜杠
|
|
227
|
+
.replace(/\/$/, '') // 去掉结尾 /
|
|
228
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { getWorkspaceDir, snapshotGlobalSessionState, restoreGlobalSessionState, type GlobalSessionStateSnapshot } from './global.js'
|
|
4
|
+
import { snapshotParamsState, restoreParamsState, type ParamsStateSnapshot } from './params.js'
|
|
5
|
+
import { dcgLogger } from './log.js'
|
|
6
|
+
|
|
7
|
+
/** 子 agent 跟踪状态(由 tool.ts 侧维护)的可序列化快照 */
|
|
8
|
+
export type SubagentTrackingSnapshot = {
|
|
9
|
+
activeSubagentRunIdsByRequester: Array<[string, string[]]>
|
|
10
|
+
requesterByChildSessionKey: Array<[string, string]>
|
|
11
|
+
runIdByChildSessionKey: Array<[string, string]>
|
|
12
|
+
requesterSessionKeyByRunId?: Array<[string, string]>
|
|
13
|
+
childSessionKeyByRunId?: Array<[string, string]>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 完整的会话相关内存状态快照 */
|
|
17
|
+
export type SessionStateFile = {
|
|
18
|
+
version: number
|
|
19
|
+
savedAt: number
|
|
20
|
+
params: ParamsStateSnapshot
|
|
21
|
+
global: GlobalSessionStateSnapshot
|
|
22
|
+
subagent: SubagentTrackingSnapshot
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SESSION_STATE_FILE_VERSION = 1
|
|
26
|
+
const SESSION_STATE_FILE_NAME = 'session-state.json'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 将快照落地到 `${workspace}/../session-state.json`,与 cron 走同级约定,
|
|
30
|
+
* 避免运行时污染 workspace 文件树。workspace 未初始化时直接跳过。
|
|
31
|
+
*/
|
|
32
|
+
function getSessionStateFilePath(): string | null {
|
|
33
|
+
const ws = getWorkspaceDir()
|
|
34
|
+
if (!ws) return null
|
|
35
|
+
return path.join(path.dirname(ws), SESSION_STATE_FILE_NAME)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function writeSessionStateFile(subagent: SubagentTrackingSnapshot): void {
|
|
39
|
+
const filePath = getSessionStateFilePath()
|
|
40
|
+
if (!filePath) {
|
|
41
|
+
dcgLogger('writeSessionStateFile: workspace dir not initialized, skip', 'error')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const global = snapshotGlobalSessionState()
|
|
45
|
+
const runningSessionKeys = new Set(global.msgStatusBySessionKey.map(([k]) => k))
|
|
46
|
+
if (runningSessionKeys.size === 0) {
|
|
47
|
+
removeSessionStateFile()
|
|
48
|
+
dcgLogger('session state skipped: no running sessions')
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
const payload: SessionStateFile = {
|
|
52
|
+
version: SESSION_STATE_FILE_VERSION,
|
|
53
|
+
savedAt: Date.now(),
|
|
54
|
+
params: snapshotParamsState(runningSessionKeys),
|
|
55
|
+
global,
|
|
56
|
+
subagent
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const dir = path.dirname(filePath)
|
|
60
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
61
|
+
const tmp = `${filePath}.tmp`
|
|
62
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
63
|
+
fs.renameSync(tmp, filePath)
|
|
64
|
+
dcgLogger(`session state persisted: ${filePath}`)
|
|
65
|
+
} catch (e) {
|
|
66
|
+
dcgLogger(`writeSessionStateFile failed: ${String(e)}`, 'error')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readSessionStateFile(): SessionStateFile | null {
|
|
71
|
+
const filePath = getSessionStateFilePath()
|
|
72
|
+
if (!filePath || !fs.existsSync(filePath)) return null
|
|
73
|
+
try {
|
|
74
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
75
|
+
const data = JSON.parse(raw) as SessionStateFile
|
|
76
|
+
if (!data || typeof data !== 'object') return null
|
|
77
|
+
if (data.version !== SESSION_STATE_FILE_VERSION) {
|
|
78
|
+
dcgLogger(`readSessionStateFile: version mismatch ${data.version} vs ${SESSION_STATE_FILE_VERSION}`, 'error')
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
return data
|
|
82
|
+
} catch (e) {
|
|
83
|
+
dcgLogger(`readSessionStateFile failed: ${String(e)}`, 'error')
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 恢复 params/global 两个模块的内存状态。subagent 跟踪由 tool.ts 侧自行 restore(避免循环依赖)。
|
|
90
|
+
*/
|
|
91
|
+
export function restoreParamsAndGlobal(snap: SessionStateFile | null): void {
|
|
92
|
+
if (!snap) return
|
|
93
|
+
restoreParamsState(snap.params)
|
|
94
|
+
restoreGlobalSessionState(snap.global)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function removeSessionStateFile(): void {
|
|
98
|
+
const filePath = getSessionStateFilePath()
|
|
99
|
+
if (!filePath) return
|
|
100
|
+
try {
|
|
101
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
|
102
|
+
} catch (e) {
|
|
103
|
+
dcgLogger(`removeSessionStateFile failed: ${String(e)}`, 'error')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function addSessionKeyToSet(out: Set<string>, k: string | undefined) {
|
|
108
|
+
const t = k?.trim()
|
|
109
|
+
if (t) out.add(t)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 从持久化快照中收集所有与「会话」相关的 dcg sessionKey,供网关恢复后 `chat.send` 等用。
|
|
114
|
+
* (不含 `sentMediaKeys` 外层 key:那是 messageId,不是 sessionKey。)
|
|
115
|
+
*/
|
|
116
|
+
export function collectSessionKeysFromSnapshot(snap: SessionStateFile | null | undefined): string[] {
|
|
117
|
+
if (!snap) return []
|
|
118
|
+
const set = new Set<string>()
|
|
119
|
+
for (const [k, status] of snap.global?.msgStatusBySessionKey ?? []) {
|
|
120
|
+
if (status === 'running') addSessionKeyToSet(set, k)
|
|
121
|
+
}
|
|
122
|
+
return [...set]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 网关恢复时只恢复顶层会话,避免对旧子会话发送「继续执行」但本地不恢复 runId 映射,
|
|
127
|
+
* 导致后续子 agent 流式输出无法回到主消息。
|
|
128
|
+
*/
|
|
129
|
+
export function collectRootSessionKeysFromSnapshot(snap: SessionStateFile | null | undefined): string[] {
|
|
130
|
+
if (!snap) return []
|
|
131
|
+
const childKeys = new Set<string>()
|
|
132
|
+
for (const [child] of snap.subagent?.requesterByChildSessionKey ?? []) {
|
|
133
|
+
const k = child?.trim()
|
|
134
|
+
if (k) childKeys.add(k)
|
|
135
|
+
}
|
|
136
|
+
return collectSessionKeysFromSnapshot(snap).filter((k) => !childKeys.has(k))
|
|
137
|
+
}
|