@dcrays/dcgchat-test 0.6.8 → 0.6.12
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 +35 -0
- package/openclaw.plugin.json +12 -41
- package/package.json +35 -8
- package/src/agent.ts +568 -0
- package/src/bot.ts +646 -0
- package/src/channel/index.ts +329 -0
- package/src/channel/outboundMedia.ts +144 -0
- package/src/channel/outboundTarget.ts +62 -0
- package/src/channel/uploadMediaUrl.ts +76 -0
- package/src/cron/message.ts +114 -0
- package/src/cron/params.ts +164 -0
- package/src/cron/toolGuard.ts +466 -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 +603 -0
- package/src/tools/messageTool.ts +334 -0
- package/src/tools/toolCallGuard.ts +327 -0
- package/src/transport.ts +217 -0
- package/src/types.ts +139 -0
- package/src/utils/agentErrors.ts +116 -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 +147 -0
- package/src/utils/global.ts +309 -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 +117 -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/undiciFetchInterceptor.ts +346 -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 -302
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { buildAgentMediaPayload } from 'openclaw/plugin-sdk/agent-media-payload'
|
|
4
|
+
import { getDcgchatRuntime } from './global.js'
|
|
5
|
+
import { dcgLogger } from './log.js'
|
|
6
|
+
import { generateSignUrl } from '../request/api.js'
|
|
7
|
+
|
|
8
|
+
export type DcgchatInboundMediaInfo = {
|
|
9
|
+
path: string
|
|
10
|
+
fileName: string
|
|
11
|
+
contentType: string
|
|
12
|
+
placeholder: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type DcgchatInboundFileRef = { name: string; url: string }
|
|
16
|
+
|
|
17
|
+
export const DCGCHAT_INBOUND_MEDIA_MAX_BYTES = 500 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
const INBOUND_FILE_URL_KEYS = ['url', 'fileUrl', 'file_url', 'link', 'href', 'path', 'ossKey', 'oss_key', 'key'] as const
|
|
20
|
+
const INBOUND_FILE_NAME_KEYS = ['name', 'fileName', 'file_name', 'title', 'originalName', 'original_filename'] as const
|
|
21
|
+
const INBOUND_FILE_ARRAY_KEYS = [
|
|
22
|
+
'files',
|
|
23
|
+
'file_list',
|
|
24
|
+
'fileList',
|
|
25
|
+
'attachments',
|
|
26
|
+
'media_files',
|
|
27
|
+
'mediaFiles',
|
|
28
|
+
'resources',
|
|
29
|
+
'uploads'
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
function firstNonEmptyString(o: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
|
33
|
+
for (const k of keys) {
|
|
34
|
+
const v = o[k]
|
|
35
|
+
if (typeof v === 'string' && v.trim()) return v.trim()
|
|
36
|
+
}
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fallbackFileNameFromUrl(url: string): string {
|
|
41
|
+
try {
|
|
42
|
+
return path.basename(new URL(url).pathname) || 'file'
|
|
43
|
+
} catch {
|
|
44
|
+
const base = url.split(/[\\/]/).pop() ?? url
|
|
45
|
+
return path.basename(base.split('?')[0] || base) || 'file'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function coerceInboundFileItem(item: unknown): DcgchatInboundFileRef | null {
|
|
50
|
+
if (item == null) return null
|
|
51
|
+
if (typeof item === 'string') {
|
|
52
|
+
const t = item.trim()
|
|
53
|
+
if (!t) return null
|
|
54
|
+
return { url: t, name: fallbackFileNameFromUrl(t) }
|
|
55
|
+
}
|
|
56
|
+
if (typeof item !== 'object') return null
|
|
57
|
+
const o = item as Record<string, unknown>
|
|
58
|
+
const url = firstNonEmptyString(o, INBOUND_FILE_URL_KEYS)
|
|
59
|
+
if (!url) return null
|
|
60
|
+
const name = firstNonEmptyString(o, INBOUND_FILE_NAME_KEYS) ?? ''
|
|
61
|
+
return { url, name: name || fallbackFileNameFromUrl(url) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function asInboundFileArray(raw: unknown): unknown[] {
|
|
65
|
+
if (Array.isArray(raw)) return raw
|
|
66
|
+
if (typeof raw === 'string') {
|
|
67
|
+
const t = raw.trim()
|
|
68
|
+
if (!t) return []
|
|
69
|
+
try {
|
|
70
|
+
const p = JSON.parse(t) as unknown
|
|
71
|
+
return Array.isArray(p) ? p : []
|
|
72
|
+
} catch {
|
|
73
|
+
return []
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 合并服务端可能使用的多种附件字段与对象形状,避免仅认 `files`+`url` 时视频未入队 */
|
|
80
|
+
export function normalizeInboundFileList(content: Record<string, unknown>): DcgchatInboundFileRef[] {
|
|
81
|
+
const seen = new Set<string>()
|
|
82
|
+
const out: DcgchatInboundFileRef[] = []
|
|
83
|
+
for (const key of INBOUND_FILE_ARRAY_KEYS) {
|
|
84
|
+
for (const item of asInboundFileArray(content[key])) {
|
|
85
|
+
const f = coerceInboundFileItem(item)
|
|
86
|
+
if (f && !seen.has(f.url)) {
|
|
87
|
+
seen.add(f.url)
|
|
88
|
+
out.push(f)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const key of ['file', 'attachment', 'media'] as const) {
|
|
93
|
+
const f = coerceInboundFileItem(content[key])
|
|
94
|
+
if (f && !seen.has(f.url)) {
|
|
95
|
+
seen.add(f.url)
|
|
96
|
+
out.push(f)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 供模型侧识别媒体槽位类型 */
|
|
103
|
+
export function inferPlaceholderFromContentType(contentType: string): string {
|
|
104
|
+
const ct = (contentType || '').toLowerCase()
|
|
105
|
+
if (ct.startsWith('image/')) return '<media:image>'
|
|
106
|
+
if (ct.startsWith('video/')) return '<media:video>'
|
|
107
|
+
if (ct.startsWith('audio/')) return '<media:audio>'
|
|
108
|
+
if (
|
|
109
|
+
ct === 'application/pdf' ||
|
|
110
|
+
ct.includes('word') ||
|
|
111
|
+
ct.includes('document') ||
|
|
112
|
+
ct.includes('msword') ||
|
|
113
|
+
ct.includes('officedocument') ||
|
|
114
|
+
ct.includes('spreadsheet') ||
|
|
115
|
+
ct.includes('presentation')
|
|
116
|
+
) {
|
|
117
|
+
return '<media:document>'
|
|
118
|
+
}
|
|
119
|
+
return '<media:document>'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 使用 SDK 与飞书通道一致的 Media* 布局,并保留 dcgchat 原有的文件名字段供工具/模板使用。
|
|
124
|
+
*/
|
|
125
|
+
export function buildDcgchatInboundMediaPayload(mediaList: DcgchatInboundMediaInfo[]): Record<string, unknown> {
|
|
126
|
+
if (mediaList.length === 0) return {}
|
|
127
|
+
const base = buildAgentMediaPayload(mediaList.map((m) => ({ path: m.path, contentType: m.contentType || null })))
|
|
128
|
+
const mediaFileNames = mediaList.map((m) => m.fileName).filter(Boolean)
|
|
129
|
+
if (mediaFileNames.length === 0) return { ...base }
|
|
130
|
+
return {
|
|
131
|
+
...base,
|
|
132
|
+
MediaFileName: mediaFileNames[0],
|
|
133
|
+
MediaFileNames: mediaFileNames
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** BodyForAgent:含附件说明与槽位行;与 RawBody/CommandBody(纯用户侧文本)分离 */
|
|
138
|
+
export function buildAgentInboundBodySuffix(userText: string, mediaList: DcgchatInboundMediaInfo[]): string {
|
|
139
|
+
if (mediaList.length === 0) return userText
|
|
140
|
+
const brief = mediaList.map((m, i) => `${i + 1}) ${m.fileName} [${m.contentType || 'unknown'}]`).join('; ')
|
|
141
|
+
const slots = mediaList.map((m) => m.placeholder).join('\n')
|
|
142
|
+
return `${userText}\n\n[系统: 用户已随本消息上传 ${mediaList.length} 个附件: ${brief}。请勿要求用户再次上传;下列槽位与 MediaPaths/MediaTypes 顺序一致。]\n${slots}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const DEFAULT_INBOUND_FETCH_TIMEOUT_MS = 180_000
|
|
146
|
+
const DEFAULT_INBOUND_DOWNLOAD_CONCURRENCY = 3
|
|
147
|
+
|
|
148
|
+
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
|
|
149
|
+
const ctrl = new AbortController()
|
|
150
|
+
const t = setTimeout(() => ctrl.abort(new Error(`fetch timeout ${timeoutMs}ms`)), timeoutMs)
|
|
151
|
+
try {
|
|
152
|
+
return await fetch(url, { signal: ctrl.signal })
|
|
153
|
+
} finally {
|
|
154
|
+
clearTimeout(t)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveFilepath(savedFilePath: string, filename: string) {
|
|
159
|
+
if (path.extname(savedFilePath)) return savedFilePath
|
|
160
|
+
|
|
161
|
+
const ext = path.extname(filename)
|
|
162
|
+
if (!ext) return savedFilePath
|
|
163
|
+
|
|
164
|
+
const newPath = `${savedFilePath}${ext}`
|
|
165
|
+
try {
|
|
166
|
+
fs.renameSync(savedFilePath, newPath);
|
|
167
|
+
return newPath
|
|
168
|
+
} catch (err) {
|
|
169
|
+
dcgLogger(`media: failed to append extension to ${savedFilePath}: ${String(err)}`, 'error')
|
|
170
|
+
return savedFilePath
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function resolveOneInboundFile(
|
|
175
|
+
file: DcgchatInboundFileRef,
|
|
176
|
+
botToken: string,
|
|
177
|
+
maxBytes: number,
|
|
178
|
+
fetchTimeoutMs: number
|
|
179
|
+
): Promise<DcgchatInboundMediaInfo | null> {
|
|
180
|
+
const core = getDcgchatRuntime()
|
|
181
|
+
try {
|
|
182
|
+
let data = ''
|
|
183
|
+
if (/^https?:\/\//i.test(file.url)) {
|
|
184
|
+
data = file.url
|
|
185
|
+
} else {
|
|
186
|
+
data = await generateSignUrl(file.url, botToken)
|
|
187
|
+
}
|
|
188
|
+
const response = await fetchWithTimeout(data, fetchTimeoutMs)
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
dcgLogger?.(`media: ${file.url} fetch failed with HTTP ${response.status}`, 'error')
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
194
|
+
|
|
195
|
+
let contentType = response.headers.get('content-type') || ''
|
|
196
|
+
if (!contentType) {
|
|
197
|
+
contentType = (await core.media.detectMime({ buffer })) || ''
|
|
198
|
+
}
|
|
199
|
+
const fileName = file.name || fallbackFileNameFromUrl(file.url)
|
|
200
|
+
const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, 'inbound', maxBytes, fileName)
|
|
201
|
+
const effectiveType = saved.contentType || contentType
|
|
202
|
+
|
|
203
|
+
const finalPath = resolveFilepath(saved.path, fileName)
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
path: finalPath,
|
|
207
|
+
fileName,
|
|
208
|
+
contentType: effectiveType,
|
|
209
|
+
placeholder: inferPlaceholderFromContentType(effectiveType)
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
dcgLogger(`media: ${file.url} FAILED to process: ${String(err)}`, 'error')
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function resolveMediaFromUrls(
|
|
218
|
+
files: DcgchatInboundFileRef[],
|
|
219
|
+
botToken: string,
|
|
220
|
+
opts?: { maxBytes?: number; fetchTimeoutMs?: number; concurrency?: number }
|
|
221
|
+
): Promise<DcgchatInboundMediaInfo[]> {
|
|
222
|
+
const maxBytes = opts?.maxBytes ?? DCGCHAT_INBOUND_MEDIA_MAX_BYTES
|
|
223
|
+
const fetchTimeoutMs = opts?.fetchTimeoutMs ?? DEFAULT_INBOUND_FETCH_TIMEOUT_MS
|
|
224
|
+
const conc = Math.max(1, opts?.concurrency ?? DEFAULT_INBOUND_DOWNLOAD_CONCURRENCY)
|
|
225
|
+
const concurrency = files.length === 0 ? 1 : Math.min(conc, files.length)
|
|
226
|
+
|
|
227
|
+
dcgLogger(`media: inbound files count=${files.length}, concurrency=${concurrency}, fetchTimeoutMs=${fetchTimeoutMs}`)
|
|
228
|
+
const results: (DcgchatInboundMediaInfo | null)[] = new Array(files.length).fill(null)
|
|
229
|
+
let nextIndex = 0
|
|
230
|
+
|
|
231
|
+
async function worker() {
|
|
232
|
+
while (true) {
|
|
233
|
+
const i = nextIndex++
|
|
234
|
+
if (i >= files.length) break
|
|
235
|
+
results[i] = await resolveOneInboundFile(files[i], botToken, maxBytes, fetchTimeoutMs)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
|
240
|
+
|
|
241
|
+
const out = results.filter((x): x is DcgchatInboundMediaInfo => x != null)
|
|
242
|
+
dcgLogger(`media: resolve complete, ${out.length}/${files.length} file(s) succeeded`)
|
|
243
|
+
return out
|
|
244
|
+
}
|
|
@@ -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,117 @@
|
|
|
1
|
+
import type { IMsgParams } from '../types.js'
|
|
2
|
+
import { sendChunk } from '../transport.js'
|
|
3
|
+
import { agentErrorPayloadText } from './agentErrors.js'
|
|
4
|
+
import { takeStreamChunkIdxAndAdvance } from './inboundTurnState.js'
|
|
5
|
+
import { isReviserExpert } from './global.js'
|
|
6
|
+
|
|
7
|
+
type OutboundTextState = {
|
|
8
|
+
inboundGen: number
|
|
9
|
+
lastStreamSnapshot: string
|
|
10
|
+
/** 仅 onPartialReply 成功下发过正文时为 true,用于 deliver 兜底判断 */
|
|
11
|
+
hasStreamedAssistantText: boolean
|
|
12
|
+
hasEmittedAssistantText: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const stateBySessionKey = new Map<string, OutboundTextState>()
|
|
16
|
+
|
|
17
|
+
/** 每轮入站 dispatch 开始前初始化,与 inboundGen 绑定供调试对齐 */
|
|
18
|
+
export function resetOutboundTextState(sessionKey: string, inboundGen: number): void {
|
|
19
|
+
const k = sessionKey.trim()
|
|
20
|
+
if (!k) return
|
|
21
|
+
stateBySessionKey.set(k, {
|
|
22
|
+
inboundGen,
|
|
23
|
+
lastStreamSnapshot: '',
|
|
24
|
+
hasStreamedAssistantText: false,
|
|
25
|
+
hasEmittedAssistantText: false
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function clearOutboundTextState(sessionKey: string): void {
|
|
30
|
+
const k = sessionKey.trim()
|
|
31
|
+
if (!k) return
|
|
32
|
+
stateBySessionKey.delete(k)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getState(sessionKey: string): OutboundTextState | undefined {
|
|
36
|
+
const k = sessionKey.trim()
|
|
37
|
+
if (!k) return undefined
|
|
38
|
+
return stateBySessionKey.get(k)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 子 agent 等路径可能未走 bot reset;懒初始化保证 dcgchat_message 仍可出站且参与快照去重 */
|
|
42
|
+
function ensureState(sessionKey: string): OutboundTextState | undefined {
|
|
43
|
+
const k = sessionKey.trim()
|
|
44
|
+
if (!k) return undefined
|
|
45
|
+
let state = stateBySessionKey.get(k)
|
|
46
|
+
if (!state) {
|
|
47
|
+
state = {
|
|
48
|
+
inboundGen: 0,
|
|
49
|
+
lastStreamSnapshot: '',
|
|
50
|
+
hasStreamedAssistantText: false,
|
|
51
|
+
hasEmittedAssistantText: false
|
|
52
|
+
}
|
|
53
|
+
stateBySessionKey.set(k, state)
|
|
54
|
+
}
|
|
55
|
+
return state
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function outboundHasStreamedAssistantText(sessionKey: string): boolean {
|
|
59
|
+
return getState(sessionKey)?.hasStreamedAssistantText ?? false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function outboundHasEmittedAssistantText(sessionKey: string): boolean {
|
|
63
|
+
return getState(sessionKey)?.hasEmittedAssistantText ?? false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 仅重置快照(用于 `replace` 流式片段),保留 generation 与已下发标记 */
|
|
67
|
+
export function resetOutboundTextSnapshot(sessionKey: string): void {
|
|
68
|
+
const k = sessionKey.trim()
|
|
69
|
+
if (!k) return
|
|
70
|
+
const state = ensureState(k)
|
|
71
|
+
if (state) {
|
|
72
|
+
state.lastStreamSnapshot = ''
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 统一助手正文出站:bot 流式/deliver 与 dcgchat_message 共用快照增量,避免双发或漏发。
|
|
78
|
+
* @param markStreamed 为 true 时表示来自 onPartialReply,会置 hasStreamedAssistantText
|
|
79
|
+
* @param isError 仅对 OpenClaw 明确标记的错误载荷做用户可见翻译,避免普通正文中的错误短语被误判
|
|
80
|
+
*/
|
|
81
|
+
export function emitOutboundAssistantText(
|
|
82
|
+
sessionKey: string,
|
|
83
|
+
raw: string | undefined,
|
|
84
|
+
outboundCtx: IMsgParams,
|
|
85
|
+
options?: { markStreamed?: boolean; isError?: boolean; emitDelta?: (delta: string) => boolean | void }
|
|
86
|
+
): boolean {
|
|
87
|
+
if (!raw || isReviserExpert(sessionKey)) return false
|
|
88
|
+
const state = ensureState(sessionKey)
|
|
89
|
+
if (!state) return false
|
|
90
|
+
|
|
91
|
+
const t = raw
|
|
92
|
+
let delta = ''
|
|
93
|
+
if (t.startsWith(state.lastStreamSnapshot)) {
|
|
94
|
+
delta = t.slice(state.lastStreamSnapshot.length)
|
|
95
|
+
state.lastStreamSnapshot = t
|
|
96
|
+
} else if (state.lastStreamSnapshot.startsWith(t)) {
|
|
97
|
+
// 快照缩短(模型修订等):不重复下发
|
|
98
|
+
} else {
|
|
99
|
+
delta = t
|
|
100
|
+
state.lastStreamSnapshot = t
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!delta.trim()) return false
|
|
104
|
+
|
|
105
|
+
delta = agentErrorPayloadText(delta, options?.isError === true)
|
|
106
|
+
|
|
107
|
+
const emitted =
|
|
108
|
+
options?.emitDelta != null
|
|
109
|
+
? options.emitDelta(delta) !== false
|
|
110
|
+
: sendChunk(delta, outboundCtx, takeStreamChunkIdxAndAdvance(sessionKey))
|
|
111
|
+
if (!emitted) return false
|
|
112
|
+
state.hasEmittedAssistantText = true
|
|
113
|
+
if (options?.markStreamed) {
|
|
114
|
+
state.hasStreamedAssistantText = true
|
|
115
|
+
}
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
@@ -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
|
+
}
|