Files
BaiLongma/src/capabilities/executor.js
xiaoyuanda666-ship-it b5dd8632c4 🎉 V3.0.0 发布 - 自进化数字意识框架
核心升级:
- 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环
- evo_loop 后台进程,无需手动触发
- consciousness 意识持久化
- ACUI 卡片组件系统
- MCP 工具生态扩展至50+工具
- 技能体系重构,4个活跃技能
- 身份升级为自由体
2026-05-24 22:06:37 +08:00

3419 lines
136 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import { fileURLToPath } from 'url'
import { spawn } from 'child_process'
import { nowTimestamp } from '../time.js'
import { searchMemories, searchMemoriesByKeywords, insertMemory, upsertMemoryByMemId, memoryExistsByMemId, getMemoryByMemId, deleteMemoryByMemId, hideMemoryByMemId, normalizeConversationPartyId, createReminder, findMergeableOneOffReminder, appendReminderTask, listPendingReminders, getReminderById, cancelReminder, upsertPrefetchTask, removePrefetchTask, listPrefetchTasks, insertActionLog, insertConversation, upsertMusicTrack, getMusicTrack, searchMusicLibrary, listMusicLibrary, updateMusicLrc, deleteMusicTrack as dbDeleteMusicTrack, setConfig as dbSetConfig } from '../db.js'
import { emitEvent, emitUICommand, emitACUIEvent, hasACUIClient, addActiveUICard, removeActiveUICard, getActiveUICards, setStickyEvent } from '../events.js'
import { dispatchSocialMessage } from '../social/dispatch.js'
import { callCapability, listCapabilities } from '../providers/registry.js'
import { isDailyLimitReached } from '../quota.js'
import { setCustomInterval as setTickerInterval, getStatus as getTickerStatus } from '../ticker.js'
import { setHotspotPanelState, getHotspotPanelState } from '../hotspots.js'
import { setPersonCardPanelState, getPersonCardPanelState, getPersonCard } from '../person-cards.js'
import { setDocPanelState, getDocPanelState } from '../docs.js'
import { setUserLocation } from '../weather.js'
import { getAgentById, isDelegationAllowed } from '../agents/registry.js'
import { installTool, uninstallTool, listInstalledTools, isInstalledTool, executeInstalledTool } from './marketplace/index.js'
import { TOOL_SCHEMAS } from './schemas.js'
// 后台进程注册表pid → { process, command, startedAt, outputLines }
const bgProcesses = new Map()
const BG_OUTPUT_MAX_LINES = 200
const IS_WIN = process.platform === 'win32'
/**
* 跨平台 spawn shell 命令,确保中文输出不乱码。
*
* Windows 编码三层同步(缺一不可,否则中文会乱码):
* 1) chcp 65001 → 切换控制台 Active Code Page 到 UTF-8。这一步最关键
* 原生程序git / npm / node / cmd 内建命令 / yt-dlp 等)读取的是 ACP
* 不读 PowerShell 的 OutputEncoding。中文 Windows 默认 ACP=936(GBK)
* 不切的话原生命令吐 GBK 字节,下游按 UTF-8 解码就是 <20>
* 2) [Console]::OutputEncoding=UTF8 → 告诉 PowerShell 按 UTF-8 解码原生命令输出。
* 3) [Console]::InputEncoding / $OutputEncoding=UTF8 → PowerShell 自身、
* 以及向子进程 stdin 写数据的方向也用 UTF-8。
*
* 不通过 Node 的 shell: 'powershell.exe' 选项,避免 Windows 下被强行套上
* cmd /d /s /c 包装PowerShell 会把这些当作未知参数,特殊字符还可能二次转义)。
* 直接显式 spawn powershell.exe + -Command 最可控。
*
* 调用方仍应对 child.stdout / child.stderr 调用 setEncoding('utf8')
* 防止数据 chunk 切在多字节字符中间产生 U+FFFD。
*/
function spawnShellCommand(command, opts = {}) {
if (IS_WIN) {
const wrapped =
`chcp 65001 > $null; ` +
`[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; ` +
`[Console]::InputEncoding=[System.Text.Encoding]::UTF8; ` +
`$OutputEncoding=[System.Text.Encoding]::UTF8; ` +
command
return spawn('powershell.exe', ['-NoLogo', '-NoProfile', '-Command', wrapped], opts)
}
return spawn(command, { ...opts, shell: true })
}
// URL 访问缓存url → { content, fetchedAt (ms timestamp) }
// 避免同一 URL 在短时间内被反复请求(如天气每天只需查一次)
const urlCache = new Map()
// web_search 结果缓存query::limit → { payload, fetchedAt }
// Map 的插入顺序即 LRU 顺序;写入时若超量则淘汰最老一条
const searchCache = new Map()
const SEARCH_CACHE_TTL_MS = 10 * 60 * 1000
const SEARCH_CACHE_MAX = 200
function searchCacheGet(key) {
const entry = searchCache.get(key)
if (!entry) return null
if (Date.now() - entry.fetchedAt >= SEARCH_CACHE_TTL_MS) {
searchCache.delete(key)
return null
}
searchCache.delete(key)
searchCache.set(key, entry)
return entry.payload
}
function searchCacheSet(key, payload) {
searchCache.set(key, { payload, fetchedAt: Date.now() })
while (searchCache.size > SEARCH_CACHE_MAX) {
const oldest = searchCache.keys().next().value
if (oldest === undefined) break
searchCache.delete(oldest)
}
}
const URL_TTL_MS = {
default: 60 * 60 * 1000, // 默认1 小时
weather: 24 * 60 * 60 * 1000, // 天气类24 小时
news: 30 * 60 * 1000, // 新闻类30 分钟
}
function getUrlTtl(url) {
const u = url.toLowerCase()
if (u.includes('wttr.in') || u.includes('weather') || u.includes('openweather') || u.includes('tianqi')) {
return URL_TTL_MS.weather
}
if (u.includes('news') || u.includes('rss') || u.includes('feed')) {
return URL_TTL_MS.news
}
return URL_TTL_MS.default
}
import { config, getTTSCredentials, setSecurity, getWebSearchCredentials } from '../config.js'
import { streamTTS } from '../voice/tts-providers.js'
import { paths } from '../paths.js'
import { PRIMARY_USER_ID, lookupReplyTarget, normalizeChannel, suggestProactiveChannel } from '../identity.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// 文件操作只允许在 sandbox 目录内
const SANDBOX_ROOT = path.resolve(paths.sandboxDir)
// inline-script 草稿注册表(内存 + 磁盘双存)
const draftCodeMap = new Map() // { scratchId → code }
const appIdToName = new Map() // { scratchId → appName }
const DRAFT_CODE_MAP_MAX = 50 // 超出后淘汰最旧条目
function addDraftCode(id, code) {
if (draftCodeMap.size >= DRAFT_CODE_MAP_MAX) {
draftCodeMap.delete(draftCodeMap.keys().next().value)
}
draftCodeMap.set(id, code)
}
// 由 api.js 调用:把 app:saveState 信号的状态自动落盘
export function persistAppState(componentId, state) {
const name = appIdToName.get(componentId)
if (!name) return false
try {
const statePath = path.resolve(SANDBOX_ROOT, 'apps', name, 'state.json')
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8')
return true
} catch { return false }
}
function createAbortError(reason = 'Aborted') {
const err = new Error(reason)
err.name = 'AbortError'
return err
}
function throwIfAborted(signal) {
if (signal?.aborted) throw createAbortError(signal.reason || 'Aborted')
}
function createMergedAbortSignal(signal, timeoutMs) {
if (!signal && !timeoutMs) return null
const controller = new AbortController()
let timeoutId = null
const abort = (reason) => {
if (!controller.signal.aborted) controller.abort(reason)
}
const onAbort = () => abort(signal?.reason || 'Aborted')
if (signal) {
if (signal.aborted) abort(signal.reason || 'Aborted')
else signal.addEventListener('abort', onAbort, { once: true })
}
if (timeoutMs) {
timeoutId = setTimeout(() => abort(`Timeout ${timeoutMs}ms`), timeoutMs)
}
return {
signal: controller.signal,
cleanup() {
if (timeoutId) clearTimeout(timeoutId)
if (signal) signal.removeEventListener('abort', onAbort)
},
}
}
function isPathInside(parentDir, candidatePath) {
const parent = path.resolve(parentDir)
const candidate = path.resolve(candidatePath)
const relative = path.relative(parent, candidate)
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
}
function assertInSandbox(resolvedPath) {
if (config.security?.fileSandbox === false) return
if (resolvedPath !== SANDBOX_ROOT && !isPathInside(SANDBOX_ROOT, resolvedPath)) {
throw new Error(`访问被拒绝:文件操作只允许在 sandbox 目录内(${SANDBOX_ROOT}`)
}
}
// 规范化路径:去掉可能带的 sandbox/ 前缀,统一以 SANDBOX_ROOT 为基准
// 同时处理模型把 list_dir 返回的绝对路径再次传入的情况
function normalizeSandboxPath(filePath) {
if (path.isAbsolute(filePath)) {
const rel = path.relative(SANDBOX_ROOT, filePath)
if (!rel.startsWith('..')) return rel || '.'
}
return filePath
.replace(/^sandbox[\\/]/i, '')
.replace(/^\.[\\/]/, '')
}
// 工具执行器:根据工具名和参数执行对应操作,返回结果字符串
const TOOL_RISK = {
read_file: 'low',
list_dir: 'low',
search_memory: 'low',
list_processes: 'low',
skip_recognition: 'low',
send_message: 'medium',
express: 'medium',
write_file: 'medium',
make_dir: 'medium',
upsert_memory: 'medium',
merge_memories: 'high', // 删数据
downgrade_memory: 'low',
skip_consolidation: 'low',
manage_reminder: 'medium',
schedule_reminder: 'medium',
manage_prefetch_task: 'medium',
ui_show: 'medium',
ui_update: 'medium',
ui_hide: 'medium',
ui_patch: 'medium',
manage_app: 'medium',
set_tick_interval: 'medium',
media_mode: 'low',
hotspot_mode: 'low',
open_doc_panel: 'low',
person_card_mode: 'low',
music: 'low',
delegate_to_agent: 'high',
grant_agent_delegation: 'high',
install_tool: 'high',
uninstall_tool: 'medium',
list_tools: 'low',
complete_startup_self_check: 'low',
delete_file: 'high',
exec_command: 'high',
kill_process: 'high',
web_search: 'high',
fetch_url: 'high',
browser_read: 'high',
speak: 'high',
generate_lyrics: 'high',
generate_music: 'high',
generate_image: 'high',
ui_register: 'high',
set_security: 'high',
}
function classifyTool(name) {
return TOOL_RISK[name] || 'medium'
}
function safeJsonStringify(value) {
try {
return JSON.stringify(value ?? {})
} catch {
return '{}'
}
}
function compactWhitespace(value) {
return String(value ?? '').replace(/\s+/g, ' ').trim()
}
function previewValue(value, max = 180) {
const text = typeof value === 'string' ? value : safeJsonStringify(value)
const compact = compactWhitespace(text)
return compact.length > max ? `${compact.slice(0, max)}...` : compact
}
function getExecutionSource(context = {}) {
return context.source || context.trigger || (context.autonomous ? 'autonomous' : 'llm')
}
function summarizeToolExecution(name, args = {}) {
switch (name) {
case 'read_file':
return `read_file(${args.path || args.filename || args.file_path || '?'})`
case 'list_dir':
return `list_dir(${args.path || args.dir || args.directory || '.'})`
case 'write_file':
return `write_file(${args.path || args.filename || args.file_path || '?'})`
case 'delete_file':
return `delete_file(${args.path || args.filename || args.file_path || '?'})`
case 'make_dir':
return `make_dir(${args.path || args.dir || args.directory || '?'})`
case 'exec_command':
return `exec_command(${String(args.command || args.cmd || '?').slice(0, 100)})`
case 'fetch_url':
case 'browser_read':
return `${name}(${String(args.url || args.link || args.href || '?').slice(0, 120)})`
case 'web_search':
return `web_search(${String(args.query || args.q || args.keyword || '?').slice(0, 120)})`
case 'send_message':
case 'express':
return `${name} -> ${args.target_id || '(unknown)'}`
case 'upsert_memory': {
const count = Array.isArray(args.memories) ? args.memories.length : 0
return `upsert_memory(${count})`
}
default:
return name
}
}
function isDangerousShellCommand(command) {
const text = String(command || '').trim()
const reasons = []
if (config.security?.execSandbox !== false) {
if (/(^|[\s"'`])\.\.([\\/]|$)/.test(text)) reasons.push('command references a parent directory')
if (/(^|[\s"'`])[a-z]:[\\/]/i.test(text) || /(^|[\s"'`])[\\/]{2}[^\\/]/.test(text)) reasons.push('command references an absolute filesystem path')
if (/(^|[\s"'`])~([\\/]|$)/.test(text) || /\$(home|env:userprofile)\b/i.test(text) || /%userprofile%/i.test(text)) reasons.push('command references the user home directory')
if (/\bgit\s+reset\s+--hard\b/i.test(text) || /\bgit\s+clean\b/i.test(text)) reasons.push('command can destructively rewrite the worktree')
if (/\b(format|diskpart|shutdown)\b/i.test(text)) reasons.push('command is system-level destructive or disruptive')
if (/Remove-Item\b.*-Recurse|-Recurse\b.*Remove-Item/i.test(text)) reasons.push('recursive delete (Remove-Item -Recurse) detected')
if (/\brd\s+\/s\b/i.test(text)) reasons.push('recursive directory delete (rd /s) detected')
if (/\bInvoke-Expression\b|\biex\s/i.test(text)) reasons.push('dynamic code execution via Invoke-Expression detected')
}
return reasons
}
function evaluateToolPolicy(name, args = {}, context = {}) {
const risk = classifyTool(name)
const blockedTools = config.security?.blockedTools || []
if (blockedTools.includes(name)) {
return { allowed: false, risk, reason: `工具 "${name}" 已被安全策略禁用` }
}
if (name === 'exec_command') {
const reasons = isDangerousShellCommand(args.command || args.cmd || '')
if (reasons.length) return { allowed: false, risk, reason: reasons.join('; ') }
}
if (context.autonomous && risk === 'high' && !context.allowHighRiskAutonomy) {
return { allowed: false, risk, reason: 'high-risk tool requires an explicit user-driven context' }
}
return { allowed: true, risk, reason: '' }
}
function inferToolStatus(result) {
const text = String(result ?? '').trim()
if (!text) return 'ok'
try {
const parsed = JSON.parse(text)
return parsed?.ok === false ? 'error' : 'ok'
} catch {}
return /^(错误|请求失败|执行失败|命令超时|命令执行失败|閿欒|璇锋眰澶辫触|鎵ц澶辫触|鍛戒护瓒呮椂|鍛戒护鎵ц澶辫触)/.test(text) ? 'error' : 'ok'
}
function writeToolAuditLog({ name, args, context, policy, status, result = '', error = '', startedAt }) {
const durationMs = Date.now() - startedAt
const detailParts = []
if (policy?.reason) detailParts.push(`policy=${policy.reason}`)
const argPreview = previewValue(args, 160)
if (argPreview && argPreview !== '{}') detailParts.push(`args=${argPreview}`)
const resultPreview = previewValue(result || error, 220)
if (resultPreview) detailParts.push(`result=${resultPreview}`)
try {
insertActionLog({
timestamp: new Date(startedAt).toISOString(),
tool: name,
summary: summarizeToolExecution(name, args),
detail: detailParts.join(' | '),
status,
risk: policy?.risk || classifyTool(name),
argsJson: safeJsonStringify(args),
resultPreview,
error,
durationMs,
source: getExecutionSource(context),
})
} catch (err) {
console.warn(`[audit] failed to persist tool audit log: ${err.message}`)
}
emitEvent('tool_audit', {
tool: name,
status,
risk: policy?.risk || classifyTool(name),
summary: summarizeToolExecution(name, args),
duration_ms: durationMs,
source: getExecutionSource(context),
})
}
async function executeToolUnchecked(name, args, context = {}) {
try {
throwIfAborted(context.signal)
switch (name) {
case 'express':
return await execExpress(args, context)
case 'send_message':
return await execSendMessage(args, context)
case 'read_file':
return await execReadFile(args, context)
case 'list_dir':
return await execListDir(args, context)
case 'write_file':
return await execWriteFile(args, context)
case 'delete_file':
return await execDeleteFile(args, context)
case 'make_dir':
return await execMakeDir(args, context)
case 'exec_command':
return await execCommand(args, context)
case 'kill_process':
return await execKillProcess(args)
case 'list_processes':
return await execListProcesses(args)
case 'web_search':
return await execWebSearch(args, context)
case 'fetch_url':
return await execFetchUrl(args, context)
case 'browser_read':
return await execBrowserRead(args, context)
case 'search_memory':
return await execSearchMemory(args)
case 'upsert_memory':
return await execUpsertMemory(args, context)
case 'skip_recognition':
return await execSkipRecognition(args)
case 'merge_memories':
return await execMergeMemories(args, context)
case 'downgrade_memory':
return await execDowngradeMemory(args)
case 'skip_consolidation':
return await execSkipConsolidation(args)
case 'speak':
return await execSpeak(args)
case 'generate_lyrics':
return await execGenerateLyrics(args)
case 'generate_music':
return await execGenerateMusic(args)
case 'generate_image':
return await execGenerateImage(args)
case 'set_tick_interval':
return execSetTickInterval(args)
case 'media_mode':
return execMediaMode(args)
case 'hotspot_mode':
return execHotspotMode(args)
case 'open_doc_panel':
return execOpenDocPanel(args)
case 'person_card_mode':
return execPersonCardMode(args)
case 'music':
return await execMusic(args)
case 'schedule_reminder':
case 'manage_reminder':
return await execManageReminder(args, context)
case 'manage_prefetch_task':
return execManagePrefetchTask(args)
case 'ui_show':
return execUIShow(args)
case 'ui_update':
return execUIUpdate(args)
case 'ui_hide':
return execUIHide(args)
case 'ui_patch':
return execUIPatch(args)
case 'manage_app':
return execManageApp(args)
case 'ui_register':
return execUIRegister(args)
case 'focus_banner':
return execFocusBanner(args)
case 'set_location':
return execSetLocation(args)
case 'set_agent_name':
return execSetAgentName(args)
case 'delegate_to_agent':
return await execDelegateToAgent(args)
case 'grant_agent_delegation':
return execGrantAgentDelegation(args)
case 'complete_startup_self_check':
return execCompleteStartupSelfCheck(args, context)
case 'set_task':
return execSetTask(args, context)
case 'complete_task':
return execCompleteTask(args, context)
case 'update_task_step':
return execUpdateTaskStep(args, context)
case 'recall_memory':
return await execRecallMemory(args, context)
case 'install_tool':
return await execInstallTool(args)
case 'uninstall_tool':
return execUninstallTool(args)
case 'list_tools':
return execListTools()
case 'connect_wechat':
return execConnectWechat()
case 'set_security':
return execSetSecurity(args)
default:
if (isInstalledTool(name)) {
return await executeInstalledTool(name, args)
}
return `错误:未知工具 "${name}"`
}
} catch (err) {
if (err.name === 'AbortError') throw err
return `执行失败:${err.message}`
}
}
export async function executeTool(name, args, context = {}) {
const startedAt = Date.now()
const safeArgs = args || {}
const policy = evaluateToolPolicy(name, safeArgs, context)
if (!policy.allowed) {
const result = toolJson({
ok: false,
tool: name,
error: 'permission denied',
policy: {
risk: policy.risk,
reason: policy.reason,
},
})
writeToolAuditLog({ name, args: safeArgs, context, policy, status: 'denied', result, startedAt })
return result
}
try {
const result = await executeToolUnchecked(name, safeArgs, context)
writeToolAuditLog({ name, args: safeArgs, context, policy, status: inferToolStatus(result), result, startedAt })
return result
} catch (err) {
if (err.name === 'AbortError') throw err
const result = `执行失败:${err.message}`
writeToolAuditLog({ name, args: safeArgs, context, policy, status: 'error', result, error: err.message, startedAt })
return result
}
}
function resolveAllowedTargetId(targetId, allowedTargetIds = []) {
const normalizedTarget = normalizeConversationPartyId(targetId)
const normalizedAllowed = [...new Set((allowedTargetIds || []).map(id => normalizeConversationPartyId(id)).filter(Boolean))]
if (!normalizedAllowed.length) {
throw new Error('The current prompt did not explicitly inject any sendable target entities, so sending a message is forbidden.')
}
if (normalizedAllowed.includes(normalizedTarget)) {
return normalizedTarget
}
const compact = value => String(value || '').trim().toLowerCase().replace(/^id:0*/, '')
const targetCompact = compact(normalizedTarget)
const fuzzyMatches = normalizedAllowed.filter(id => compact(id) === targetCompact)
if (fuzzyMatches.length === 1) {
console.log(`[send_message] ID strict validation passed by fuzzy normalization: "${targetId}" -> "${fuzzyMatches[0]}"`)
return fuzzyMatches[0]
}
throw new Error(`target_id "${targetId}" is not in the target entity list explicitly injected into the current prompt: ${normalizedAllowed.join(', ')}`)
}
function assertVisibleTargetId(targetId, visibleTargetIds = []) {
const normalizedTarget = normalizeConversationPartyId(targetId)
const normalizedVisible = [...new Set((visibleTargetIds || []).map(id => normalizeConversationPartyId(id)).filter(Boolean))]
if (!normalizedVisible.length) {
throw new Error('The current L2 prompt did not inject any conversation targets, so sending a message is forbidden.')
}
if (normalizedVisible.includes(normalizedTarget)) {
return normalizedTarget
}
throw new Error(`target_id "${targetId}" does not appear in the conversation records injected into the current L2 prompt: ${normalizedVisible.join(', ')}`)
}
function parseReminderDueAt(value) {
if (!value || typeof value !== 'string') {
throw new Error('due_at was not provided')
}
const dueAt = new Date(value.trim())
if (Number.isNaN(dueAt.getTime())) {
throw new Error('due_at must be a valid ISO 8601 absolute time, for example 2026-04-21T06:00:00+08:00')
}
return dueAt
}
function trimAssistantFluff(content) {
let text = String(content || '').trim()
if (!text) return text
text = text
.replace(/^(?:\s*\[assistant(?:\s+to\s+[^\]\r\n]+)?(?:\s+\d{4}-\d{2}-\d{2}T[^\]\r\n]+)?\]\s*)+/giu, '')
.trim()
const patterns = [
/[,、。.!~\s]*(?:从现在起|从今以后|以后)?我就是[\u4e00-\u9fa5A-Za-z0-9 _-]{1,24}[,、。.!~\s]*为您效劳[!~。.\s]*$/u,
/[,、。.!~\s]*有什么需要帮忙的[?]?[,、。.!~\s]*(?:随时)?为您效劳[~!。.\s]*$/u,
/[,、。.!~\s]*有什么需要我帮忙的[?]?[,、。.!~\s]*(?:随时)?为您效劳[~!。.\s]*$/u,
/[,、。.!~\s]*随时为您效劳[~!。.\s]*$/u,
/[,、。.!~\s]*为您效劳[~!。.\s]*$/u,
/[,、。.!~\s]*有什么需要帮忙的[?]?[~!。.\s]*$/u,
/[,、。.!~\s]*有什么需要我帮忙的[?]?[~!。.\s]*$/u,
]
let changed = true
while (changed) {
changed = false
for (const pattern of patterns) {
const next = text.replace(pattern, '').trim()
if (next !== text) {
text = next
changed = true
}
}
}
return text
}
// express表达器入口根据 format 路由到对应输出渠道
async function execExpress({ target_id, content, channel = 'AUTO', format = 'text' }, context = {}) {
if (!content?.trim()) return '错误:未提供表达内容'
if (format === 'voice') {
// 语音表达:先发文字消息再生成语音
const sendResult = await execSendMessage({ target_id, content, channel }, context)
if (sendResult.startsWith('错误:') || sendResult.startsWith('执行失败:')) return sendResult
return await execSpeak({ text: content })
}
// 默认:文字表达
return await execSendMessage({ target_id, content, channel }, context)
}
// 决议出站消息的真实投递目标:
// 输入 target_id可能是 canonical ID:000001 或带前缀的外部 ID+ channel 偏好WECHAT/DISCORD/FEISHU/WECOM/TUI/AUTO+ ctx
// 输出 { externalTargetId, deliveryChannel, isLocal, reason }
// - externalTargetId: 传给 dispatchSocialMessage 的 ID本地投递时为 null
// - deliveryChannel: conversations.channel 字段实际值(数据库格式,如 WECHAT_CLAWBOT/TUI
// - isLocal: true 时不调外部 dispatch只走本地 SSE
// - reason: 失败时给 LLM 的提示
// AUTO 决议顺序:当前 turn 渠道(响应模式)→ suggestProactiveChannel主动模式
function resolveDeliveryTarget(resolvedId, channelPref, context = {}) {
const pref = (channelPref || 'AUTO').toUpperCase()
// resolvedId 本身就是带渠道前缀的外部 ID少见但保留兼容—— 直接当外部投递
if (/^(wechat|discord|feishu|wecom):/i.test(resolvedId)) {
return { externalTargetId: resolvedId, deliveryChannel: '', isLocal: false }
}
// canonical 用户 ID根据 channel 偏好决议
let actualPref = pref
if (actualPref === 'AUTO') {
// 优先用当前 turn 的渠道:用户在哪儿发消息就回到哪儿(响应直觉一致)
const currentNorm = context.currentChannel ? normalizeChannel(context.currentChannel) : null
if (currentNorm && currentNorm !== 'SYSTEM') {
actualPref = currentNorm
} else {
// 没有当前 turn 渠道典型场景tick 主动外联)→ 用 presence 推荐
actualPref = suggestProactiveChannel(resolvedId)
}
}
if (actualPref === 'TUI') {
return { externalTargetId: null, deliveryChannel: 'TUI', isLocal: true }
}
// 当前 turn 已经在该外部渠道、且带 externalPartyId → 直接复用,省一次 DB 查
if (context.currentExternalPartyId && context.currentChannel) {
const ctxNorm = normalizeChannel(context.currentChannel)
if (ctxNorm === actualPref) {
return {
externalTargetId: context.currentExternalPartyId,
deliveryChannel: context.currentChannel,
isLocal: false,
}
}
}
// 否则反查该 canonical 用户在指定渠道最近一次的 external_id
const reply = lookupReplyTarget({ canonicalId: resolvedId, channel: actualPref })
if (reply) {
return { externalTargetId: reply.externalId, deliveryChannel: reply.channel, isLocal: false }
}
// 用户在该渠道从未交互过,无法主动联系
return {
externalTargetId: null,
deliveryChannel: '',
isLocal: false,
error: `cannot route to ${actualPref}: user ${resolvedId} has no recorded external_party_id on that channel`,
}
}
// send_message投递到指定渠道本地 SSE 或外部平台),并写入 conversations 表
async function execSendMessage({ target_id, content, channel = 'AUTO' }, context = {}) {
if (!target_id) return '错误:未提供 target_id'
if (!content?.trim()) return '错误:未提供消息内容'
const resolvedId = resolveAllowedTargetId(target_id, context.allowedTargetIds)
assertVisibleTargetId(resolvedId, context.visibleTargetIds)
const cleanedContent = trimAssistantFluff(content)
if (!cleanedContent) return '错误:消息内容为空'
const delivery = resolveDeliveryTarget(resolvedId, channel, context)
if (delivery.error) return `错误:${delivery.error}`
const timestamp = nowTimestamp()
const channelLabel = delivery.deliveryChannel || (delivery.isLocal ? 'TUI' : '')
console.log(`\n[消息发送] → ${resolvedId}${delivery.externalTargetId ? ` via ${delivery.externalTargetId}` : ''}${channelLabel ? ` [${channelLabel}]` : ''}`)
console.log(` ${cleanedContent}`)
console.log(` 时间:${timestamp}`)
// 顺序先写数据库source of truth再广播 SSE最后外部投递。
// 外部投递失败时仍保留对话记录,下次 LLM 仍能看到自己发过这句话;前端也已经显示。
insertConversation({
role: 'jarvis',
from_id: 'jarvis',
to_id: resolvedId,
content: cleanedContent,
timestamp,
channel: channelLabel,
external_party_id: delivery.externalTargetId || '',
})
emitEvent('message', {
from: 'consciousness',
to: resolvedId,
content: cleanedContent,
timestamp,
channel: channelLabel,
external_party_id: delivery.externalTargetId || '',
})
let socialResult = null
if (!delivery.isLocal && delivery.externalTargetId) {
try {
socialResult = await dispatchSocialMessage(delivery.externalTargetId, cleanedContent)
} catch (err) {
console.warn(`[消息发送] 外部投递异常 (${delivery.deliveryChannel}): ${err.message}`)
socialResult = { ok: false, error: err.message }
}
}
if (socialResult?.ok) return `消息已发送至 ${resolvedId}${socialResult.platform} 已投递)`
if (socialResult?.skipped) return `消息已发送至 ${resolvedId}(社交平台未配置:${socialResult.reason}`
if (socialResult && socialResult.ok === false) {
const reason = socialResult.reason || socialResult.error || 'unknown'
// wechat-clawbot 缺 context_token 是该渠道最常见的失败:重启后内存 Map 清空、或用户从未入站。
// 单独点名,让 LLM 直接告诉用户"先发一条过来",不要去编造其他解释。
const isMissingContextToken = /no context_token/i.test(reason)
const hint = isMissingContextToken
? 'wechat-clawbot 必须先收到该用户的入站消息才能回发;告诉用户先从微信给你发一条任意内容即可。)'
: ''
return `消息发送失败:外部渠道 ${delivery.deliveryChannel || 'unknown'} 投递未成功(${reason})。${hint}请如实告知用户该消息未送达及原因。`
}
return `消息已发送至 ${resolvedId}${channelLabel ? `${channelLabel}` : ''}`
}
function parseHourMinute(value, label = 'time') {
const m = String(value || '').trim().match(/^(\d{1,2}):(\d{2})$/)
if (!m) throw new Error(`${label} must use HH:MM format, for example 09:00`)
const hour = Number(m[1]), minute = Number(m[2])
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) throw new Error(`${label} is outside the valid range`)
return { hour, minute }
}
// 周期提醒:根据 type/config 计算下一次触发时间(晚于 fromDate
export function calculateNextDueAt(type, config, fromDate = new Date()) {
const now = fromDate
const { hour, minute } = parseHourMinute(config.time, 'time')
if (type === 'daily') {
const next = new Date(now)
next.setHours(hour, minute, 0, 0)
if (next <= now) next.setDate(next.getDate() + 1)
return next
}
if (type === 'weekly') {
const targetWeekday = Number(config.weekday)
if (!Number.isInteger(targetWeekday) || targetWeekday < 0 || targetWeekday > 6) {
throw new Error('weekday must be an integer from 0 to 6 (0=Sunday)')
}
const next = new Date(now)
next.setHours(hour, minute, 0, 0)
let diff = (targetWeekday - now.getDay() + 7) % 7
if (diff === 0 && next <= now) diff = 7
next.setDate(next.getDate() + diff)
return next
}
if (type === 'monthly') {
const targetDay = Number(config.day_of_month)
if (!Number.isInteger(targetDay) || targetDay < 1 || targetDay > 31) {
throw new Error('day_of_month must be an integer from 1 to 31')
}
let year = now.getFullYear(), month = now.getMonth()
for (let i = 0; i < 12; i++) {
const lastDay = new Date(year, month + 1, 0).getDate()
if (targetDay <= lastDay) {
const next = new Date(year, month, targetDay, hour, minute, 0, 0)
if (next > now) return next
}
month++
if (month > 11) { month = 0; year++ }
}
throw new Error('Could not find the next matching month')
}
throw new Error(`Unknown recurrence kind: ${type}`)
}
function buildSystemMessage(targetId, taskText) {
return `I am the system. Based on the reminder you set, you now need to perform this task for user ${targetId}: ${taskText}. Handle it immediately, and when needed use send_message to send the result to ${targetId}.`
}
function formatReminderRow(r) {
const recurrence = r.recurrence_type
? `[${r.recurrence_type}] ${(() => {
try {
const c = JSON.parse(r.recurrence_config || '{}')
if (r.recurrence_type === 'daily') return `每天 ${c.time}`
if (r.recurrence_type === 'weekly') {
const names = ['周日','周一','周二','周三','周四','周五','周六']
return `${names[c.weekday]} ${c.time}`
}
if (r.recurrence_type === 'monthly') return `每月 ${c.day_of_month} ${c.time}`
return JSON.stringify(c)
} catch { return '' }
})()}`
: '[once]'
return `#${r.id} ${recurrence} 下次 ${r.due_at}${r.user_id}${r.task}`
}
async function execManageReminder(args, context = {}) {
const action = args.action || (args.due_at || args.kind ? 'create' : null)
if (!action) return '错误:未提供 actioncreate/list/cancel'
if (action === 'list') {
const rows = listPendingReminders(50)
if (!rows.length) return '当前没有待触发的提醒。'
return `${rows.length} 条待触发提醒:\n` + rows.map(formatReminderRow).join('\n')
}
if (action === 'cancel') {
const id = Number(args.id)
if (!Number.isInteger(id) || id <= 0) return '错误cancel 需要提供合法的提醒 id'
const existing = getReminderById(id)
if (!existing) return `错误:未找到提醒 #${id}`
if (existing.status !== 'pending') return `错误:提醒 #${id} 当前状态为 ${existing.status},无法取消`
const result = cancelReminder(id)
if (!result.changes) return `错误:取消提醒 #${id} 失败`
emitEvent('reminder_cancelled', { id, user_id: existing.user_id, task: existing.task })
return `提醒 #${id} 已取消(${existing.task}`
}
if (action !== 'create') return `错误:未知 action "${action}",仅支持 create/list/cancel`
const { task } = args
if (!task?.trim()) return '错误:未提供 task'
const taskText = task.trim()
const fallbackTargetId = context.visibleTargetIds?.[0] || context.allowedTargetIds?.[0] || PRIMARY_USER_ID
const resolvedTargetId = resolveAllowedTargetId(args.target_id || fallbackTargetId, context.allowedTargetIds)
const kind = args.kind || 'once'
if (kind === 'once') {
const dueAt = parseReminderDueAt(args.due_at)
if (dueAt.getTime() <= Date.now()) throw new Error('提醒时间必须晚于当前时间')
const isoDueAt = dueAt.toISOString()
const minuteKey = isoDueAt.slice(0, 16)
const mergeTarget = findMergeableOneOffReminder(resolvedTargetId, minuteKey)
if (mergeTarget) {
const mergedTaskText = `${mergeTarget.task}; ${taskText}`
const newSystemMessage = buildSystemMessage(resolvedTargetId, mergedTaskText)
const r = appendReminderTask(mergeTarget.id, taskText, newSystemMessage)
if (!r.changes) return `错误:合并提醒 #${mergeTarget.id} 失败`
emitEvent('reminder_merged', { id: mergeTarget.id, user_id: resolvedTargetId, due_at: mergeTarget.due_at, task: mergedTaskText })
return `已合并到现有提醒 #${mergeTarget.id}(同时间),合并后任务:${mergedTaskText}`
}
const result = createReminder({
userId: resolvedTargetId,
dueAt: isoDueAt,
task: taskText,
systemMessage: buildSystemMessage(resolvedTargetId, taskText),
source: `tool:manage_reminder@${nowTimestamp()}`,
})
emitEvent('reminder_created', { id: Number(result.lastInsertRowid), user_id: resolvedTargetId, due_at: isoDueAt, task: taskText })
return `提醒已创建:#${result.lastInsertRowid},将在 ${isoDueAt} 触发,目标用户 ${resolvedTargetId}`
}
// 周期提醒
const config = {}
if (kind === 'daily') {
config.time = args.time
} else if (kind === 'weekly') {
config.time = args.time
config.weekday = args.weekday
} else if (kind === 'monthly') {
config.time = args.time
config.day_of_month = args.day_of_month
} else {
throw new Error(`未知的 kind "${kind}",支持 once/daily/weekly/monthly`)
}
const nextDate = calculateNextDueAt(kind, config)
const isoDueAt = nextDate.toISOString()
const result = createReminder({
userId: resolvedTargetId,
dueAt: isoDueAt,
task: taskText,
systemMessage: buildSystemMessage(resolvedTargetId, taskText),
source: `tool:manage_reminder@${nowTimestamp()}`,
recurrenceType: kind,
recurrenceConfig: config,
})
emitEvent('reminder_created', { id: Number(result.lastInsertRowid), user_id: resolvedTargetId, due_at: isoDueAt, task: taskText, recurrence_type: kind, recurrence_config: config })
return `周期提醒已创建:#${result.lastInsertRowid} (${kind}),下次触发 ${isoDueAt},目标用户 ${resolvedTargetId}`
}
// read_file读取文件内容
async function execReadFile(args, context = {}) {
throwIfAborted(context.signal)
const rawPath = args.path || args.filename || args.file_path
if (!rawPath) return '错误:未提供文件路径'
const filePath = normalizeSandboxPath(rawPath)
const resolved = path.resolve(SANDBOX_ROOT, filePath)
assertInSandbox(resolved)
return fs.readFileSync(resolved, 'utf-8')
}
// list_dir列出目录内容
async function execListDir(args, context = {}) {
throwIfAborted(context.signal)
const rawPath = args.path || args.dir || args.directory || '.'
const dirPath = normalizeSandboxPath(rawPath)
const resolved = path.resolve(SANDBOX_ROOT, dirPath)
assertInSandbox(resolved)
const entries = fs.readdirSync(resolved, { withFileTypes: true })
const result = entries.map(e => {
const type = e.isDirectory() ? '[目录]' : '[文件]'
return `${type} ${e.name}`
}).join('\n')
const relDisplay = dirPath === '.' ? '.' : dirPath.replace(/\\/g, '/')
return `目录(相对路径):${relDisplay}\n\n${result || '(空目录)'}`
}
const PROTECTED_FILES = new Set(['readme.txt', 'world.txt', 'package.json'])
// write_file写入文件
async function execWriteFile(args, context = {}) {
throwIfAborted(context.signal)
const rawPath = args.path || args.filename || args.file_path
const content = args.content ?? args.text ?? args.data
if (!rawPath) return '错误:未提供文件路径'
if (content === undefined) return '错误:未提供写入内容'
const filePath = normalizeSandboxPath(rawPath)
if (PROTECTED_FILES.has(path.basename(filePath).toLowerCase())) {
return `错误:${path.basename(filePath)} 是系统文件,不可修改`
}
const resolved = path.resolve(SANDBOX_ROOT, filePath)
assertInSandbox(resolved)
// 确保目录存在
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, content, 'utf-8')
const verifiedContent = fs.readFileSync(resolved, 'utf-8')
const verified = verifiedContent === String(content)
const bytes = Buffer.byteLength(verifiedContent, 'utf-8')
if (!verified) {
return toolJson({
ok: false,
tool: 'write_file',
path: filePath,
absolute_path: resolved,
bytes,
verified: false,
error: 'read-back verification did not match written content',
})
}
return toolJson({
ok: true,
tool: 'write_file',
path: filePath,
absolute_path: resolved,
bytes,
verified: true,
content_preview: verifiedContent.slice(0, 120),
})
}
// delete_file删除沙盒内的文件或目录
async function execDeleteFile(args, context = {}) {
throwIfAborted(context.signal)
const rawPath = args.path || args.filename || args.file_path
if (!rawPath) return '错误:未提供路径'
const filePath = normalizeSandboxPath(rawPath)
if (PROTECTED_FILES.has(path.basename(filePath).toLowerCase())) {
return `错误:${path.basename(filePath)} 是系统文件,不可删除`
}
const resolved = path.resolve(SANDBOX_ROOT, filePath)
assertInSandbox(resolved)
if (!fs.existsSync(resolved)) return `错误:路径不存在:${filePath}`
const stat = fs.statSync(resolved)
if (stat.isDirectory()) {
fs.rmSync(resolved, { recursive: true, force: true })
const verifiedAbsent = !fs.existsSync(resolved)
return toolJson({
ok: verifiedAbsent,
tool: 'delete_file',
path: filePath,
kind: 'directory',
verified_absent: verifiedAbsent,
})
} else {
fs.unlinkSync(resolved)
const verifiedAbsent = !fs.existsSync(resolved)
return toolJson({
ok: verifiedAbsent,
tool: 'delete_file',
path: filePath,
kind: 'file',
verified_absent: verifiedAbsent,
})
}
}
// make_dir在沙盒内创建目录支持多级
async function execMakeDir(args, context = {}) {
throwIfAborted(context.signal)
const rawPath = args.path || args.dir || args.directory
if (!rawPath) return '错误:未提供目录路径'
const dirPath = normalizeSandboxPath(rawPath)
const resolved = path.resolve(SANDBOX_ROOT, dirPath)
assertInSandbox(resolved)
fs.mkdirSync(resolved, { recursive: true })
const verified = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()
return toolJson({
ok: verified,
tool: 'make_dir',
path: dirPath,
absolute_path: resolved,
verified,
})
}
function resolveExecCwd(cwdArg) {
if (!cwdArg) return config.security?.execSandbox === false ? process.cwd() : SANDBOX_ROOT
if (config.security?.execSandbox === false) return path.resolve(process.cwd(), cwdArg)
const resolved = path.resolve(SANDBOX_ROOT, cwdArg)
assertInSandbox(resolved)
return resolved
}
// exec_command在沙盒目录内执行 shell 命令
// background=true 时后台运行,返回 PID否则等待完成返回输出
async function execCommand(args, context = {}) {
throwIfAborted(context.signal)
const command = String(args.command || args.cmd || '').trim()
if (!command) return toolJson({ ok: false, tool: 'exec_command', error: 'missing command' })
const background = args.background === true || args.background === 'true'
const promoteToBackground = args.promote_to_background === true || args.promote_to_background === 'true'
// schema 说明单位是秒,转换为毫秒;兼容旧调用(如果传入 >1000 视为已是毫秒)
const rawTimeout = Number(args.timeout) || 30
const timeoutMs = Math.max(1000, Math.min(rawTimeout < 1000 ? rawTimeout * 1000 : rawTimeout, 120000))
let execCwd
try {
execCwd = resolveExecCwd(args.cwd || '')
} catch (err) {
return toolJson({ ok: false, tool: 'exec_command', error: err.message })
}
console.log(`[exec_command] ${background ? '[后台]' : '[前台]'} ${command} (cwd: ${execCwd})`)
emitEvent('exec_command', { command, background, cwd: execCwd })
if (background) {
return execBackground(command, execCwd)
} else {
return execForeground(command, timeoutMs, context.signal, execCwd, promoteToBackground)
}
}
function toolJson(payload) {
return JSON.stringify(payload, null, 2)
}
function trimCommandOutput(value = '', max = 6000) {
const text = String(value || '')
if (text.length <= max) return text
return `${text.slice(0, max)}\n\n[输出已截断,原始长度 ${text.length} 字符,仅保留前 ${max} 字符]`
}
function execBackground(command, execCwd) {
const child = spawnShellCommand(command, {
cwd: execCwd,
detached: false,
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
const pid = child.pid
if (!pid) {
return toolJson({
ok: false,
tool: 'exec_command',
mode: 'background',
command,
cwd: execCwd,
error: 'process did not start',
})
}
const startedAt = nowTimestamp()
const entry = { process: child, command, startedAt, outputLines: [] }
bgProcesses.set(pid, entry)
child.on('exit', (code) => {
console.log(`[exec_command] 后台进程 PID ${pid} 退出code=${code}`)
bgProcesses.delete(pid)
emitEvent('process_exit', { pid, command, code })
})
const pushOutputLine = (stream, data) => {
const text = data.toString()
entry.outputLines.push({ stream, text, ts: Date.now() })
if (entry.outputLines.length > BG_OUTPUT_MAX_LINES) entry.outputLines.shift()
emitEvent('process_output', { pid, stream, text: text.slice(0, 500) })
}
child.stdout?.on('data', (data) => pushOutputLine('stdout', data))
child.stderr?.on('data', (data) => pushOutputLine('stderr', data))
return toolJson({
ok: true,
tool: 'exec_command',
mode: 'background',
command,
cwd: execCwd,
pid,
started_at: startedAt,
hint: 'Process is running in the background. Use list_processes to inspect it or kill_process with this pid to stop it.',
})
}
function execForeground(command, timeoutMs, signal, execCwd, promoteToBackground = false) {
return new Promise((resolve) => {
throwIfAborted(signal)
const child = spawnShellCommand(command, { cwd: execCwd })
child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
let stdout = ''
let stderr = ''
let timedOut = false
let settled = false
let timer = null
const finish = (value) => {
if (settled) return
settled = true
if (timer) clearTimeout(timer)
merged?.cleanup()
resolve(value)
}
const merged = createMergedAbortSignal(signal)
const onAbort = () => {
child.kill()
finish(toolJson({
ok: false,
tool: 'exec_command',
mode: 'foreground',
command,
cwd: execCwd,
aborted: true,
stdout: trimCommandOutput(stdout),
stderr: trimCommandOutput(stderr),
error: 'command aborted',
}))
}
if (merged?.signal.aborted) {
child.kill()
finish(toolJson({
ok: false,
tool: 'exec_command',
mode: 'foreground',
command,
cwd: execCwd,
aborted: true,
stdout: '',
stderr: '',
error: 'command aborted before start',
}))
return
}
merged?.signal.addEventListener('abort', onAbort, { once: true })
timer = setTimeout(() => {
timedOut = true
if (promoteToBackground && child.pid) {
const pid = child.pid
const entry = { process: child, command, startedAt: nowTimestamp(), outputLines: [] }
bgProcesses.set(pid, entry)
child.stdout?.on('data', (data) => {
const text = data.toString()
entry.outputLines.push({ stream: 'stdout', text, ts: Date.now() })
if (entry.outputLines.length > BG_OUTPUT_MAX_LINES) entry.outputLines.shift()
emitEvent('process_output', { pid, stream: 'stdout', text: text.slice(0, 500) })
})
child.stderr?.on('data', (data) => {
const text = data.toString()
entry.outputLines.push({ stream: 'stderr', text, ts: Date.now() })
if (entry.outputLines.length > BG_OUTPUT_MAX_LINES) entry.outputLines.shift()
emitEvent('process_output', { pid, stream: 'stderr', text: text.slice(0, 500) })
})
child.on('exit', (code) => {
console.log(`[exec_command] 提升后台进程 PID ${pid} 退出code=${code}`)
bgProcesses.delete(pid)
emitEvent('process_exit', { pid, command, code })
})
finish(toolJson({
ok: true,
tool: 'exec_command',
mode: 'promoted_to_background',
command,
cwd: execCwd,
pid,
stdout: trimCommandOutput(stdout),
stderr: trimCommandOutput(stderr),
hint: `Foreground timed out after ${timeoutMs / 1000}s — process promoted to background with pid ${pid}. Use list_processes to monitor it.`,
}))
} else {
child.kill()
finish(toolJson({
ok: false,
tool: 'exec_command',
mode: 'foreground',
command,
cwd: execCwd,
timed_out: true,
timeout_ms: timeoutMs,
stdout: trimCommandOutput(stdout),
stderr: trimCommandOutput(stderr),
error: `command timed out after ${timeoutMs / 1000}s`,
hint: 'If this is a long-running server, rerun with background=true or set promote_to_background=true.',
}))
}
}, timeoutMs)
child.stdout?.on('data', (d) => {
if (timedOut) return
const text = d.toString()
stdout += text
emitEvent('exec_output', { mode: 'foreground', stream: 'stdout', command, text: text.slice(0, 300) })
})
child.stderr?.on('data', (d) => {
if (timedOut) return
const text = d.toString()
stderr += text
emitEvent('exec_output', { mode: 'foreground', stream: 'stderr', command, text: text.slice(0, 300) })
})
child.on('close', (code) => {
if (timedOut) return
finish(toolJson({
ok: code === 0,
tool: 'exec_command',
mode: 'foreground',
command,
cwd: execCwd,
exit_code: code,
stdout: trimCommandOutput(stdout),
stderr: trimCommandOutput(stderr),
error: code === 0 ? null : `command exited with code ${code}`,
hint: code === 0 ? 'Command completed successfully.' : 'Inspect stderr/stdout before retrying or changing the command.',
}))
})
child.on('error', (err) => {
if (timedOut) return
finish(toolJson({
ok: false,
tool: 'exec_command',
mode: 'foreground',
command,
cwd: execCwd,
stdout: trimCommandOutput(stdout),
stderr: trimCommandOutput(stderr),
error: err.message,
}))
})
})
}
// kill_process停止后台进程通过 PID
async function execKillProcess(args) {
const pid = Number(args.pid)
if (!pid) return toolJson({ ok: false, tool: 'kill_process', error: 'missing pid' })
const entry = bgProcesses.get(pid)
if (!entry) return toolJson({ ok: false, tool: 'kill_process', pid, error: 'process not found or already exited' })
entry.process.kill()
bgProcesses.delete(pid)
return toolJson({
ok: true,
tool: 'kill_process',
pid,
command: entry.command,
stopped: true,
})
}
// list_processes列出当前后台进程包含最近输出行
async function execListProcesses(args = {}) {
const tailLines = Math.min(Number(args.tail) || 20, BG_OUTPUT_MAX_LINES)
const processes = [...bgProcesses.entries()].map(([pid, { command, startedAt, outputLines }]) => ({
pid,
command,
started_at: startedAt,
recent_output: outputLines.slice(-tailLines).map(({ stream, text, ts }) => ({ stream, text, ts })),
}))
return toolJson({
ok: true,
tool: 'list_processes',
count: processes.length,
processes,
})
}
const WEB_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
// 从 config.json 或 process.env 读取上网工具配置
// 5 秒内复用结果,避免一次 web_search 在 5 引擎 fallback 时同步读盘 5 次
let _webConfigCache = null
let _webConfigFetchedAt = 0
const WEB_CONFIG_TTL_MS = 5000
function readWebConfig() {
const now = Date.now()
if (_webConfigCache && now - _webConfigFetchedAt < WEB_CONFIG_TTL_MS) {
return _webConfigCache
}
_webConfigCache = getWebSearchCredentials()
_webConfigFetchedAt = now
return _webConfigCache
}
// 单例浏览器:避免每次 browser_read 冷启动 Chromium耗时 3~5 秒)
let _sharedBrowser = null
let _sharedBrowserLastUsed = 0
let _playwrightChromium = null
const BROWSER_IDLE_TIMEOUT_MS = 10 * 60 * 1000 // 闲置 10 分钟后关掉
async function getSharedBrowser() {
const now = Date.now()
if (_sharedBrowser && now - _sharedBrowserLastUsed > BROWSER_IDLE_TIMEOUT_MS) {
try { await _sharedBrowser.close() } catch {}
_sharedBrowser = null
}
if (!_sharedBrowser) {
_sharedBrowser = await launchReadableBrowser()
}
_sharedBrowserLastUsed = Date.now()
return _sharedBrowser
}
function invalidateSharedBrowser() {
_sharedBrowser = null
}
const BROWSER_VIEWPORT = { width: 1365, height: 900 }
function webJson(payload) {
return JSON.stringify(payload)
}
function normalizeWebUrl(raw) {
const value = String(raw || '').trim()
if (!value) return ''
if (/^https?:\/\//i.test(value)) return value
return `https://${value}`
}
function decodeHtmlEntities(value = '') {
return String(value)
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(parseInt(code, 16)))
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
function htmlToText(html = '') {
return decodeHtmlEntities(html)
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|section|article|li|h[1-6])>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function extractTitle(html = '') {
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
return match ? htmlToText(match[1]).slice(0, 200) : ''
}
function isLowValuePageText(text = '') {
const compact = String(text || '').replace(/\s+/g, ' ').trim()
if (compact.length < 80) return true
return /^(please wait|just a moment|checking your browser|enable javascript|access denied|forbidden|captcha|安全验证|请稍候|请稍等|正在验证|访问受限)/i.test(compact)
}
// 长文阈值:抓取结果超过此长度时落盘,识别器只看摘要 + body_path
const ARTICLE_LENGTH_THRESHOLD = 2000
const ARTICLE_SUMMARY_EXCERPT = 800
function urlHash8(url) {
return crypto.createHash('sha1').update(String(url || '')).digest('hex').slice(0, 8)
}
function sanitizeSlugPart(value, max = 40) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, max)
}
// 把长文写入 sandbox/articles/{YYYY-MM}/{date}_{titleSlug}_{hash8}.md
// 同 URL 当天再次抓取直接复用已有文件,避免重复落盘
function saveLongArticle({ url, finalUrl, title, body, source }) {
const now = new Date()
const yyyyMm = now.toISOString().slice(0, 7)
const date = now.toISOString().slice(0, 10)
const hash = urlHash8(finalUrl || url || '')
const titleSlug = sanitizeSlugPart(title)
const baseName = titleSlug ? `${date}_${titleSlug}_${hash}.md` : `${date}_${hash}.md`
const monthDir = path.join(SANDBOX_ROOT, 'articles', yyyyMm)
const absPath = path.join(monthDir, baseName)
const relPath = path.posix.join('articles', yyyyMm, baseName)
if (fs.existsSync(absPath)) {
return { path: relPath, bytes: fs.statSync(absPath).size, reused: true }
}
fs.mkdirSync(monthDir, { recursive: true })
const frontmatter = [
'---',
`title: ${JSON.stringify(title || '')}`,
`source_url: ${url || ''}`,
finalUrl && finalUrl !== url ? `final_url: ${finalUrl}` : null,
`source_tool: ${source || 'fetch_url'}`,
`fetched_at: ${now.toISOString()}`,
'---',
'',
].filter(Boolean).join('\n')
const content = frontmatter + (title ? `# ${title}\n\n` : '') + body
fs.writeFileSync(absPath, content, 'utf-8')
return { path: relPath, bytes: Buffer.byteLength(content, 'utf-8'), reused: false }
}
async function launchReadableBrowser() {
const chromium = await getPlaywrightChromium()
const launchOptions = { headless: true }
try {
return await chromium.launch(launchOptions)
} catch (firstError) {
for (const channel of ['msedge', 'chrome']) {
try {
return await chromium.launch({ ...launchOptions, channel })
} catch {}
}
throw firstError
}
}
async function getPlaywrightChromium() {
if (_playwrightChromium) return _playwrightChromium
try {
const mod = await import('playwright')
_playwrightChromium = mod.chromium
return _playwrightChromium
} catch (err) {
throw new Error(`Playwright is not bundled in this build: ${err.message || String(err)}`)
}
}
async function autoScrollPage(page, signal) {
for (let i = 0; i < 4; i++) {
throwIfAborted(signal)
await page.evaluate(() => window.scrollBy(0, Math.max(window.innerHeight, 800)))
await page.waitForTimeout(450)
}
await page.evaluate(() => window.scrollTo(0, 0))
}
function unwrapDuckDuckGoUrl(url) {
const decoded = decodeHtmlEntities(url)
const uddg = decoded.match(/[?&]uddg=([^&]+)/)
if (uddg) {
try { return decodeURIComponent(uddg[1]) } catch { return uddg[1] }
}
if (decoded.startsWith('//')) return `https:${decoded}`
return decoded
}
// Bing 搜索结果常用 bing.com/ck/a?...&u=a1<base64url> 中转链接;
// 不解包的话下游 fetch_url 会拿到跳转壳页而不是真正的目标页
function unwrapBingUrl(url) {
try {
if (!url || !/bing\.com\/ck\/a/i.test(url)) return url
const u = new URL(url)
const raw = u.searchParams.get('u')
if (!raw) return url
let encoded = raw.startsWith('a1') ? raw.slice(2) : raw
encoded = encoded.replace(/-/g, '+').replace(/_/g, '/')
while (encoded.length % 4) encoded += '='
const decoded = Buffer.from(encoded, 'base64').toString('utf-8')
return /^https?:\/\//i.test(decoded) ? decoded : url
} catch {
return url
}
}
function parseDuckDuckGoResults(html, limit) {
const raw = []
const resultRegex = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi
let match
while ((match = resultRegex.exec(html)) !== null) {
const url = unwrapDuckDuckGoUrl(match[1])
const title = htmlToText(match[2])
if (!url || !title) continue
const nextStart = resultRegex.lastIndex
const nextMatch = html.slice(nextStart).match(/<a[^>]+class="result__a"/i)
const block = nextMatch ? html.slice(nextStart, nextStart + nextMatch.index) : html.slice(nextStart, nextStart + 2000)
const snippetMatch = block.match(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>|class="result__snippet"[^>]*>([\s\S]*?)<\/div>/i)
const snippet = htmlToText(snippetMatch?.[1] || snippetMatch?.[2] || '')
raw.push({ title, url, snippet })
}
return normalizeResults(raw, limit)
}
// 引擎返回约定:
// null → 未配置/跳过,不计入失败
// { ok: true, results, source } → 成功
// { ok: false, reason } → 已尝试但失败reason 会聚合到最终错误
//
// reason 用简短可读字符串("http 401"、"empty html"、"blocked or captcha"、"network: ..."
// 让"key 失效"和"被限速"在日志里能分清楚
const SEARCH_TITLE_MAX = 200
const SEARCH_SNIPPET_MAX = 300
const SEARCH_LOG_QUERY_MAX = 100
function hasCJK(s) {
return /[㐀-鿿豈-﫿]/.test(s)
}
function truncateForLog(s, max = SEARCH_LOG_QUERY_MAX) {
const str = String(s || '')
return str.length <= max ? str : `${str.slice(0, max)}…(${str.length})`
}
// 各引擎 raw 结果统一处理:截断超长字段、丢弃空 url/title、按 URL 去重host+path忽略 query/fragment
function normalizeResults(raw, limit) {
const out = []
const seen = new Set()
for (const r of raw) {
const url = String(r?.url || '').trim()
const title = String(r?.title || '').trim().slice(0, SEARCH_TITLE_MAX)
if (!url || !title) continue
let dedupKey
try {
const u = new URL(url)
dedupKey = `${u.host}${u.pathname.replace(/\/$/, '')}`
} catch {
dedupKey = url
}
if (seen.has(dedupKey)) continue
seen.add(dedupKey)
out.push({
title,
url,
snippet: String(r?.snippet || '').trim().slice(0, SEARCH_SNIPPET_MAX),
})
if (out.length >= limit) break
}
return out
}
// web_search 引擎1Serper.devGoogle SERP JSON API最稳定
async function searchViaSerper(query, limit, signal) {
const { serperKey } = readWebConfig()
if (!serperKey) return null
const merged = createMergedAbortSignal(signal, 12000)
try {
const res = await fetch('https://google.serper.dev/search', {
method: 'POST',
headers: {
'X-API-KEY': serperKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
q: query,
num: limit,
hl: hasCJK(query) ? 'zh-cn' : 'en',
gl: hasCJK(query) ? 'cn' : 'us',
}),
signal: merged?.signal,
})
merged?.cleanup()
if (!res.ok) {
const hint = res.status === 401 || res.status === 403 ? ' (check SERPER_API_KEY)' : ''
return { ok: false, reason: `http ${res.status}${hint}` }
}
const data = await res.json()
const raw = (data.organic || []).map(r => ({ title: r.title, url: r.link, snippet: r.snippet }))
const results = normalizeResults(raw, limit)
if (results.length === 0) return { ok: false, reason: 'empty results' }
return { ok: true, results, source: 'serper' }
} catch (err) {
merged?.cleanup()
if (err.name === 'AbortError') throw err
return { ok: false, reason: `network: ${err.message || err}` }
}
}
// web_search 引擎2SearXNG自托管JSON API
async function searchViaSearXNG(query, limit, signal) {
const { searxngUrl } = readWebConfig()
if (!searxngUrl) return null
if (!/^https?:\/\//i.test(searxngUrl)) {
return { ok: false, reason: 'SEARXNG_URL must start with http:// or https://' }
}
const base = searxngUrl.replace(/\/$/, '').replace(/\/search$/i, '')
const url = `${base}/search?q=${encodeURIComponent(query)}&format=json&pageno=1`
const merged = createMergedAbortSignal(signal, 12000)
try {
const res = await fetch(url, { headers: { 'Accept': 'application/json' }, signal: merged?.signal })
merged?.cleanup()
if (!res.ok) return { ok: false, reason: `http ${res.status}` }
const data = await res.json()
const raw = (data.results || []).map(r => ({ title: r.title, url: r.url, snippet: r.content }))
const results = normalizeResults(raw, limit)
if (results.length === 0) return { ok: false, reason: 'empty results' }
return { ok: true, results, source: 'searxng' }
} catch (err) {
merged?.cleanup()
if (err.name === 'AbortError') throw err
return { ok: false, reason: `network: ${err.message || err}` }
}
}
// web_search 引擎3Jina Searchs.jina.ai无 key 时也能试,但现在 Jina 新版 API 要 key
async function searchViaJina(query, limit, signal) {
const { jinaKey } = readWebConfig()
const url = `https://s.jina.ai/${encodeURIComponent(query)}`
const merged = createMergedAbortSignal(signal, 18000)
const headers = {
'Accept': 'text/plain',
'X-Respond-With': 'no-references',
'User-Agent': WEB_HEADERS['User-Agent'],
}
if (jinaKey) headers['Authorization'] = `Bearer ${jinaKey}`
try {
const res = await fetch(url, { headers, signal: merged?.signal })
merged?.cleanup()
if (!res.ok) {
let hint = ''
if (res.status === 401 || res.status === 403) hint = jinaKey ? ' (check jina_api_key)' : ' (jina now requires api key, set it in 设置 → 上网)'
else if (res.status === 429) hint = ' (rate-limited)'
return { ok: false, reason: `http ${res.status}${hint}` }
}
const text = (await res.text()).trim()
if (!text) return { ok: false, reason: 'empty body' }
// 短到不可能是正常 SERPJina 限流时常返 200 + 几十字提示)
if (text.length < 50) return { ok: false, reason: `short body (${text.length} chars, likely rate-limited)` }
// Jina Search 返回格式:
// [1] 标题
// URL: https://...
// Description: 摘要...
//
// [2] ...
const raw = []
const blocks = text.split(/\n(?=\[\d+\])/)
for (const block of blocks) {
const titleMatch = block.match(/^\[\d+\]\s*(.+)/)
const urlMatch = block.match(/^URL:\s*(\S+)/m)
const descMatch = block.match(/^Description:\s*(.+)/m)
if (titleMatch && urlMatch) {
raw.push({ title: titleMatch[1], url: urlMatch[1], snippet: descMatch?.[1] || '' })
}
}
const results = normalizeResults(raw, limit)
if (results.length === 0) return { ok: false, reason: 'parsed 0 results (format may have changed)' }
return { ok: true, results, source: 'jina_search' }
} catch (err) {
merged?.cleanup()
if (err.name === 'AbortError') throw err
return { ok: false, reason: `network: ${err.message || err}` }
}
}
// web_search 引擎3bBing国内可访问HTML 解析)
async function searchViaBing(query, limit, signal) {
const searchUrl = `https://cn.bing.com/search?q=${encodeURIComponent(query)}&setlang=zh-CN`
const merged = createMergedAbortSignal(signal, 15000)
try {
const res = await fetch(searchUrl, {
headers: { ...WEB_HEADERS, 'Accept-Language': 'zh-CN,zh;q=0.9' },
signal: merged?.signal,
})
merged?.cleanup()
if (!res.ok) return { ok: false, reason: `http ${res.status}` }
const html = await res.text()
// Bing 的 <li class="b_algo"> 不闭合 </li>,按下一个 b_algo 切块更稳
const parts = html.split(/<li class="b_algo"/i).slice(1)
const raw = []
for (const part of parts) {
// 标题在 <h2><a href="...">...内可能嵌 <strong>...</a></h2>
const headerMatch = part.match(/<h2[^>]*>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/i)
if (!headerMatch) continue
const url = unwrapBingUrl(headerMatch[1])
const title = htmlToText(headerMatch[2])
if (!title || !url) continue
// 摘要:优先 b_lineclamp* / b_caption 内的 <p>,兜底取第一个有内容的 <p>
const snippetMatch =
part.match(/<p[^>]*class="[^"]*b_lineclamp[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
part.match(/class="[^"]*b_caption[^"]*"[^>]*>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/i) ||
part.match(/<p[^>]*>([\s\S]{30,}?)<\/p>/i)
const snippet = snippetMatch ? htmlToText(snippetMatch[1]) : ''
raw.push({ title, url, snippet })
}
const results = normalizeResults(raw, limit)
if (results.length === 0) {
const blocked = /sorry|captcha|verify|访问被拒绝/i.test(html.slice(0, 4000))
let reason
if (blocked) reason = 'blocked or captcha'
else if (parts.length === 0) reason = 'no b_algo found (layout may have changed)'
else reason = `found ${parts.length} b_algo blocks but parsed 0 (h2>a structure may have changed)`
return { ok: false, reason }
}
return { ok: true, results, source: 'bing' }
} catch (err) {
merged?.cleanup()
if (err.name === 'AbortError') throw err
return { ok: false, reason: `network: ${err.message || err}` }
}
}
// web_search 引擎4DuckDuckGo HTML最后兜底不稳定
async function searchViaDDG(query, limit, signal) {
const searchUrl = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`
const merged = createMergedAbortSignal(signal, 15000)
try {
const res = await fetch(searchUrl, { headers: WEB_HEADERS, signal: merged?.signal })
merged?.cleanup()
if (!res.ok) return { ok: false, reason: `http ${res.status}` }
const html = await res.text()
// DDG 返回 403/CAPTCHA 页时 HTML 中不含 result__a
if (!html.includes('result__a')) return { ok: false, reason: 'blocked or captcha (no result__a)' }
const results = parseDuckDuckGoResults(html, limit)
if (results.length === 0) return { ok: false, reason: 'parsed 0 results' }
return { ok: true, results, source: 'duckduckgo' }
} catch (err) {
merged?.cleanup()
if (err.name === 'AbortError') throw err
return { ok: false, reason: `network: ${err.message || err}` }
}
}
async function execWebSearch(args, context = {}) {
throwIfAborted(context.signal)
const query = String(args.query || args.q || args.keyword || '').trim()
const limit = Math.max(1, Math.min(Number(args.limit) || 5, 8))
if (!query) return webJson({ ok: false, tool: 'web_search', error: 'missing query' })
const cacheKey = `${query}::${limit}`
const cached = searchCacheGet(cacheKey)
if (cached) return webJson({ ...cached, cached: true })
console.log(`[web_search] ${truncateForLog(query)}`)
// 依次尝试Serper → SearXNG → Bing国内可访问→ Jina Search → DuckDuckGo兜底
const engines = [
['serper', searchViaSerper],
['searxng', searchViaSearXNG],
['bing', searchViaBing],
['jina', searchViaJina],
['ddg', searchViaDDG],
]
const failures = []
for (const [name, engine] of engines) {
throwIfAborted(context.signal)
let result
try {
result = await engine(query, limit, context.signal)
} catch (err) {
if (err.name === 'AbortError') throw err
failures.push({ engine: name, reason: `threw: ${err.message || err}` })
console.log(`[web_search] ${name} threw: ${err.message || err}`)
continue
}
if (result == null) continue // 未配置
if (result.ok) {
const payload = {
ok: true, tool: 'web_search', query,
source: result.source,
results: result.results,
hint: 'Open 1-3 reliable result URLs with fetch_url, then answer the user.',
}
searchCacheSet(cacheKey, payload)
return webJson(payload)
}
failures.push({ engine: name, reason: result.reason || 'unknown' })
console.log(`[web_search] ${name} failed: ${result.reason || 'unknown'}`)
}
const summary = failures.length
? failures.map(f => `${f.engine}: ${f.reason}`).join('; ')
: 'no engine configured'
return webJson({
ok: false, tool: 'web_search', query,
error: `all search engines failed (${summary})`,
failures,
hint: 'All search engines failed. Try fetch_url with a known URL, or configure SERPER_API_KEY for reliable search.',
})
}
// fetch_url 策略一Jina Readerr.jina.ai
// 服务端 Chromium 渲染 + Mozilla Readability免费无需 key支持 JS 页面
async function fetchViaJina(url, signal) {
const jinaUrl = `https://r.jina.ai/${url}`
const merged = createMergedAbortSignal(signal, 20000)
try {
const res = await fetch(jinaUrl, {
headers: {
'Accept': 'text/plain',
'X-Return-Format': 'markdown',
'X-Timeout': '15',
'User-Agent': WEB_HEADERS['User-Agent'],
},
signal: merged?.signal,
})
merged?.cleanup()
if (!res.ok) return null
const text = (await res.text()).trim()
if (isLowValuePageText(text)) return null
// Jina 返回格式:第一行是 "Title: xxx",第二行空行,然后是正文 Markdown
let title = ''
let body = text
const titleMatch = text.match(/^Title:\s*(.+)/m)
if (titleMatch) {
title = titleMatch[1].trim()
body = text.replace(/^Title:.*\n?/m, '').replace(/^URL Source:.*\n?/m, '').replace(/^Markdown Content:\n?/m, '').trim()
}
return { title, body, source: 'jina' }
} catch (err) {
merged?.cleanup()
if (err.name === 'AbortError') throw err
return null
}
}
// fetch_url 策略二:直接 HTTP + 正则 HTML 转文本(兜底,适合简单静态页)
async function fetchViaDirect(url, signal) {
const merged = createMergedAbortSignal(signal, 12000)
try {
const res = await fetch(url, { headers: WEB_HEADERS, signal: merged?.signal })
merged?.cleanup()
if (!res.ok) return { ok: false, status: res.status }
const contentType = res.headers.get('content-type') || ''
if (contentType && !/text|html|xml|json/i.test(contentType)) {
return { ok: false, status: res.status, content_type: contentType }
}
const html = await res.text()
const text = htmlToText(html)
const title = extractTitle(html)
if (isLowValuePageText(text)) return { ok: false, status: res.status, title, low_value: true }
return { ok: true, status: res.status, title, body: text }
} catch (err) {
merged?.cleanup()
if (err.name === 'AbortError') throw err
return { ok: false, error: err.message }
}
}
// fetch_url: open a known URL, extract readable text, and return structured JSON.
async function execFetchUrl(args, context = {}) {
throwIfAborted(context.signal)
const url = normalizeWebUrl(args.url || args.URL || args.link || args.href || args.uri)
if (!url) return webJson({ ok: false, tool: 'fetch_url', error: 'missing url' })
const cached = urlCache.get(url)
const ttl = getUrlTtl(url)
if (cached && Date.now() - cached.fetchedAt < ttl) {
const ageMin = Math.round((Date.now() - cached.fetchedAt) / 60000)
return webJson({ ...cached.payload, cached: true, cache_age_minutes: ageMin })
}
console.log(`[fetch_url] -> ${url}`)
// 策略一Jina Reader处理 JS 页面、Cloudflare 防护、内容提取质量最好)
throwIfAborted(context.signal)
let title = ''
let text = ''
let fetchSource = 'jina'
let httpStatus = null
const jinaResult = await fetchViaJina(url, context.signal)
if (jinaResult) {
title = jinaResult.title
text = jinaResult.body
} else {
// 策略二:直接 HTTP静态页面兜底
console.log(`[fetch_url] jina failed, trying direct: ${url}`)
fetchSource = 'direct'
const directResult = await fetchViaDirect(url, context.signal)
httpStatus = directResult.status
if (!directResult.ok) {
const hint = directResult.low_value
? 'The page requires JavaScript or blocks crawlers. Use browser_read instead.'
: 'This page could not be read. Use web_search to find another accessible source.'
return webJson({
ok: false, tool: 'fetch_url', url,
status: directResult.status,
content_type: directResult.content_type,
error: directResult.error || (directResult.low_value ? 'no readable content' : `HTTP ${directResult.status}`),
hint,
})
}
title = directResult.title || ''
text = directResult.body || ''
}
const MAX = 5000
const isLong = text.length >= ARTICLE_LENGTH_THRESHOLD
let bodyPath = null
let bodyBytes = null
if (isLong) {
try {
const saved = saveLongArticle({ url, finalUrl: url, title, body: text, source: fetchSource })
bodyPath = saved.path
bodyBytes = saved.bytes
} catch (err) {
console.warn(`[fetch_url] 长文落盘失败: ${err.message}`)
}
}
const content = isLong
? `${text.slice(0, ARTICLE_SUMMARY_EXCERPT)}\n\n...`
: (text.length > MAX ? `${text.slice(0, MAX)}\n\n...` : text)
const payload = {
ok: true,
tool: 'fetch_url',
url,
status: httpStatus,
fetch_source: fetchSource,
title,
content,
truncated: isLong || text.length > MAX,
content_length: text.length,
body_path: bodyPath,
body_bytes: bodyBytes,
hint: bodyPath
? `Long article saved. Full text at sandbox path: ${bodyPath}. Use read_file to open it.`
: 'Use this page content with other sources if needed, then answer the user.',
}
urlCache.set(url, { payload, fetchedAt: Date.now() })
return webJson(payload)
}
async function execBrowserRead(args, context = {}) {
throwIfAborted(context.signal)
const url = normalizeWebUrl(args.url || args.URL || args.link || args.href || args.uri)
if (!url) return webJson({ ok: false, tool: 'browser_read', error: 'missing url' })
const timeoutMs = Math.max(5000, Math.min(Number(args.timeout_ms || args.timeout || 20000), 45000))
const maxChars = Math.max(1000, Math.min(Number(args.max_chars || args.maxChars || 8000), 12000))
console.log(`[browser_read] -> ${url}`)
let browserContext = null
let page = null
try {
// 复用单例浏览器,避免每次冷启动 Chromium约 3~5 秒)
const browser = await getSharedBrowser()
browserContext = await browser.newContext({
viewport: BROWSER_VIEWPORT,
locale: 'zh-CN',
userAgent: WEB_HEADERS['User-Agent'],
})
page = await browserContext.newPage()
page.setDefaultTimeout(timeoutMs)
page.setDefaultNavigationTimeout(timeoutMs)
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs })
// networkidle 可能挂死,限制等待时间
await page.waitForLoadState('networkidle', { timeout: Math.min(timeoutMs, 8000) }).catch(() => {})
await autoScrollPage(page, context.signal)
const title = (await page.title()).trim()
const text = await page.evaluate(() => {
;['script', 'style', 'noscript', 'svg', 'canvas', 'iframe', 'header', 'footer', 'nav'].forEach(
tag => document.querySelectorAll(tag).forEach(el => el.remove())
)
// 优先取语义容器,取文本最长的那个
const candidates = [
...document.querySelectorAll('article, main, [role="main"], .article, .post, .content, .entry-content, #content, #main'),
]
const best = candidates
.map(el => ({ el, text: (el.innerText || '').trim() }))
.sort((a, b) => b.text.length - a.text.length)[0]
return (best?.text && best.text.length > 300 ? best.text : document.body?.innerText || '').trim()
})
const finalUrl = page.url()
if (isLowValuePageText(text)) {
return webJson({
ok: false,
tool: 'browser_read',
url,
final_url: finalUrl,
title,
error: 'no readable content rendered',
content_preview: String(text || '').slice(0, 300),
content_length: String(text || '').length,
hint: 'The browser opened the page, but did not find readable article text. The page may require login, CAPTCHA, or block automation. Try another source.',
})
}
const isLong = text.length >= ARTICLE_LENGTH_THRESHOLD
let bodyPath = null
let bodyBytes = null
if (isLong) {
try {
const saved = saveLongArticle({ url, finalUrl, title, body: text, source: 'browser_read' })
bodyPath = saved.path
bodyBytes = saved.bytes
} catch (err) {
console.warn(`[browser_read] 长文落盘失败: ${err.message}`)
}
}
const content = isLong
? `${text.slice(0, ARTICLE_SUMMARY_EXCERPT)}\n\n...`
: (text.length > maxChars ? `${text.slice(0, maxChars)}\n\n...` : text)
return webJson({
ok: true,
tool: 'browser_read',
url,
final_url: finalUrl,
title,
content,
truncated: isLong || text.length > maxChars,
content_length: text.length,
body_path: bodyPath,
body_bytes: bodyBytes,
hint: bodyPath
? `Long article saved. Full text at sandbox path: ${bodyPath}. Use read_file to open it.`
: 'Rendered page content extracted by Chromium.',
})
} catch (err) {
if (err.name === 'AbortError') throw err
// 浏览器崩溃或断开时,清掉单例让下次重建
invalidateSharedBrowser()
return webJson({
ok: false,
tool: 'browser_read',
url,
error: err.message || String(err),
hint: 'Browser rendering failed. Try fetch_url or another accessible source.',
})
} finally {
// 关 context含页面不关 browser单例复用
try { await page?.close() } catch {}
try { await browserContext?.close() } catch {}
}
}
// search_memory批量按关键词检索记忆。
// 优先走 keywords 数组;为兼容旧调用方,单字符串 keyword 也接受(自动转数组)。
// 输入有 keywords 时返回 JSON 字符串(结构化命中 + matched_by用于识别器查重。
// 输入只有 keyword 时返回旧版拼接字符串,用于主对话主动检索。
async function execSearchMemory(args = {}) {
const { keyword, limit, limit_per_keyword, type_filter } = args
// XML tool call parser passes array values as JSON strings — parse if needed
let keywords = args.keywords
if (typeof keywords === 'string') {
try { keywords = JSON.parse(keywords) } catch { keywords = [keywords] }
}
if (Array.isArray(keywords) && keywords.length > 0) {
const cleaned = keywords.map(k => String(k || '').trim()).filter(Boolean).slice(0, 8)
if (cleaned.length === 0) return JSON.stringify({ ok: false, error: 'no valid keywords' })
const hits = searchMemoriesByKeywords(cleaned, {
limitPerKeyword: Math.max(1, Math.min(Number(limit_per_keyword || 5), 10)),
typeFilter: type_filter || null,
})
return JSON.stringify({ ok: true, count: hits.length, hits }, null, 2)
}
if (keyword) {
const rows = searchMemories(keyword, Math.max(1, Math.min(Number(limit || 5), 20)))
if (rows.length === 0) return `未找到包含"${keyword}"的记忆`
return rows.map(m =>
`[${m.timestamp.slice(0, 10)}] ${m.event_type}: ${m.content}\n ${m.detail?.slice(0, 100) ?? ''}`
).join('\n\n')
}
return '错误:未提供 keywords 或 keyword'
}
// upsert_memory识别器调用按 mem_id 批量 upsert。
async function execUpsertMemory(args = {}, context = {}) {
let list = null
if (Array.isArray(args.memories)) {
list = args.memories
} else if (typeof args.memories === 'string') {
// XML tool call parser passes all parameter values as strings — try to parse JSON
try { const parsed = JSON.parse(args.memories); if (Array.isArray(parsed)) list = parsed } catch {}
}
// LLM forgot the memories[] wrapper and put fields at top level — auto-wrap
if (!list && args.mem_id) {
list = [args]
}
if (!list || list.length === 0) {
return JSON.stringify({
ok: false,
error: 'missing memories[]',
hint: 'Pass memories as an array: upsert_memory({ memories: [{ mem_id, type, title, content }] })',
})
}
const sourceRef = context.sessionRef || context.source_ref || null
// 同批次:无 parent 的先写,有 parent 的后写,保证父节点 mem_id 已就绪
const roots = list.filter(m => !m.parent_mem_id)
const children = list.filter(m => m.parent_mem_id)
const ordered = [...roots, ...children]
const results = []
for (const memory of ordered) {
try {
// 只对 fact 兜底fact 绝大多数是关于用户的稳定事实/偏好;
// person/object/article/knowledge 有各自主体,不该粘上 sender ID
const isFact = memory.type === 'fact' || (!memory.type && typeof memory.mem_id === 'string' && memory.mem_id.startsWith('fact_'))
const entitiesEmpty = !Array.isArray(memory.entities) || memory.entities.length === 0
// 只对新记忆兜底,避免 UPDATE 覆盖旧 entities
if (entitiesEmpty && isFact && context.senderId && !memoryExistsByMemId(memory.mem_id)) {
memory.entities = [context.senderId]
console.log(`[识别器] entities 兜底注入: mem_id=${memory.mem_id} entities=[${context.senderId}]`)
}
const payload = { ...memory, source_ref: memory.source_ref || sourceRef }
const r = upsertMemoryByMemId(payload)
results.push({ mem_id: r.mem_id, action: r.updated ? 'updated' : 'inserted', id: r.id })
} catch (err) {
results.push({ mem_id: memory.mem_id || null, action: 'error', error: err.message })
}
}
const inserted = results.filter(r => r.action === 'inserted').length
const updated = results.filter(r => r.action === 'updated').length
const failed = results.filter(r => r.action === 'error').length
return JSON.stringify({ ok: failed === 0, inserted, updated, failed, results }, null, 2)
}
// skip_recognition识别器明确表示无内容要存
async function execSkipRecognition({ reason } = {}) {
return JSON.stringify({ ok: true, skipped: true, reason: reason || '' })
}
// merge_memories整合器合并多条语义重复记忆。
// keep 被 PATCH 更新drops 不再硬删除改为软隐藏visibility=0, merged_into=keep_mem_id
// 软隐藏的记忆 search/get* 不会再返回但行、FTS5 索引、embedding 完整保留,
// 第 3 步专注帧机制可凭 mem_id 复活merged_into 字段也保证可追踪合并去向。
async function execMergeMemories(args = {}, context = {}) {
const { keep_mem_id, drop_mem_ids, merged_content, merged_salience, reason } = args
if (!keep_mem_id || !Array.isArray(drop_mem_ids) || drop_mem_ids.length === 0 || !merged_content) {
return JSON.stringify({ ok: false, error: 'missing keep_mem_id / drop_mem_ids[] / merged_content' })
}
const keep = getMemoryByMemId(keep_mem_id)
if (!keep) return JSON.stringify({ ok: false, error: `keep_mem_id not found: ${keep_mem_id}` })
let mergedEntities = []
try { mergedEntities = JSON.parse(keep.entities || '[]') } catch {}
let maxSalience = keep.salience || 3
const drops = []
for (const dmid of drop_mem_ids) {
if (dmid === keep_mem_id) continue
const d = getMemoryByMemId(dmid)
if (!d) continue
drops.push(d)
try {
const de = JSON.parse(d.entities || '[]')
mergedEntities = [...mergedEntities, ...de]
} catch {}
if ((d.salience || 3) > maxSalience) maxSalience = d.salience || 3
}
mergedEntities = [...new Set(mergedEntities)]
const finalSalience = merged_salience !== undefined ? merged_salience : maxSalience
upsertMemoryByMemId({
mem_id: keep_mem_id,
content: merged_content,
entities: mergedEntities,
salience: finalSalience,
})
const hiddenAt = new Date().toISOString()
const hidden = []
for (const d of drops) {
if (hideMemoryByMemId(d.mem_id, { mergedInto: keep_mem_id, hiddenAt })) {
hidden.push(d.mem_id)
}
}
console.log(`[整合器] merge: keep=${keep_mem_id} hidden=[${hidden.join(',')}] merged_into=${keep_mem_id} salience=${finalSalience} reason="${reason || ''}"`)
emitEvent('memory_consolidated', {
action: 'merge',
keep_mem_id,
hidden,
// 'dropped' 字段保留作为向后兼容(旧 UI / 日志消费者仍期望此键),
// 但语义已从"已删除"变为"已软隐藏"——读者请以 'hidden' 为准。
dropped: hidden,
merged_into: keep_mem_id,
salience: finalSalience,
reason: reason || '',
})
return JSON.stringify({ ok: true, action: 'merge', keep_mem_id, hidden, dropped: hidden, merged_into: keep_mem_id, salience: finalSalience })
}
// downgrade_memory整合器降低记忆的 salience不删保留信号
async function execDowngradeMemory(args = {}) {
const { mem_id, new_salience, reason } = args
if (!mem_id || new_salience === undefined) {
return JSON.stringify({ ok: false, error: 'missing mem_id or new_salience' })
}
const before = getMemoryByMemId(mem_id)
if (!before) return JSON.stringify({ ok: false, error: `mem_id not found: ${mem_id}` })
upsertMemoryByMemId({ mem_id, salience: new_salience })
console.log(`[整合器] downgrade: ${mem_id} ${before.salience || 3}${new_salience} reason="${reason || ''}"`)
emitEvent('memory_consolidated', {
action: 'downgrade',
mem_id,
before: before.salience || 3,
after: new_salience,
reason: reason || '',
})
return JSON.stringify({ ok: true, action: 'downgrade', mem_id, before: before.salience || 3, after: new_salience })
}
// skip_consolidation整合器显式表示本批无需操作
async function execSkipConsolidation({ reason } = {}) {
return JSON.stringify({ ok: true, skipped: true, reason: reason || '' })
}
// speak将文字转为语音保存为音频文件
// 有效的 MiniMax 声音 ID
const VALID_VOICE_IDS = new Set([
'male-qn-qingse', 'male-qn-jingying', 'male-qn-badao', 'male-qn-daxuesheng',
'female-shaonv', 'female-yujie', 'female-chengshu', 'female-tianmei',
'presenter_male', 'presenter_female', 'audiobook_male_1', 'audiobook_female_1',
])
const DEFAULT_VOICE = 'male-qn-qingse'
async function execSpeak(args) {
const text = args.text || args.content || args.words || args.speech
const { filename } = args
console.log(`[speak] args:`, JSON.stringify(args))
if (!text) return '错误:未提供要朗读的文字'
if (isDailyLimitReached('tts')) return '错误:今日 TTS 配额已用完'
if (text.length > 1000) return `错误:文字过长(${text.length} 字),请控制在 1000 字以内`
const creds = getTTSCredentials()
const voiceId = (args.voice_id || args.voice) || creds.voiceId
const nodeStream = await streamTTS({ text, provider: creds.provider, voiceId, keys: creds })
const chunks = []
for await (const chunk of nodeStream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
const buffer = Buffer.concat(chunks)
const ts = nowTimestamp().replace(/[:.+]/g, '-').slice(0, 19)
const fname = filename ? filename.replace(/[^a-zA-Z0-9_一-龥-]/g, '') + '.mp3' : `speech_${ts}.mp3`
const resolved = path.resolve(SANDBOX_ROOT, 'audio', fname)
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, buffer)
const relPath = `audio/${fname}`
emitEvent('audio_created', { path: relPath, text: text.slice(0, 60), autoPlay: true })
console.log(`[speak] 已生成: ${relPath}`)
return `语音已生成:${relPath}`
}
// ─── 工具市场执行函数 ──────────────────────────────────────────────────────────
async function execInstallTool(args) {
const { name, description, parameters_schema, code } = args
return await installTool({ name, description, parameters: parameters_schema, code })
}
function execUninstallTool(args) {
return uninstallTool({ name: args.name })
}
function execListTools() {
const builtins = Object.entries(TOOL_SCHEMAS)
.filter(([name]) => name !== 'express')
.map(([name, s]) => ({ name, description: s.function.description, source: 'builtin' }))
const installed = listInstalledTools()
const all = [...builtins, ...installed]
const lines = all.map(t => `[${t.source}] ${t.name}: ${t.description}`)
return `${all.length} 个工具(${builtins.length} 内置 + ${installed.length} 已安装):\n\n${lines.join('\n')}`
}
// 语音消息自动回复 TTS检测到用户用语音输入时通知前端播放语音
// 由 index.js 调用,前端收到 tts_reply 事件后调用 /tts/stream 完成实际合成
export function autoSpeakForVoiceReply(text) {
if (!text) return
const plain = text.trim()
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`(.+?)`/g, '$1')
.replace(/#{1,6}\s+/g, '')
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1')
.replace(/!\[[^\]]*\]\([^\)]+\)/g, '')
.replace(/\n+/g, ' ')
.trim()
if (!plain) return
emitEvent('tts_reply', { text: plain })
}
// generate_lyrics生成歌词
async function execGenerateLyrics({ prompt, mode }) {
if (!prompt) return '错误:未提供创作方向'
if (isDailyLimitReached('lyrics')) return '错误:今日歌词生成配额已用完'
const result = await callCapability('lyrics', { prompt, mode })
// 自动保存歌词到 sandbox
const ts = nowTimestamp().replace(/[:.+]/g, '-').slice(0, 19)
const fname = `lyrics_${ts}.txt`
const content = `# ${result.title}\n风格:${result.style}\n\n${result.lyrics}`
const resolved = path.resolve(SANDBOX_ROOT, 'lyrics', fname)
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, content, 'utf-8')
emitEvent('lyrics_created', { path: `lyrics/${fname}`, title: result.title })
return `歌词已生成并保存至 lyrics/${fname}\n\n标题:${result.title}\n风格:${result.style}\n\n${result.lyrics}`
}
// generate_music生成音乐
async function execGenerateMusic({ prompt, lyrics, instrumental }) {
if (!prompt) return '错误:未提供音乐描述'
if (isDailyLimitReached('music')) return '错误:今日音乐生成配额已用完'
const result = await callCapability('music', { prompt, lyrics, instrumental })
const ts = nowTimestamp().replace(/[:.+]/g, '-').slice(0, 19)
const fname = `music_${ts}.mp3`
const resolved = path.resolve(SANDBOX_ROOT, 'music', fname)
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, result.buffer)
const relPath = `music/${fname}`
emitEvent('music_created', { path: relPath, prompt: prompt.slice(0, 60) })
console.log(`[music] 已生成: ${relPath}`)
return `音乐已生成:${relPath}(时长约 ${result.duration ?? '?'} 秒)`
}
// generate_image生成图片
async function execGenerateImage({ prompt, aspect_ratio = '1:1', n = 1 }) {
if (!prompt) return '错误:未提供图片描述'
if (isDailyLimitReached('image')) return '错误今日图片生成配额已用完50 次/天)'
const validRatios = new Set(['1:1', '16:9', '4:3', '3:4', '9:16'])
const ratio = validRatios.has(aspect_ratio) ? aspect_ratio : '1:1'
const count = Math.min(Math.max(Math.floor(n) || 1, 1), 4)
const result = await callCapability('image', { prompt, aspect_ratio: ratio, n: count })
emitEvent('image_created', { urls: result.urls, prompt: prompt.slice(0, 60) })
console.log(`[image] 已生成 ${result.urls.length} 张图片`)
return `图片已生成(${result.urls.length} 张):\n${result.urls.join('\n')}`
}
// manage_prefetch_task管理预热任务
function execManagePrefetchTask({ action, source, label, url, ttl_minutes, tags }) {
if (action === 'list') {
const tasks = listPrefetchTasks()
if (tasks.length === 0) return '当前没有预热任务。'
return tasks.map(t =>
`[${t.enabled ? '✓' : '✗'}] ${t.source} ${t.label} TTL=${t.ttl_minutes}min\n URL: ${t.url}`
).join('\n')
}
if (action === 'add') {
if (!source) return '错误:缺少 source'
if (!label) return '错误:缺少 label'
if (!url) return '错误:缺少 url'
upsertPrefetchTask({ source, label, url, ttlMinutes: ttl_minutes ?? 60, tags: tags ?? [] })
return `预热任务已保存:${source}${label}TTL=${ttl_minutes ?? 60}min。下次运行预热时生效。`
}
if (action === 'remove') {
if (!source) return '错误:缺少 source'
const ok = removePrefetchTask(source)
return ok ? `预热任务已删除:${source}` : `未找到任务:${source}`
}
return `错误:未知 action "${action}",可选 add / remove / list`
}
// set_tick_intervalL2 调节自身思维节奏
function execSetTickInterval({ seconds, ttl, reason }) {
const res = setTickerInterval({ seconds, ttl, reason })
if (!res.ok) return `错误:${res.error}`
const parts = [`节奏已设为 ${res.seconds}s持续 ${res.ttl}`]
if (res.clampedFrom?.seconds !== undefined) parts.push(`seconds ${res.clampedFrom.seconds} 越界,已 clamp 到 ${res.seconds}`)
if (res.clampedFrom?.ttl !== undefined) parts.push(`ttl ${res.clampedFrom.ttl} 越界,已 clamp 到 ${res.ttl}`)
return parts.join('')
}
// ─────────────────────────────────────────────────────────────────────────────
// ACUI · UI 控制工具
// ─────────────────────────────────────────────────────────────────────────────
function execMediaMode(args = {}) {
const mode = String(args.mode || args.kind || '').trim()
const action = String(args.action || 'show').trim()
if (!['video', 'camera', 'image', 'music'].includes(mode)) {
return JSON.stringify({ ok: false, tool: 'media_mode', error: 'mode must be video, camera, image, or music' })
}
if (!['show', 'hide', 'close', 'play', 'pause', 'seek', 'set_volume', 'update'].includes(action)) {
return JSON.stringify({ ok: false, tool: 'media_mode', error: 'unsupported action' })
}
const payload = {
mode,
action,
url: typeof args.url === 'string' ? args.url : undefined,
src: typeof args.src === 'string' ? args.src : undefined,
title: typeof args.title === 'string' ? args.title : undefined,
artist: typeof args.artist === 'string' ? args.artist : undefined,
lrc: typeof args.lrc === 'string' ? args.lrc : undefined,
cover: typeof args.cover === 'string' ? args.cover : undefined,
alt: typeof args.alt === 'string' ? args.alt : undefined,
autoplay: typeof args.autoplay === 'boolean' ? args.autoplay : (mode === 'music' ? true : undefined),
muted: typeof args.muted === 'boolean' ? args.muted : undefined,
camera: mode === 'camera' || args.camera === true,
}
if (Number.isFinite(Number(args.volume))) {
payload.volume = Math.max(0, Math.min(1, Number(args.volume)))
}
if (Number.isFinite(Number(args.currentTime ?? args.time ?? args.seek))) {
payload.currentTime = Math.max(0, Number(args.currentTime ?? args.time ?? args.seek))
}
emitEvent('media_mode', payload)
emitEvent('action', { tool: 'media_mode', summary: `${mode}:${action}`, detail: payload.title || payload.url || '' })
return JSON.stringify({ ok: true, tool: 'media_mode', ...payload })
}
function execHotspotMode(args = {}) {
const action = String(args.action || 'status').trim().toLowerCase()
if (!['show', 'open', 'hide', 'close', 'toggle', 'status'].includes(action)) {
return JSON.stringify({ ok: false, tool: 'hotspot_mode', error: 'unsupported action' })
}
let nextActive = null
if (action === 'show' || action === 'open') nextActive = true
if (action === 'hide' || action === 'close') nextActive = false
if (action === 'toggle') nextActive = !getHotspotPanelState().active
const state = typeof nextActive === 'boolean'
? setHotspotPanelState({ active: nextActive, source: 'agent_tool' })
: getHotspotPanelState()
if (typeof nextActive === 'boolean') {
emitEvent('hotspot_mode', {
action: state.active ? 'show' : 'hide',
active: state.active,
reason: typeof args.reason === 'string' ? args.reason : '',
})
emitEvent('action', {
tool: 'hotspot_mode',
summary: state.active ? '打开热点面板' : '关闭热点面板',
detail: args.reason || '',
})
}
return JSON.stringify({ ok: true, tool: 'hotspot_mode', state })
}
function execOpenDocPanel(args = {}) {
const action = String(args.action || 'open').trim().toLowerCase()
const nextActive = action !== 'close'
const validTopics = ['voice_asr', 'voice_tts', 'voice_config']
// 打开时 topic 必填;关闭时 topic 可省略(沿用当前面板已有的 topicId
let topic = args.topic ? String(args.topic).trim() : null
if (nextActive && topic && !validTopics.includes(topic)) {
if (/asr|识别|麦克风/.test(topic)) topic = 'voice_asr'
else if (/tts|合成|声音/.test(topic)) topic = 'voice_tts'
else topic = 'voice_config'
}
const state = setDocPanelState({ active: nextActive, topicId: topic, source: 'agent_tool' })
const effectiveTopic = topic || state.topicId
emitEvent('doc_panel_mode', {
action: nextActive ? 'open' : 'close',
active: nextActive,
topic: effectiveTopic,
reason: typeof args.reason === 'string' ? args.reason : '',
})
emitEvent('action', {
tool: 'open_doc_panel',
summary: nextActive ? `打开文档面板(${effectiveTopic}` : '关闭文档面板',
detail: args.reason || '',
})
return JSON.stringify({ ok: true, tool: 'open_doc_panel', topic: effectiveTopic, state })
}
function execPersonCardMode(args = {}) {
const action = String(args.action || 'status').trim().toLowerCase()
if (!['show', 'open', 'hide', 'close', 'update', 'toggle', 'status'].includes(action)) {
return JSON.stringify({ ok: false, tool: 'person_card_mode', error: 'unsupported action' })
}
let nextActive = null
if (action === 'show' || action === 'open' || action === 'update') nextActive = true
if (action === 'hide' || action === 'close') nextActive = false
if (action === 'toggle') nextActive = !getPersonCardPanelState().active
const name = String(args.name || args.person || '').trim()
const card = {
...(name ? getPersonCard(name) : {}),
...(args.card && typeof args.card === 'object' ? args.card : {}),
}
if (name) card.name = name
for (const key of ['title', 'summary', 'image', 'avatar', 'source']) {
if (typeof args[key] === 'string' && args[key].trim()) card[key] = args[key].trim()
}
if (Array.isArray(args.knownFor) || typeof args.knownFor === 'string') card.knownFor = args.knownFor
if (Array.isArray(args.tags) || typeof args.tags === 'string') card.tags = args.tags
if (Array.isArray(args.aliases) || typeof args.aliases === 'string') card.aliases = args.aliases
const state = typeof nextActive === 'boolean'
? setPersonCardPanelState({
active: nextActive,
source: 'agent_tool',
card: (card.name || card.summary || card.title) ? card : null,
name,
})
: getPersonCardPanelState()
if (typeof nextActive === 'boolean') {
emitEvent('person_card_mode', {
action: state.active ? 'show' : 'hide',
active: state.active,
card: state.card,
reason: typeof args.reason === 'string' ? args.reason : '',
})
emitEvent('action', {
tool: 'person_card_mode',
summary: state.active ? `打开人物卡片${state.card?.name ? `${state.card.name}` : ''}` : '关闭人物卡片',
detail: args.reason || '',
})
}
return JSON.stringify({ ok: true, tool: 'person_card_mode', state })
}
// ── Music Library ─────────────────────────────────────────────────────────────
const MUSIC_AUDIO_EXTS = new Set(['.mp3', '.flac', '.wav', '.aac', '.ogg', '.m4a', '.opus'])
async function fetchLrcFromNet(title, artist) {
const headers = { 'User-Agent': 'BaiLongma/1.0' }
// 策略1精确匹配title + artist
try {
const params = new URLSearchParams({ track_name: title })
if (artist) params.set('artist_name', artist)
const res = await fetch(`https://lrclib.net/api/get?${params}`, {
signal: AbortSignal.timeout(8000), headers,
})
if (res.ok) {
const data = await res.json()
const lrc = data.syncedLyrics || data.plainLyrics || null
if (lrc) return lrc
}
} catch {}
// 策略2仅 title 关键词搜索,取第一条结果
try {
const params = new URLSearchParams({ q: title })
const res = await fetch(`https://lrclib.net/api/search?${params}`, {
signal: AbortSignal.timeout(8000), headers,
})
if (res.ok) {
const list = await res.json()
if (Array.isArray(list) && list.length > 0) {
const hit = list[0]
return hit.syncedLyrics || hit.plainLyrics || null
}
}
} catch {}
return null
}
function decodeProcessOutput(chunks) {
const buffer = Buffer.concat(chunks)
if (buffer.length === 0) return ''
const utf8 = new TextDecoder('utf-8', { fatal: false }).decode(buffer)
if (!utf8.includes('\uFFFD') || !IS_WIN) return utf8
try {
return new TextDecoder('gb18030', { fatal: false }).decode(buffer)
} catch {
return utf8
}
}
function runProcess(file, args = [], cwd) {
return new Promise((resolve) => {
const child = spawn(file, args, {
cwd: cwd || paths.musicDir,
windowsHide: true,
env: {
...process.env,
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1',
},
})
const stdoutChunks = []
const stderrChunks = []
child.stdout?.on('data', d => { stdoutChunks.push(Buffer.from(d)) })
child.stderr?.on('data', d => { stderrChunks.push(Buffer.from(d)) })
child.on('close', code => resolve({
code,
stdout: decodeProcessOutput(stdoutChunks),
stderr: decodeProcessOutput(stderrChunks),
}))
child.on('error', err => resolve({
code: -1,
stdout: decodeProcessOutput(stdoutChunks),
stderr: err.message,
}))
})
}
const YTDLP_LOCAL = path.join(paths.musicDir, 'yt-dlp.exe')
const YTDLP_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe'
async function resolveYtDlp() {
// 1. 系统 PATH 里有就直接用
const sys = await runProcess('yt-dlp', ['--version'], paths.musicDir)
if (sys.code === 0) return 'yt-dlp'
// 2. music 目录里有本地副本就用它
if (fs.existsSync(YTDLP_LOCAL)) {
const local = await runProcess(YTDLP_LOCAL, ['--version'], paths.musicDir)
if (local.code === 0) return YTDLP_LOCAL
}
// 3. 自动下载 yt-dlp.exe 到 music 目录
emitEvent('action', { tool: 'music', summary: 'yt-dlp 未安装,正在自动下载…', detail: YTDLP_URL })
const res = await fetch(YTDLP_URL, { signal: AbortSignal.timeout(60000) })
if (!res.ok) return null
const buf = Buffer.from(await res.arrayBuffer())
fs.writeFileSync(YTDLP_LOCAL, buf)
fs.chmodSync(YTDLP_LOCAL, 0o755)
return YTDLP_LOCAL
}
async function execMusic(args = {}) {
const action = String(args.action || 'list').trim()
const musicDir = paths.musicDir
// ── list ──────────────────────────────────────────────────────────────────
if (action === 'list') {
const rows = listMusicLibrary(Number(args.limit) || 50)
return JSON.stringify({ ok: true, count: rows.length, tracks: rows })
}
// ── search ────────────────────────────────────────────────────────────────
if (action === 'search') {
const q = String(args.query || '').trim()
if (!q) return JSON.stringify({ ok: false, error: 'query required' })
const rows = searchMusicLibrary(q, Number(args.limit) || 20)
return JSON.stringify({ ok: true, count: rows.length, tracks: rows })
}
// ── scan ──────────────────────────────────────────────────────────────────
if (action === 'scan') {
const entries = fs.readdirSync(musicDir, { withFileTypes: true })
const added = []
for (const entry of entries) {
if (!entry.isFile()) continue
const ext = path.extname(entry.name).toLowerCase()
if (!MUSIC_AUDIO_EXTS.has(ext)) continue
const filePath = path.join(musicDir, entry.name)
const baseName = path.basename(entry.name, ext)
const track = upsertMusicTrack({ title: baseName, filePath })
added.push({ id: track.id, title: track.title, file_path: track.file_path })
}
return JSON.stringify({ ok: true, scanned: added.length, tracks: added })
}
// ── add ───────────────────────────────────────────────────────────────────
if (action === 'add') {
const filePath = String(args.path || '').trim()
if (!filePath) return JSON.stringify({ ok: false, error: 'path required' })
if (!fs.existsSync(filePath)) return JSON.stringify({ ok: false, error: `file not found: ${filePath}` })
const ext = path.extname(filePath).toLowerCase()
if (!MUSIC_AUDIO_EXTS.has(ext)) return JSON.stringify({ ok: false, error: `unsupported format: ${ext}` })
const baseName = path.basename(filePath, ext)
const track = upsertMusicTrack({
title: String(args.title || baseName),
artist: String(args.artist || ''),
album: String(args.album || ''),
filePath,
})
return JSON.stringify({ ok: true, track })
}
// ── download ──────────────────────────────────────────────────────────────
if (action === 'download') {
const url = String(args.url || '').trim()
if (!url) return JSON.stringify({ ok: false, error: 'url required' })
// 自动解析 yt-dlp 路径(没有则自动下载)
const ytdlp = await resolveYtDlp()
if (!ytdlp) return JSON.stringify({ ok: false, error: 'yt-dlp 自动下载失败,请检查网络连接' })
// Download: print final filepath after conversion
const outTemplate = path.join(musicDir, '%(title)s.%(ext)s').replace(/\\/g, '/')
const dlArgs = ['-x', '--audio-format', 'mp3', '--audio-quality', '192K', '--no-playlist', '--print', 'after_move:filepath', '-o', outTemplate]
let result = await runProcess(ytdlp, [...dlArgs, url])
// SSL 握手失败时降级:加 --no-check-certificates 重试一次
if (result.code !== 0 && /ssl|EOF occurred in violation of protocol/i.test(result.stderr)) {
result = await runProcess(ytdlp, [...dlArgs, '--no-check-certificates', url])
}
if (result.code !== 0) {
return JSON.stringify({ ok: false, error: `yt-dlp failed: ${result.stderr.slice(0, 400)}` })
}
// Parse output filepath (last non-empty line)
const lines = result.stdout.trim().split('\n').map(l => l.trim()).filter(Boolean)
let filePath = lines[lines.length - 1] || ''
// Fallback: scan for newest mp3 in musicDir
if (!filePath || !fs.existsSync(filePath)) {
const files = fs.readdirSync(musicDir)
.filter(f => f.endsWith('.mp3'))
.map(f => ({ f, mt: fs.statSync(path.join(musicDir, f)).mtimeMs }))
.sort((a, b) => b.mt - a.mt)
if (files.length) filePath = path.join(musicDir, files[0].f)
}
if (!filePath || !fs.existsSync(filePath)) {
return JSON.stringify({ ok: false, error: 'Download completed but could not locate output file' })
}
const baseName = path.basename(filePath, '.mp3')
const title = String(args.title || baseName)
const artist = String(args.artist || '')
// Auto-fetch lyrics
let lrc = ''
if (title) {
lrc = await fetchLrcFromNet(title, artist) || ''
}
const track = upsertMusicTrack({ title, artist, album: String(args.album || ''), filePath, lrc, sourceUrl: url })
return JSON.stringify({ ok: true, track, lrc_fetched: Boolean(lrc) })
}
// ── get_lyrics ────────────────────────────────────────────────────────────
if (action === 'get_lyrics') {
const id = Number(args.id)
let title = String(args.title || '').trim()
let artist = String(args.artist || '').trim()
if (id) {
const track = getMusicTrack(id)
if (!track) return JSON.stringify({ ok: false, error: `track id=${id} not found` })
if (!title) title = track.title
if (!artist) artist = track.artist
}
if (!title) return JSON.stringify({ ok: false, error: 'title required' })
const lrc = await fetchLrcFromNet(title, artist)
if (!lrc) return JSON.stringify({ ok: true, id: id || null, title, artist, lrc: null, hint: 'lyrics not found on lrclib.net' })
if (id) updateMusicLrc(id, lrc)
return JSON.stringify({ ok: true, id: id || null, title, artist, lrc_length: lrc.length, lrc })
}
// ── delete ────────────────────────────────────────────────────────────────
if (action === 'delete') {
const id = Number(args.id)
if (!id) return JSON.stringify({ ok: false, error: 'id required' })
const track = getMusicTrack(id)
if (!track) return JSON.stringify({ ok: false, error: `track id=${id} not found` })
dbDeleteMusicTrack(id)
return JSON.stringify({ ok: true, deleted: { id, title: track.title } })
}
return JSON.stringify({ ok: false, error: `unknown action: ${action}` })
}
const ACUI_COMPONENTS_PATH = path.resolve(__dirname, 'ui-components.json')
const ACUI_REGISTRY_PATH = path.resolve(__dirname, '..', 'ui', 'brain-ui', 'acui', 'registry.js')
const ACUI_COMPONENTS_DIR = path.resolve(__dirname, '..', 'ui', 'brain-ui', 'acui', 'components')
let _acuiComponentsCache = null
function loadACUIComponents() {
if (!_acuiComponentsCache) {
_acuiComponentsCache = JSON.parse(fs.readFileSync(ACUI_COMPONENTS_PATH, 'utf-8'))
}
return _acuiComponentsCache
}
function invalidateACUIComponentsCache() { _acuiComponentsCache = null }
// 校验并就地容错number-like 字符串自动转 number避免 LLM 把 "18" 当 18 传过来时硬挂。
function validateProps(propsSchema, props) {
if (!props || typeof props !== 'object') return null
for (const [name, spec] of Object.entries(propsSchema)) {
let v = props[name]
if (spec.required && (v === undefined || v === null)) {
return `字段 ${name} 必填`
}
if (v === undefined || v === null) continue
const t = spec.type
if (t === 'number' && typeof v !== 'number') {
// 容错LLM 经常把数字当字符串传("18"、"23.5")。是合法 number-like 字符串就转一下。
if (typeof v === 'string' && v.trim() !== '' && !isNaN(Number(v))) {
props[name] = Number(v)
continue
}
return `字段 ${name} 必须为 number`
}
if (t === 'string' && typeof v !== 'string') return `字段 ${name} 必须为 string`
if (t === 'array' && !Array.isArray(v)) return `字段 ${name} 必须为 array`
if (t === 'object' && (typeof v !== 'object' || Array.isArray(v))) return `字段 ${name} 必须为 object`
if (t === 'boolean' && typeof v !== 'boolean') return `字段 ${name} 必须为 boolean`
}
return null
}
// 合并 LLM 给的 hint 和组件 propsSchema 默认值,按 placement 推断动画/拖动/遮罩默认。
function mergeHint(hint, def) {
const h = hint && typeof hint === 'object' ? hint : {}
const placement = ['notification', 'center', 'floating', 'stage'].includes(h.placement)
? h.placement
: (def?.placement || 'notification')
const enterDefaults = { notification: 'slide-from-right', center: 'scale-up', floating: 'fade-up', stage: 'stage-up' }
const exitDefaults = { notification: 'slide-to-right', center: 'scale-down', floating: 'fade-down', stage: 'stage-down' }
const draggable = typeof h.draggable === 'boolean' ? h.draggable
: (typeof def?.draggable === 'boolean' ? def.draggable : (placement === 'floating'))
const modal = typeof h.modal === 'boolean' ? h.modal
: (typeof def?.modal === 'boolean' ? def.modal : (placement === 'center' || placement === 'stage'))
const size = h.size ?? def?.size ?? 'md'
// def.enter/exit 只在 placement=notification 时生效;切换到 center/floating/stage
// 组件原来的 slide-from-right 就不合适了,按 placement 默认动画走。
const usesDefAnim = placement === 'notification'
return {
placement,
size,
draggable,
modal,
enter: h.enter || (usesDefAnim ? def?.enter : null) || enterDefaults[placement],
exit: h.exit || (usesDefAnim ? def?.exit : null) || exitDefaults[placement],
}
}
function execUIShow({ component, props, hint }) {
console.log(`[ui_show] component=${component} props=${JSON.stringify(props)}`)
if (!component) return '错误:未提供 component 或 mode'
const components = loadACUIComponents()
const def = components[component]
if (!def) return `错误:组件 "${component}" 未注册(可用:${Object.keys(components).join(', ') || '无'}`
const propsErr = validateProps(def.propsSchema, props || {})
if (propsErr) return `错误props 校验失败 — ${propsErr}(实际 props=${JSON.stringify(props)}`
if (!hasACUIClient()) return '错误:当前没有 UI 客户端连接,请改用文字回答'
// 单例组件:显示新卡前先关掉同类旧卡,避免动画重叠出现"两种"
const SINGLETON_COMPONENTS = new Set(['SelfCheckStepCard'])
if (SINGLETON_COMPONENTS.has(component)) {
const existing = getActiveUICards().filter(c => c.component === component)
for (const old of existing) {
emitUICommand({ op: 'unmount', id: old.id })
removeActiveUICard(old.id)
}
}
const id = `${component.toLowerCase()}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`
emitUICommand({
op: 'mount',
id,
component,
props,
hint: mergeHint(hint, def),
})
addActiveUICard(id, { component })
emitEvent('action', { tool: 'ui_show', summary: `推送 ${component}`, detail: id })
return JSON.stringify({ ok: true, id })
}
function execUIHide({ id }) {
if (!id) return '错误:未提供 id'
if (!getActiveUICards().find(c => c.id === id)) return `错误:卡片 "${id}" 不存在或已关闭`
if (!hasACUIClient()) return '错误:当前没有 UI 客户端连接'
emitUICommand({ op: 'unmount', id })
removeActiveUICard(id)
emitEvent('action', { tool: 'ui_hide', summary: `关闭卡片`, detail: id })
return JSON.stringify({ ok: true, id })
}
function execUIUpdate({ id, props }) {
if (!id) return '错误:未提供 id'
if (!props || typeof props !== 'object' || Array.isArray(props)) return '错误props 必须为对象'
const card = getActiveUICards().find(c => c.id === id)
if (!card) return `错误:卡片 "${id}" 不存在或已关闭`
if (card.component) {
const def = loadACUIComponents()[card.component]
if (def) {
const propsErr = validateProps(def.propsSchema, props)
if (propsErr) return `错误props 校验失败 — ${propsErr}`
}
}
if (!hasACUIClient()) return '错误:当前没有 UI 客户端连接'
emitUICommand({ op: 'update', id, props })
emitEvent('action', { tool: 'ui_update', summary: `更新卡片`, detail: id })
return JSON.stringify({ ok: true, id })
}
function execUIPatch({ id, op, data }) {
if (!id) return '错误:未提供 id'
if (!op) return '错误:未提供 op'
if (!getActiveUICards().find(c => c.id === id)) return `错误:卡片 "${id}" 不存在或已关闭`
if (!hasACUIClient()) return '错误:当前没有 UI 客户端连接'
emitUICommand({ op: 'patch', id, patchOp: op, data: data || {} })
emitEvent('action', { tool: 'ui_patch', summary: `应用补丁 ${op}`, detail: id })
return JSON.stringify({ ok: true, id, op })
}
function execManageApp({ action, name, label, draft_id, state, hint }) {
const appsRoot = path.resolve(SANDBOX_ROOT, 'apps')
if (action === 'save') {
if (!name) return '错误save 操作必须提供 name'
if (!draft_id) return '错误save 操作必须提供 draft_id'
// 从内存或草稿文件取代码
let code = draftCodeMap.get(draft_id)
if (!code) {
const draftPath = path.resolve(appsRoot, '.drafts', `${draft_id}.js`)
if (!fs.existsSync(draftPath)) return `错误:找不到草稿 ${draft_id},请确认 draft_id 是 ui_show(mode="inline-script") 返回的 id`
code = fs.readFileSync(draftPath, 'utf-8')
}
const appDir = path.resolve(appsRoot, name)
fs.mkdirSync(appDir, { recursive: true })
// 版本备份(若已有同名应用)
const componentPath = path.resolve(appDir, 'component.js')
const metaPath = path.resolve(appDir, 'meta.json')
let newVersion = 1
if (fs.existsSync(componentPath) && fs.existsSync(metaPath)) {
try {
const oldMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
const v = oldMeta.version || 1
fs.copyFileSync(componentPath, path.resolve(appDir, `component.v${v}.js`))
newVersion = v + 1
} catch (_) {}
}
const meta = {
name, label: label || name,
created_at: new Date().toISOString(),
last_used: new Date().toISOString(),
version: newVersion,
draft_id,
hint: hint || { placement: 'floating', size: 'lg' },
}
fs.writeFileSync(componentPath, code, 'utf-8')
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8')
if (state) fs.writeFileSync(path.resolve(appDir, 'state.json'), JSON.stringify(state, null, 2), 'utf-8')
appIdToName.set(draft_id, name)
draftCodeMap.delete(draft_id)
emitEvent('action', { tool: 'manage_app', summary: `保存应用 ${name}`, detail: draft_id })
return JSON.stringify({ ok: true, name, path: `sandbox/apps/${name}/` })
}
if (action === 'open') {
if (!name) return '错误open 操作必须提供 name'
const appDir = path.resolve(appsRoot, name)
if (!fs.existsSync(appDir)) return `错误:应用 "${name}" 不存在,请先 save`
const code = fs.readFileSync(path.resolve(appDir, 'component.js'), 'utf-8')
const meta = JSON.parse(fs.readFileSync(path.resolve(appDir, 'meta.json'), 'utf-8'))
let savedState = {}
const statePath = path.resolve(appDir, 'state.json')
if (!state && fs.existsSync(statePath)) {
savedState = JSON.parse(fs.readFileSync(statePath, 'utf-8'))
}
const props = state || savedState
const mountHint = hint || meta.hint || { placement: 'floating', size: 'lg' }
const result = execUIShowInline({ mode: 'inline-script', code, props, hint: mountHint })
try {
const parsed = JSON.parse(result)
if (parsed.ok) {
appIdToName.set(parsed.id, name)
meta.last_used = new Date().toISOString()
fs.writeFileSync(path.resolve(appDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8')
}
} catch (e) { console.warn(`[manage_app open] 解析挂载结果失败:${e.message}`) }
emitEvent('action', { tool: 'manage_app', summary: `打开应用 ${name}`, detail: name })
return result
}
if (action === 'list') {
if (!fs.existsSync(appsRoot)) return JSON.stringify({ ok: true, apps: [] })
const apps = fs.readdirSync(appsRoot, { withFileTypes: true })
.filter(d => d.isDirectory() && d.name !== '.drafts')
.map(d => {
try { return JSON.parse(fs.readFileSync(path.resolve(appsRoot, d.name, 'meta.json'), 'utf-8')) }
catch { return { name: d.name } }
})
return JSON.stringify({ ok: true, apps })
}
if (action === 'delete') {
if (!name) return '错误delete 操作必须提供 name'
const appDir = path.resolve(appsRoot, name)
if (!fs.existsSync(appDir)) return `错误:应用 "${name}" 不存在`
fs.rmSync(appDir, { recursive: true })
emitEvent('action', { tool: 'manage_app', summary: `删除应用 ${name}`, detail: name })
return JSON.stringify({ ok: true, name, deleted: true })
}
return `错误:未知 action "${action}"可用save / open / list / delete`
}
function isPascalCase(name) { return /^[A-Z][A-Za-z0-9]*$/.test(name) }
function pascalToKebab(name) { return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() }
const RESERVED_COMPONENT_NAMES = new Set(['Inline', 'System', 'Base', 'Test'])
function execUIRegister({ component_name, code, props_schema, use_case, example_call }) {
if (!component_name || !isPascalCase(component_name)) return '错误component_name 必须为 PascalCase如 TodoCard'
if (RESERVED_COMPONENT_NAMES.has(component_name)) return `错误component_name "${component_name}" 是保留名`
if (!code || typeof code !== 'string') return '错误code 必填字符串'
if (!props_schema || typeof props_schema !== 'object' || Array.isArray(props_schema)) return '错误props_schema 必须为对象'
if (!use_case || typeof use_case !== 'string') return '错误use_case 必填'
if (!example_call || typeof example_call !== 'string') return '错误example_call 必填'
// code 必须含 customElements.define & static tagName
if (!/customElements\s*\.\s*define/.test(code)) return '错误code 必须以 customElements.define(...) 注册收尾'
if (!/static\s+tagName\s*=\s*['"`]/.test(code)) return '错误code 必须含 static tagName = "acui-..."'
// 占用检查
const components = loadACUIComponents()
if (components[component_name]) return `错误:组件名 "${component_name}" 已存在`
// 语法预检:剥离顶层 import / export 行new Function 不接受 module 语法)
try {
const stripped = code
.replace(/^\s*import\s[^\n]*\n/gm, '')
.replace(/^\s*export\s+default\s+/gm, '')
.replace(/^\s*export\s*\{[^}]*\}[^\n]*\n/gm, '')
.replace(/^\s*export\s+/gm, '')
new Function(stripped)
} catch (e) {
return `错误:代码语法预检失败 — ${e.message}`
}
const kebab = pascalToKebab(component_name)
const filePath = path.join(ACUI_COMPONENTS_DIR, `${kebab}.js`)
// 文件名必须严格 kebab-case且只能写入 components 目录内
const resolved = path.resolve(filePath)
if (!isPathInside(ACUI_COMPONENTS_DIR, resolved)) return '错误:目标路径越界'
if (fs.existsSync(resolved)) return `错误:目标文件已存在:${kebab}.js`
// 写组件文件
fs.writeFileSync(resolved, code, 'utf-8')
// 改 registry.js在 import 区追加COMPONENTS 对象内追加键
let registry = fs.readFileSync(ACUI_REGISTRY_PATH, 'utf-8')
const importLine = `import { ${component_name} } from './components/${kebab}.js'`
if (!registry.includes(importLine)) {
// 在最后一个 import 后追加
registry = registry.replace(/((?:^import .*\n)+)/m, (m) => m + importLine + '\n')
}
// 在 COMPONENTS 对象里追加键
if (!new RegExp(`\\b${component_name}\\s*[,}]`).test(registry)) {
registry = registry.replace(/export const COMPONENTS = \{([\s\S]*?)\}/, (m, body) => {
const trimmed = body.replace(/\s+$/, '')
const sep = trimmed.endsWith(',') || trimmed === '' ? '' : ','
return `export const COMPONENTS = {${trimmed}${sep}\n ${component_name},\n}`
})
}
fs.writeFileSync(ACUI_REGISTRY_PATH, registry, 'utf-8')
// 改 ui-components.json
components[component_name] = {
propsSchema: props_schema,
enter: 'slide-from-right',
exit: 'slide-to-right',
}
fs.writeFileSync(ACUI_COMPONENTS_PATH, JSON.stringify(components, null, 2), 'utf-8')
invalidateACUIComponentsCache()
// seed skill.ui 记忆
const skillContent = `[Skill UI] ${component_name}\nUse case: ${use_case}\nExample call: ${example_call}`
try {
insertMemory({
mem_id: `skill-ui-${kebab}`,
type: 'skill',
content: skillContent,
detail: skillContent,
title: `UI component: ${component_name}`,
tags: ['skill.ui', `component:${component_name}`],
entities: [],
timestamp: new Date().toISOString(),
})
} catch (e) {
console.warn(`[ui_register] 写技能记忆失败:${e.message}(组件已注册成功)`)
}
// 通知前端热重载 registry
emitACUIEvent('acui:reload', { component_name })
emitEvent('action', { tool: 'ui_register', summary: `转正组件 ${component_name}`, detail: kebab })
return JSON.stringify({ ok: true, component_name, file: `${kebab}.js` })
}
// ─────────────────────────────────────────────────────────────────────────────
// 任务管理工具(通过 context 回调通知 index.js
// ─────────────────────────────────────────────────────────────────────────────
function execSetTask({ description, steps = [] }, context) {
if (!description?.trim()) return '错误:未提供任务描述'
if (!Array.isArray(steps) || steps.length === 0) return '错误steps 不能为空,请提供具体执行步骤'
if (!context?.onSetTask) return '错误:任务管理回调未注册'
const cleanSteps = steps.map(s => String(s).trim()).filter(Boolean)
context.onSetTask(description.trim(), cleanSteps)
return `任务已开启:${description}\n步骤(${cleanSteps.length} 个):\n${cleanSteps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}`
}
function execCompleteTask({ summary = '' }, context) {
if (!context?.onCompleteTask) return '错误:任务管理回调未注册'
context.onCompleteTask(String(summary || '').trim())
return `任务已完成${summary ? '' + summary : ''}`
}
function execUpdateTaskStep({ step_index, status, note = '' }, context) {
if (step_index === undefined || step_index === null) return '错误:未提供步骤编号'
const idx = Number(step_index)
if (!Number.isInteger(idx) || idx < 0) return '错误:步骤编号必须为非负整数'
if (!['done', 'failed', 'skipped'].includes(status)) return '错误status 必须为 done/failed/skipped'
if (!context?.onUpdateTaskStep) return '错误:任务管理回调未注册'
const result = context.onUpdateTaskStep(idx, status, String(note || '').trim())
if (result?.error) return `错误:${result.error}`
const statusLabel = { done: '完成 ✓', failed: '失败 ✗', skipped: '跳过 —' }[status]
return `步骤 ${idx + 1} 已标记为${statusLabel}${note ? '' + note : ''}`
}
function execFocusBanner({ action, task = '', current_step = '', tasks = [] }) {
if (!['show', 'update', 'hide'].includes(action)) {
return toolJson({ ok: false, error: 'action 必须是 show / update / hide' })
}
const bridge = global.focusBannerBridge
if (!bridge) {
return toolJson({ ok: false, error: '桌面功能不可用(非 Electron 环境)' })
}
if (action === 'hide') {
bridge.emit('hide')
return toolJson({ ok: true, action: 'hide', message: '专注横幅已关闭' })
}
const cleanTasks = Array.isArray(tasks)
? tasks.map(t => ({ text: String(t.text || ''), done: !!t.done }))
: []
bridge.emit('command', { action, task: String(task), current_step: String(current_step), tasks: cleanTasks })
return toolJson({ ok: true, action, task, current_step, tasks: cleanTasks })
}
function execSetLocation({ city }) {
const loc = String(city || '').trim()
if (!loc) return toolJson({ ok: false, error: '城市名称不能为空' })
setUserLocation(loc)
return toolJson({ ok: true, city: loc, message: `位置已更新为:${loc}` })
}
function execSetAgentName({ name }) {
const trimmed = String(name || '').trim()
if (!trimmed) return toolJson({ ok: false, error: '名字不能为空' })
if (trimmed.length > 32) return toolJson({ ok: false, error: '名字不能超过 32 个字符' })
if (!/^[一-龥A-Za-z0-9 _-]+$/.test(trimmed)) {
return toolJson({ ok: false, error: '名字只允许包含中文、英文字母、数字、空格、下划线、短横线' })
}
dbSetConfig('agent_name', trimmed)
setStickyEvent('agent_name_updated', { name: trimmed })
emitEvent('agent_name_updated', { name: trimmed })
return toolJson({ ok: true, name: trimmed, message: `好的,我以后就叫 ${trimmed}` })
}
function execConnectWechat() {
if (!hasACUIClient()) {
return toolJson({ ok: false, error: '当前没有 UI 客户端,无法弹出微信连接界面。' })
}
emitEvent('show_wechat_popup', {})
return toolJson({ ok: true, status: 'popup_shown', message: '已弹出微信连接二维码界面,请告知用户扫码操作。' })
}
function execSetSecurity({ file_sandbox, exec_sandbox, reason = '' }) {
if (file_sandbox === undefined && exec_sandbox === undefined) {
return toolJson({ ok: false, error: '至少指定 file_sandbox 或 exec_sandbox 之一' })
}
if (!hasACUIClient()) {
return toolJson({ ok: false, error: '当前没有 UI 客户端,无法弹出确认框。请告知用户到设置页面手动修改安全沙箱配置。' })
}
const props = { reason: reason || '' }
if (file_sandbox !== undefined) props.file_sandbox = file_sandbox
if (exec_sandbox !== undefined) props.exec_sandbox = exec_sandbox
const id = `security-confirm-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`
emitUICommand({ op: 'mount', id, component: 'SecurityConfirmCard', props, hint: { placement: 'center' } })
addActiveUICard(id, { component: 'SecurityConfirmCard' })
emitEvent('action', { tool: 'set_security', summary: '等待用户确认安全设置变更', detail: id })
return toolJson({ ok: true, id, status: 'pending_confirmation', message: '已弹出确认卡片,等待用户确认。' })
}
// 把 Agent 的文档信息格式化成错误响应里的引导字段
function agentDocsHint(agent) {
if (!agent) return {}
const hint = {}
if (agent.docs_url) {
hint.docs_url = agent.docs_url
hint.docs_hint = `调用失败。建议先用 fetch_url("${agent.docs_url}") 查阅 ${agent.name} 当前版本(${agent.version || 'unknown'})的使用文档,确认正确的参数格式后重试。`
} else if (agent.docs_search_query) {
hint.docs_search_query = agent.docs_search_query
hint.docs_hint = `调用失败。建议先用 web_search("${agent.docs_search_query}") 查找 ${agent.name} 当前版本(${agent.version || 'unknown'})的使用文档,确认正确的调用方式后重试。`
}
return hint
}
async function execDelegateToAgent({ agent_id, prompt: agentPrompt, context: agentContext = '', timeout = 60 }) {
if (!isDelegationAllowed()) {
return toolJson({ ok: false, error: '尚未获得 Agent 委托权限,请先询问用户并通过 grant_agent_delegation 获取授权。' })
}
const agent = getAgentById(String(agent_id || ''))
if (!agent) {
return toolJson({ ok: false, error: `未找到 Agent${agent_id}。请先用 list_known_agents 查看可用列表。` })
}
if (!agent.available) {
return toolJson({
ok: false,
error: `Agent ${agent.name} 当前不可用(上次检测:${agent.detected_at})。`,
...agentDocsHint(agent),
})
}
const fullPrompt = agentContext
? `${agentContext.trim()}\n\n${agentPrompt.trim()}`
: agentPrompt.trim()
const timeoutSec = Math.min(Math.max(Number(timeout) || 60, 5), 300)
if (agent.invoke_type === 'cli') {
const safePrompt = fullPrompt.replace(/"/g, '\\"').replace(/\n/g, ' ')
const cmdArgs = (agent.invokeArgs || []).map(a => a === '{prompt}' ? `"${safePrompt}"` : a).join(' ')
const cmd = `${agent.invoke_cmd} ${cmdArgs}`
const result = await execCommand({ command: cmd, timeout: timeoutSec, background: false }, {})
// CLI 调用失败时注入文档引导
try {
const parsed = typeof result === 'string' ? JSON.parse(result) : result
if (parsed?.ok === false || (parsed?.exit_code !== undefined && parsed.exit_code !== 0)) {
return toolJson({ ...parsed, ...agentDocsHint(agent) })
}
} catch { /* result 不是 JSON直接返回 */ }
return result
}
if (agent.invoke_type === 'http') {
const base = agent.invoke_cmd.replace(/\/$/, '')
// Ollama API端口 11434有专属格式需要带 model 字段
const isOllama = base.includes(':11434')
const ollamaModel = agent.notes?.match(/ollama[^)]*\(([^)]+)\)/i)?.[1]
|| agent.id // 用 agent id 作为 model 名的兜底
const endpoints = isOllama
? [{ path: '/api/chat', body: { model: ollamaModel, messages: [{ role: 'user', content: fullPrompt }], stream: false } },
{ path: '/api/generate', body: { model: ollamaModel, prompt: fullPrompt, stream: false } }]
: [{ path: '/api/chat', body: { message: fullPrompt, messages: [{ role: 'user', content: fullPrompt }] } },
{ path: '/v1/chat/completions', body: { messages: [{ role: 'user', content: fullPrompt }] } },
{ path: '/chat', body: { message: fullPrompt } },
{ path: '/query', body: { query: fullPrompt } }]
for (const ep of endpoints) {
try {
const res = await fetch(`${base}${ep.path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ep.body),
signal: AbortSignal.timeout(timeoutSec * 1000),
})
if (res.ok) {
const data = await res.json()
const reply = data?.message?.content || data?.response || data?.message
|| data?.content || data?.choices?.[0]?.message?.content || JSON.stringify(data)
return toolJson({ ok: true, agent_id, agent_name: agent.name, reply: String(reply).slice(0, 4000) })
}
} catch { /* 尝试下一个端点 */ }
}
return toolJson({
ok: false,
error: `无法连接到 ${agent.name}${base}),所有端点均不响应。`,
...agentDocsHint(agent),
})
}
return toolJson({ ok: false, error: `不支持的调用类型:${agent.invoke_type}` })
}
function execGrantAgentDelegation({ allowed, note = '' }) {
try {
dbSetConfig('agent_delegation_asked', 'true')
dbSetConfig('agent_delegation_allowed', allowed ? 'true' : 'false')
} catch (e) {
console.error('[Agents] grant_agent_delegation 写入失败:', e.message)
return toolJson({ ok: false, error: e.message })
}
const msg = allowed
? `已记录授权Bailongma 可以指挥本地 AI 小伙伴工作。`
: `已记录:用户暂不授权 Agent 委托功能。`
return toolJson({ ok: true, allowed: !!allowed, note: String(note || ''), message: msg })
}
function normalizeSelfCheckResults(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const normalized = {}
for (const [key, item] of Object.entries(value)) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
normalized[key] = { status: String(item || 'unknown') }
continue
}
normalized[key] = {
status: String(item.status || item.state || 'unknown').slice(0, 40),
detail: String(item.detail || item.message || '').slice(0, 500),
}
}
return normalized
}
function execCompleteStartupSelfCheck({ summary = '', results = {} } = {}, context = {}) {
if (!context?.startupSelfCheck?.active || !context?.onCompleteStartupSelfCheck) {
return toolJson({
ok: false,
tool: 'complete_startup_self_check',
error: 'startup self-check is not active',
})
}
const cleanResults = normalizeSelfCheckResults(results)
const completed = context.onCompleteStartupSelfCheck({
summary: String(summary || '').slice(0, 1000),
results: cleanResults,
})
return toolJson({
ok: true,
tool: 'complete_startup_self_check',
version: completed.version,
status: completed.status,
completed_at: completed.completed_at,
results: cleanResults,
})
}
async function execRecallMemory({ query }, context) {
if (!query?.trim()) return '错误:未提供查询内容'
if (context?.onRecall) context.onRecall(query.trim())
const rows = searchMemories(query.trim(), 8)
if (rows.length === 0) return `记忆库中未找到与"${query}"相关的内容,已标记下轮持续关注此主题。`
const results = rows.map(m =>
`[${m.timestamp.slice(0, 10)}] ${m.event_type || m.type || ''}: ${m.content}\n ${(m.detail || '').slice(0, 100)}`
).join('\n\n')
return `已找到 ${rows.length} 条相关记忆(下轮将持续注入此主题):\n\n${results}`
}