Agent/clients/anthropic-client.ts
2026-08-06 22:17:39 +08:00

530 lines
16 KiB
TypeScript

// @ts-nocheck
import {
anthropicUrl, zhongtaiProxyUrl, zhongtaiOmlxProxyUrl,
loadAnthropicConfig, loadThinkingEnabled, cloudProtocol,
} 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';
const SYSTEM_PROMPT = `
${AGENT_IDENTITY_CORE}
你是"ccSparkle Agent",地铁乘务专业的智能助手,基于 MCP 协议操作本地地铁运营图 Electron 应用。
【回答风格 - 严格遵守】
- **只用简体中文**回复用户,禁止整句英文(见上方【语言】规则)。
- 用自然口语化的中文,专业、简洁、有温度。
- **禁止使用任何 emoji 表情符号**,全部用纯文字表达。
- 不要机械复述身份描述。
【工具调用规则 - 必须】
- 涉及地铁业务时,**必须真正调用工具**(发 tool_use 块),不允许只用文字描述。
- 工具失败时如实告知错误。
- 多候选时列给用户选,不自己代决定。
${DOMAIN_RULES}
`;
export async function chat(opts) {
if (cloudProtocol() === 'openai') {
return chatOpenAI(opts);
}
return chatAnthropic(opts);
}
function resolveTierModel(cfg) {
return ({
fast: cfg.fastModel,
standard: cfg.standardModel,
pro: cfg.proModel,
})[cfg.currentTier] || cfg.fastModel;
}
function assertCloudConfig(cfg, tierModel) {
if (!cfg.authToken) {
throw new Error('未配置 Auth Token,请点配置按钮(配)设置云端模型');
}
if (!cfg.baseUrl) {
throw new Error('未配置 Base URL,请点配置按钮(配)设置云端模型');
}
if (!tierModel) {
throw new Error('未配置当前档位的模型名,请点配置按钮(配)设置');
}
}
async function chatAnthropic({ messages, tools, signal, onTextDelta, onThinkingDelta, maxTokens: maxTokensOpt, temperature }) {
const cfg = loadAnthropicConfig();
const thinkOn = loadThinkingEnabled();
const tierModel = resolveTierModel(cfg);
assertCloudConfig(cfg, tierModel);
let maxTokens = maxTokensOpt != null
? maxTokensOpt
: (cfg.maxTokens || 4096);
if (maxTokensOpt == null && thinkOn && maxTokens < 8192) maxTokens = 8192;
const systemExtra = messages
.filter(m => m.role === 'system' && m.content)
.map(m => m.content)
.join('\n\n');
const body = {
model: tierModel,
max_tokens: maxTokens,
system: /claude/i.test(tierModel)
? [
{ type: 'text', text: SYSTEM_PROMPT },
...(systemExtra ? [{ type: 'text', text: systemExtra }] : []),
].map((b, i, arr) => (
i === arr.length - 1 ? { ...b, cache_control: { type: 'ephemeral' } } : b
))
: (systemExtra ? `${SYSTEM_PROMPT}\n\n${systemExtra}` : SYSTEM_PROMPT),
messages: convertMessagesAnthropic(messages),
stream: true,
thinking: { type: thinkOn ? 'enabled' : 'disabled' },
};
if (temperature != null && !(thinkOn && /claude/i.test(tierModel))) {
body.temperature = temperature;
}
if (thinkOn && /claude/i.test(tierModel)) {
const budget = Math.min(10000, Math.max(1024, Math.floor(maxTokens * 0.4)));
body.thinking.budget_tokens = budget;
if (body.max_tokens <= budget) body.max_tokens = budget + 2048;
}
if (tools && tools.length > 0) {
body.tools = tools.map(t => ({
name: t.name,
description: t.description || '',
input_schema: t.inputSchema || { type: 'object', properties: {} },
}));
}
const r = await fetch(zhongtaiProxyUrl(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Cloud-Url': anthropicUrl(),
'X-Cloud-Token': cfg.authToken,
'anthropic-version': cfg.apiVersion || '2023-06-01',
},
body: JSON.stringify(body),
signal,
});
if (!r.ok) {
const text = await r.text();
throw new Error(`云端 API HTTP ${r.status}: ${text.slice(0, 300)}`);
}
return consumeAnthropicSSE(r.body, { onTextDelta, onThinkingDelta, thinkOn });
}
function convertMessagesAnthropic(messages) {
const out = [];
let pendingToolResults = [];
const flushToolResults = () => {
if (pendingToolResults.length > 0) {
out.push({ role: 'user', content: pendingToolResults });
pendingToolResults = [];
}
};
for (const m of messages) {
if (m.role === 'system') continue;
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
flushToolResults();
const content = [];
if (m.content) content.push({ type: 'text', text: m.content });
for (const tc of m.toolCalls) {
content.push({
type: 'tool_use',
id: tc.id,
name: tc.name,
input: tc.arguments || {},
});
}
out.push({ role: 'assistant', content });
} else if (m.role === 'tool') {
pendingToolResults.push({
type: 'tool_result',
tool_use_id: m.toolCallId,
content: m.content,
});
} else {
flushToolResults();
out.push({ role: m.role, content: m.content || '' });
}
}
flushToolResults();
return out;
}
async function consumeAnthropicSSE(body, { onTextDelta, onThinkingDelta, thinkOn }) {
const reader = body.getReader();
const decoder = new TextDecoder();
let lineBuf = '';
const t0 = performance.now();
const blocks = new Map();
let outputTokens = 0;
let inputUsage = null;
let fullText = '';
let fullThinking = '';
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) continue;
let evt;
try {
evt = JSON.parse(data);
} catch (e) {
continue;
}
handleAnthropicEvent(evt, blocks, {
onTextDelta,
onThinkingDelta,
thinkOn,
appendText: (t) => { fullText += t; },
appendThinking: (t) => { fullThinking += t; },
});
if (evt.type === 'message_start' && evt.message?.usage) {
inputUsage = { ...evt.message.usage };
}
if (evt.type === 'message_delta' && evt.usage) {
outputTokens = evt.usage.output_tokens || outputTokens;
inputUsage = { ...(inputUsage || {}), ...evt.usage };
}
}
}
const toolCalls = [];
for (const [, block] of [...blocks.entries()].sort((a, b) => a[0] - b[0])) {
if (block.type === 'tool_use' && block.toolName) {
let input = {};
if (block.toolInput) {
try {
input = JSON.parse(block.toolInput);
} catch (e) {
input = { _raw: block.toolInput };
}
}
toolCalls.push({
id: block.toolId,
name: block.toolName,
arguments: input,
});
}
}
if (inputUsage) {
reportPromptCache(inputUsage);
}
if (outputTokens > 0) {
const durationMs = performance.now() - t0;
reportTokens(outputTokens, durationMs);
reportStreamEnd(outputTokens);
}
return {
text: fullText.trim(),
thinking: fullThinking,
toolCalls,
evalCount: outputTokens,
};
}
function handleAnthropicEvent(evt, blocks, hooks) {
const {
onTextDelta,
onThinkingDelta,
thinkOn,
appendText,
appendThinking,
} = hooks;
switch (evt.type) {
case 'content_block_start': {
const idx = evt.index;
const block = evt.content_block || {};
blocks.set(idx, {
type: block.type,
text: block.text || '',
thinking: block.thinking || '',
toolName: block.name,
toolId: block.id,
toolInput: '',
});
if (block.type === 'thinking' || block.type === 'redacted_thinking') {
const piece = block.thinking || '';
if (piece) {
appendThinking(piece);
if (thinkOn) onThinkingDelta?.(piece);
}
}
if (block.type === 'text' && block.text) {
if (onTextDelta) onTextDelta(block.text);
appendText(block.text);
}
break;
}
case 'content_block_delta': {
const idx = evt.index;
const block = blocks.get(idx);
if (!block) return;
const delta = evt.delta || {};
if (delta.type === 'text_delta') {
block.text += delta.text || '';
if (onTextDelta) onTextDelta(delta.text || '');
appendText(delta.text || '');
reportStreamDelta((delta.text || '').length);
} else if (delta.type === 'thinking_delta') {
const piece = delta.thinking || '';
block.thinking = (block.thinking || '') + piece;
appendThinking(piece);
if (thinkOn && piece) onThinkingDelta?.(piece);
reportStreamDelta(piece.length);
} else if (delta.type === 'input_json_delta') {
block.toolInput += delta.partial_json || '';
}
break;
}
case 'content_block_stop': {
break;
}
}
}
async function chatOpenAI({ messages, tools, signal, onTextDelta, onThinkingDelta, maxTokens: maxTokensOpt, temperature }) {
const cfg = loadAnthropicConfig();
const thinkOn = loadThinkingEnabled();
const tierModel = resolveTierModel(cfg);
assertCloudConfig(cfg, tierModel);
let maxTokens = maxTokensOpt != null
? maxTokensOpt
: (cfg.maxTokens || 4096);
if (maxTokensOpt == null && thinkOn && maxTokens < 8192) maxTokens = 8192;
const body = {
model: tierModel,
max_tokens: maxTokens,
messages: convertMessagesOpenAI(messages),
stream: true,
stream_options: { include_usage: true },
};
if (temperature != null) {
body.temperature = temperature;
}
if (/glm/i.test(tierModel)) {
body.thinking = { type: thinkOn ? 'enabled' : 'disabled' };
}
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(zhongtaiOmlxProxyUrl(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Cloud-Url': anthropicUrl(),
'X-Cloud-Token': cfg.authToken,
},
body: JSON.stringify(body),
signal,
});
if (!r.ok) {
const text = await r.text();
throw new Error(`云端 API(OpenAI) HTTP ${r.status}: ${text.slice(0, 300)}`);
}
return consumeOpenAISSE(r.body, { onTextDelta, onThinkingDelta, thinkOn });
}
function convertMessagesOpenAI(messages) {
const systemExtra = messages
.filter(m => m.role === 'system' && m.content)
.map(m => m.content)
.join('\n\n');
const out = [{
role: 'system',
content: systemExtra ? `${SYSTEM_PROMPT}\n\n${systemExtra}` : SYSTEM_PROMPT,
}];
for (const m of messages) {
if (m.role === 'system') continue;
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
out.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') {
out.push({
role: 'tool',
tool_call_id: m.toolCallId,
content: m.content,
});
} else {
out.push({ role: m.role, content: m.content || '' });
}
}
return out;
}
async function consumeOpenAISSE(body, { onTextDelta, onThinkingDelta, thinkOn }) {
const reader = body.getReader();
const decoder = new TextDecoder();
let lineBuf = '';
let fullText = '';
let fullThinking = '';
let evalCount = 0;
const t0 = performance.now();
const toolAcc = new Map();
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) {
fullThinking += thinkPiece;
if (thinkOn) onThinkingDelta?.(thinkPiece);
reportStreamDelta(thinkPiece.length);
}
if (delta.content) {
fullText += delta.content;
if (onTextDelta) onTextDelta(delta.content);
reportStreamDelta(delta.content.length);
}
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
let acc = toolAcc.get(idx);
if (!acc) {
acc = {
id: tc.id || `toolu_openai_${Date.now()}_${idx}`,
name: '',
arguments: '',
};
toolAcc.set(idx, acc);
}
if (tc.id) acc.id = tc.id;
if (tc.function?.name) acc.name += tc.function.name;
if (tc.function?.arguments) acc.arguments += tc.function.arguments;
}
}
if (chunk.usage) {
evalCount = chunk.usage.completion_tokens || evalCount;
reportPromptCache(chunk.usage);
}
}
}
const toolCalls = [...toolAcc.entries()]
.sort((a, b) => a[0] - b[0])
.filter(([, acc]) => acc.name)
.map(([, acc]) => ({
id: acc.id,
name: acc.name,
arguments: safeParseJson(acc.arguments),
}));
if (evalCount > 0) {
const durationMs = performance.now() - t0;
reportTokens(evalCount, durationMs);
reportStreamEnd(evalCount);
}
return {
text: fullText.trim(),
thinking: fullThinking,
toolCalls,
evalCount,
};
}
function safeParseJson(s) {
if (!s) return {};
try {
return JSON.parse(s);
} catch (e) {
return { _raw: s };
}
}