// @ts-nocheck import { sparkleUrl, zhongtaiOmlxProxyUrl, loadSparkleConfig, loadThinkingEnabled } from '../utils/config.js'; import { reportTokens, reportStreamDelta, reportStreamEnd, reportPromptCache } from '../utils/metrics-client.js'; import { AGENT_IDENTITY_CORE } from '../core/agent-identity-core.js'; import { DOMAIN_RULES } from '../core/domain-rules.js'; import { buildAnalysisSystemPrompt } from '../reports/md-splitter.js'; import { scanToolCallBuffer, finalizeToolCallBuffer, cleanToolCallArtifacts } from '../utils/tool-call-parse.js'; import { createThinkSplitter, stripThinkTags } from '../core/think-parse.js'; import { friendlyLocalFetchError } from '../utils/model-manager.js'; const FALLBACK_TOKEN = 'sparkle'; const IDENTITY = ` ${AGENT_IDENTITY_CORE} 你是"ccSparkle Agent",地铁乘务专业的智能助手,基于本地部署的 Sparkle 推理服务(OpenAI 兼容协议),通过 MCP 协议操作本地地铁运营图 Electron 应用。 【回答风格 - 严格遵守】 - **只用简体中文**回复用户,禁止整句英文(见上方【语言】规则)。 - 用自然口语化的中文,专业、简洁、有温度。 - **禁止使用任何 emoji 表情符号**,全部用纯文字表达。 - 不要机械复述身份描述。 【工具调用规则】 - **仅当本轮 messages 里提供了 tools、且用户有明确操作指令时**才可调用工具。 - 自我介绍、向评委讲解、寒暄、概念说明:**禁止**输出 \`\`,直接写中文正文。 - 有工具可调时格式: \`{"name":"metro_xxx","arguments":{...}}\` - metro_load_timetable_full 的 arguments **只有** query(字符串,如"工作日"),不要臆造 dayType 等 schema 里没有的字段 - 工具失败时如实告知错误。 - 多候选时列给用户选,不自己代决定。 - **employeeId 只填裸 emp_xxx**(如 emp_d3ms001)。花名册工号列若出现 [ID:emp_xxx] 展示形态,填参时去掉方括号与 ID: 前缀,只保留 emp_xxx。禁止把 employeeName 填成「年度」。花名册已精确命中后禁止改口说「姓名有误请确认」。 - **查某人年度数据/分析评估报告**(本轮有 profile_* 工具时):必须先调 \`profile_list_employees\`(再按需调报告工具)。禁止未调花名册就断言「无匹配/姓名有误」。禁止仅凭对话历史里的旧结论跳过工具。带数字的姓名(如「张磊2」)是合法全名,禁止擅自改成「张磊」再追问用户。 ${DOMAIN_RULES} `; export async function chat({ messages, tools, signal, onTextDelta, onThinkingDelta, maxTokens: maxTokensOpt, temperature }) { const cfg = loadSparkleConfig(); const apiKey = cfg.apiKey || FALLBACK_TOKEN; const thinkOn = loadThinkingEnabled(); const leadIdx = messages.findIndex(m => m.role !== 'system'); const leadEnd = leadIdx === -1 ? messages.length : leadIdx; const systemExtra = messages.slice(0, leadEnd) .map(m => m.content).filter(Boolean).join('\n\n'); const sparkleMessages = [{ role: 'system', content: systemExtra ? `${IDENTITY}\n\n${systemExtra}` : IDENTITY, }]; for (const m of messages.slice(leadEnd)) { if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { sparkleMessages.push({ role: 'assistant', content: m.content || null, tool_calls: m.toolCalls.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments || {}), }, })), }); } else if (m.role === 'tool') { sparkleMessages.push({ role: 'tool', tool_call_id: m.toolCallId, content: m.content, }); } else { let content = m.content || ''; if (m.role === 'user' && content) { if (!thinkOn && !content.includes('/no_think') && !content.includes('/think')) { content = `${content} /no_think`; } else if (thinkOn && !content.includes('/think') && !content.includes('/no_think')) { content = `${content} /think`; } } sparkleMessages.push({ role: m.role, content }); } } const payload = { model: cfg.model, messages: sparkleMessages, stream: true, stream_options: { include_usage: true }, chat_template_kwargs: { enable_thinking: !!thinkOn }, enable_thinking: !!thinkOn, }; if (maxTokensOpt != null) { payload.max_tokens = maxTokensOpt; } if (temperature != null && Number(temperature) > 0) { payload.temperature = temperature; } else if (temperature != null && Number(temperature) === 0) { payload.temperature = 0.7; payload.top_p = 0.8; payload.top_k = 20; } if (tools && tools.length > 0) { payload.tools = tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description || '', parameters: t.inputSchema || { type: 'object', properties: {} }, }, })); } let r; try { r = await fetch(zhongtaiOmlxProxyUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Cloud-Url': sparkleUrl(), 'X-Cloud-Token': apiKey, }, body: JSON.stringify(payload), signal, }); } catch (e) { throw friendlyLocalFetchError('sparkle', e); } if (!r.ok) { const text = await r.text(); if (r.status === 502 || /ECONNREFUSED|fetch failed|connect/i.test(text)) { throw friendlyLocalFetchError('sparkle', new Error(text.slice(0, 120))); } throw new Error(`Sparkle HTTP ${r.status}: ${text.slice(0, 300)}`); } return consumeStream(r.body, { onTextDelta, onThinkingDelta, thinkOn }); } let _warmedUp = false; export async function warmup(tools) { if (_warmedUp) return true; const cfg = loadSparkleConfig(); const apiKey = cfg.apiKey || FALLBACK_TOKEN; try { const body = { model: cfg.model, messages: [ { role: 'system', content: IDENTITY }, { role: 'user', content: 'ping' }, ], stream: false, max_tokens: 1, temperature: 0, }; if (tools && tools.length > 0) { body.tools = tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description || '', parameters: t.inputSchema || { type: 'object', properties: {} }, }, })); } const r = await fetch(sparkleUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify(body), }); if (r.ok) { _warmedUp = true; return true; } return false; } catch (e) { return false; } } let _reportWarmedUp = false; export async function warmupReportAnalysis() { if (_reportWarmedUp) return true; const cfg = loadSparkleConfig(); const apiKey = cfg.apiKey || FALLBACK_TOKEN; const analysisSystem = buildAnalysisSystemPrompt({ reportKind: 'person', mainPid: 'emp_warmup' }); try { const r = await fetch(sparkleUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify({ model: cfg.model, messages: [ { role: 'system', content: `${IDENTITY}\n\n${analysisSystem}` }, { role: 'user', content: 'ping /no_think' }, ], stream: false, max_tokens: 1, temperature: 0, chat_template_kwargs: { enable_thinking: false }, enable_thinking: false, }), }); if (r.ok) { _reportWarmedUp = true; return true; } return false; } catch (e) { return false; } } export async function listModels() { const cfg = loadSparkleConfig(); const apiKey = cfg.apiKey || FALLBACK_TOKEN; try { const r = await fetch(`${cfg.baseUrl.replace(/\/+$/, '')}/v1/models`, { headers: { 'Authorization': `Bearer ${apiKey}` }, }); if (!r.ok) return []; const data = await r.json(); return (data.data || []).map(m => m.id); } catch (e) { return []; } } async function consumeStream(body, { onTextDelta, onThinkingDelta, thinkOn }) { const reader = body.getReader(); const decoder = new TextDecoder(); let lineBuf = ''; let textBuf = ''; let fullText = ''; let fullThinking = ''; let toolCalls = []; let evalCount = 0; const t0 = performance.now(); const thinkSplit = createThinkSplitter(thinkOn); let rawSample = ''; let rawLen = 0; let sawReasoningField = false; let reasoningFieldLen = 0; const emitContent = (raw) => { if (!raw) return; rawLen += raw.length; if (rawSample.length < 400) rawSample += raw.slice(0, 400 - rawSample.length); const split = thinkSplit.push(raw); if (split.thinking) { fullThinking += split.thinking; if (thinkOn) onThinkingDelta?.(split.thinking); } if (split.content) { textBuf += split.content; fullText += split.content; const result = scanToolCallBuffer(textBuf); if (result.safeText && onTextDelta) onTextDelta(result.safeText); textBuf = result.remainder; if (result.toolCalls.length > 0) toolCalls.push(...result.toolCalls); reportStreamDelta(split.content.length); } }; while (true) { const { done, value } = await reader.read(); if (done) break; lineBuf += decoder.decode(value, { stream: true }); const lines = lineBuf.split('\n'); lineBuf = lines.pop(); for (const line of lines) { if (!line.startsWith('data:')) continue; const data = line.slice(5).trim(); if (!data || data === '[DONE]') continue; let chunk; try { chunk = JSON.parse(data); } catch (e) { continue; } const choice = chunk.choices?.[0]; const delta = choice?.delta; const thinkPiece = delta?.reasoning_content || delta?.reasoning || ''; if (thinkPiece) { sawReasoningField = true; reasoningFieldLen += thinkPiece.length; fullThinking += thinkPiece; if (thinkOn) onThinkingDelta?.(thinkPiece); reportStreamDelta(thinkPiece.length); } if (delta?.content) emitContent(delta.content); if (delta?.tool_calls) { for (const tc of delta.tool_calls) { if (tc.function?.name) { toolCalls.push({ id: tc.id || `toolu_openai_${Date.now()}_${toolCalls.length}`, name: tc.function.name, arguments: typeof tc.function.arguments === 'string' ? safeParse(tc.function.arguments) : (tc.function.arguments || {}), }); } } } if (chunk.usage) { evalCount = chunk.usage.completion_tokens || evalCount; reportPromptCache(chunk.usage); } } } const tail = thinkSplit.flush(); if (tail.thinking) { fullThinking += tail.thinking; if (thinkOn) onThinkingDelta?.(tail.thinking); } if (tail.content) { textBuf += tail.content; fullText += tail.content; } const final = finalizeToolCallBuffer(textBuf); if (final.safeText && onTextDelta) onTextDelta(final.safeText); if (final.toolCalls.length > 0) toolCalls.push(...final.toolCalls); const cleanedText = stripThinkTags(cleanToolCallArtifacts(fullText)); if (evalCount > 0) { const durationMs = performance.now() - t0; reportTokens(evalCount, durationMs); reportStreamEnd(evalCount); } return { text: cleanedText, thinking: fullThinking, toolCalls: sanitizeSparkleProfileArgs(toolCalls), evalCount, }; } function sanitizeSparkleProfileArgs(toolCalls) { if (!Array.isArray(toolCalls) || toolCalls.length === 0) return toolCalls; return toolCalls.map((tc) => { const name = tc?.name || ''; if (!String(name).startsWith('profile_')) return tc; const args = { ...(tc.arguments || {}) }; if (args.employeeId != null && String(args.employeeId).trim()) { const s = String(args.employeeId).trim(); const wrapped = s.match(/\[(?:ID|NAME):(emp_[a-z0-9]+)\]/i); const bare = s.match(/\b(emp_[a-z0-9]+)\b/i); if (wrapped) args.employeeId = wrapped[1].toLowerCase(); else if (bare) args.employeeId = bare[1].toLowerCase(); } if (args.employeeName != null) { const en = String(args.employeeName).trim(); if (!en || /^(年度|年份|年|数据|报告|评估|画像)$/.test(en)) { delete args.employeeName; } } return { ...tc, arguments: args }; }); } function safeParse(s) { try { let cleaned = s.replace(/\{\{/g, '{').replace(/\}\}/g, '}'); return JSON.parse(cleaned); } catch (e) { return { _raw: s }; } }