增加调试模式的全量日志功能。

修复工具调用2
This commit is contained in:
2026-08-07 14:58:56 +08:00
parent 3326ea3f4a
commit 7ddb460afc
3 changed files with 161 additions and 303 deletions
+84 -230
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name Halo WebUI OpenAI 本机桥接
// @namespace http://tampermonkey.net/
// @version 2.0.0
// @version 2.1.0
// @description 使用当前 Halo 登录会话代理 OpenAI Chat Completions,并实时转发回复。
// @match http://192.168.0.87:3000/*
// @grant none
@@ -10,7 +10,6 @@
(function () {
'use strict';
const BRIDGE_URL = 'http://127.0.0.1:8787';
const POLL_INTERVAL_MS = 1000;
const SOCKET_PATH = '/ws/socket.io/?EIO=4&transport=websocket';
@@ -19,275 +18,130 @@
let socketReady;
let socketSessionId = '';
let activeHaloTask;
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function bridgeRequest(path, options = {}) {
const response = await fetch(`${BRIDGE_URL}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
const data = await response.json();
if (!response.ok) throw new Error(data?.error?.message || `本机桥接请求失败 (${response.status})`);
return data;
}
let debugSequence = 0;
function uuid() {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const bytes = new Uint8Array(16); crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 15) | 64; bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
async function bridgeRequest(path, options = {}) {
const response = await fetch(`${BRIDGE_URL}${path}`, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
const text = await response.text();
let data; try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; }
if (!response.ok) throw new Error(data?.error?.message || `本机桥接请求失败 (${response.status})`);
return data;
}
function pageDebug(event, data = {}) {
return bridgeRequest('/bridge/debug', { method: 'POST', body: JSON.stringify({ event, taskId: activeHaloTask?.bridgeId || null, sequence: ++debugSequence, at: new Date().toISOString(), socketSessionId, data }) }).catch(() => {});
}
function findJwt(value, visited = new Set()) {
if (typeof value === 'string') {
const match = value.match(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/);
return match?.[0] || '';
}
if (typeof value === 'string') return value.match(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/)?.[0] || '';
if (!value || typeof value !== 'object' || visited.has(value)) return '';
visited.add(value);
for (const item of Object.values(value)) {
const token = findJwt(item, visited);
if (token) return token;
}
return '';
visited.add(value); for (const item of Object.values(value)) { const token = findJwt(item, visited); if (token) return token; } return '';
}
function getStoredToken() {
for (const storage of [localStorage, sessionStorage]) {
for (let index = 0; index < storage.length; index += 1) {
const value = storage.getItem(storage.key(index));
try {
const token = findJwt(JSON.parse(value));
if (token) return token;
} catch (_) {
const token = findJwt(value);
if (token) return token;
}
}
for (const storage of [localStorage, sessionStorage]) for (let index = 0; index < storage.length; index += 1) {
const value = storage.getItem(storage.key(index));
try { const token = findJwt(JSON.parse(value)); if (token) return token; } catch (_) { const token = findJwt(value); if (token) return token; }
}
return findJwt(document.cookie);
}
function socketSend(packet) { pageDebug('socket-outbound-raw', { packet }); socket.send(packet); }
function connectSocket() {
if (socketReady) return socketReady;
socketReady = new Promise((resolve, reject) => {
const token = getStoredToken();
if (!token) {
socketReady = null;
reject(new Error('未在页面存储中找到 Halo 登录令牌;请刷新已登录的 Halo 页面后重试。'));
return;
}
pageDebug('socket-connect-start', { token });
if (!token) { socketReady = null; reject(new Error('未在页面存储中找到 Halo 登录令牌。')); return; }
socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}${SOCKET_PATH}`);
const timer = setTimeout(() => reject(new Error('Halo Socket.IO 连接超时。')), 10000);
const timer = setTimeout(() => { pageDebug('socket-timeout'); reject(new Error('Halo Socket.IO 连接超时。')); }, 10000);
socket.addEventListener('open', () => { pageDebug('socket-open'); socketSend(`40${JSON.stringify({ token })}`); });
socket.addEventListener('message', event => {
const packet = String(event.data);
const packet = String(event.data); pageDebug('socket-inbound-raw', { packet });
if (packet.startsWith('0')) return;
if (packet.startsWith('40')) {
try { socketSessionId = JSON.parse(packet.slice(2)).sid || socketSessionId; } catch (_) {}
clearTimeout(timer);
resolve();
return;
}
if (packet.startsWith('2')) {
socket.send('3');
return;
}
if (packet.startsWith('40')) { try { socketSessionId = JSON.parse(packet.slice(2)).sid || socketSessionId; } catch (error) { pageDebug('socket-sid-parse-error', { packet, error: error.message }); } clearTimeout(timer); pageDebug('socket-ready', { socketSessionId }); resolve(); return; }
if (packet.startsWith('2')) { socketSend('3'); return; }
if (!packet.startsWith('42')) return;
try {
const [eventName, payload] = JSON.parse(packet.slice(2));
if (eventName === 'chat-events') handleChatEvent(payload);
} catch (error) {
console.warn('[Halo Bridge] 无法解析 Socket.IO 消息', error);
}
try { const [eventName, payload] = JSON.parse(packet.slice(2)); const matches = Boolean(activeHaloTask && payload?.chat_id === activeHaloTask.chatId && payload?.message_id === activeHaloTask.messageId); pageDebug('socket-event', { eventName, payload, matches }); if (eventName === 'chat-events' && matches) handleChatEvent(payload); }
catch (error) { pageDebug('socket-parse-error', { packet, error: error.message }); }
});
socket.addEventListener('open', () => socket.send(`40${JSON.stringify({ token })}`));
socket.addEventListener('close', () => {
socketReady = null;
socket = null;
socketSessionId = '';
if (activeHaloTask) failActiveTask('Halo Socket.IO 连接已断开。');
});
socket.addEventListener('error', () => {
clearTimeout(timer);
socketReady = null;
reject(new Error('无法建立 Halo Socket.IO 连接。'));
});
});
return socketReady;
socket.addEventListener('close', event => { pageDebug('socket-close', { code: event.code, reason: event.reason }); socketReady = null; socket = null; socketSessionId = ''; if (activeHaloTask) failActiveTask('Halo Socket.IO 连接已断开。'); });
socket.addEventListener('error', () => { clearTimeout(timer); socketReady = null; pageDebug('socket-error'); reject(new Error('无法建立 Halo Socket.IO 连接。')); });
}); return socketReady;
}
function plainContent(html) {
return String(html || '')
.replace(/<details\b[\s\S]*?<\/details>\s*/gi, '')
.replace(/<[^>]+>/g, '')
.trim();
}
function plainContent(html) { return String(html || '').replace(/<details\b[\s\S]*?<\/details>\s*/gi, '').replace(/<[^>]+>/g, '').replace(/<||[^|]+||>/g, '').trim(); }
function appendDelta(previous, current) { if (!previous) return current; if (current.startsWith(previous)) return current.slice(previous.length); let index = 0; while (index < Math.min(previous.length, current.length) && previous[index] === current[index]) index += 1; return current.slice(index); }
function appendDelta(previous, current) {
if (!previous) return current;
if (current.startsWith(previous)) return current.slice(previous.length);
let index = 0;
const limit = Math.min(previous.length, current.length);
while (index < limit && previous[index] === current[index]) index += 1;
return current.slice(index);
}
async function failActiveTask(message) {
const task = activeHaloTask;
if (!task || task.finished) return;
task.finished = true;
activeHaloTask = null;
try {
await bridgeRequest(`/bridge/tasks/${task.bridgeId}/failed`, {
method: 'POST', body: JSON.stringify({ message })
});
} catch (error) {
console.error('[Halo Bridge] 无法回传失败状态', error);
function parseHaloToolCalls(source) {
source = String(source || ''); const marker = source.search(/(?:DSML.*tool_calls|tool_calls)/i);
if (marker < 0) return { content: plainContent(source), toolCalls: [] };
const visible = source.slice(0, source.lastIndexOf('<', marker)); const block = source.slice(Math.max(0, source.lastIndexOf('<', marker)));
const toolCalls = []; const invokePattern = /(?:invoke|tool_call)\s+name\s*=\s*["=]?\s*([^"<\s|]+)/gi; let match;
while ((match = invokePattern.exec(block))) {
const name = match[1].trim(); const start = match.index + match[0].length; const end = block.search(/(?:\/invoke|\/tool_call)/i); const body = end >= 0 ? block.slice(start, end) : block.slice(start);
const args = {}; const parameterPattern = /parameter\s+name\s*=\s*["=]?\s*([^"<\s|]+)[^>|]*[>|]?([\s\S]*?)(?=(?:parameter\s+name|\/parameter|\/invoke|\/tool_call|$))/gi; let parameter;
while ((parameter = parameterPattern.exec(body))) args[parameter[1].trim()] = plainContent(parameter[2]).trim();
toolCalls.push({ id: `call_${uuid().replace(/-/g, '').slice(0, 24)}`, type: 'function', function: { name, arguments: JSON.stringify(args) } });
}
return { content: plainContent(visible), toolCalls };
}
async function failActiveTask(message) { const task = activeHaloTask; if (!task || task.finished) return; task.finished = true; activeHaloTask = null; pageDebug('halo-task-failed', { message }); await bridgeRequest(`/bridge/tasks/${task.bridgeId}/failed`, { method: 'POST', body: JSON.stringify({ message }) }).catch(() => {}); }
function handleChatEvent(event) {
const task = activeHaloTask;
if (!task || task.finished || event.chat_id !== task.chatId || event.message_id !== task.messageId) return;
const eventData = event.data;
if (eventData?.type !== 'chat:completion') return;
const data = eventData.data || {};
if (typeof data.content === 'string') {
const content = plainContent(data.content);
const delta = appendDelta(task.content, content);
task.content = content;
if (delta) {
bridgeRequest(`/bridge/tasks/${task.bridgeId}/delta`, {
method: 'POST', body: JSON.stringify({ content: delta })
}).catch(error => failActiveTask(error.message));
}
}
const finishReason = data.choices?.[0]?.finish_reason;
if (data.done || finishReason === 'stop') {
task.finished = true;
activeHaloTask = null;
bridgeRequest(`/bridge/tasks/${task.bridgeId}/complete`, {
method: 'POST',
body: JSON.stringify({ content: task.content, usage: data.usage || task.usage })
}).catch(error => console.error('[Halo Bridge] 无法回传完成状态', error));
} else if (data.usage) {
task.usage = data.usage;
}
}
function getChatId() {
const match = location.pathname.match(/^\/c\/([^/?#]+)/);
if (!match) throw new Error('请打开一个 Halo 对话页面(URL 应为 /c/{chat_id})。');
return match[1];
const task = activeHaloTask; if (!task || task.finished) return;
const data = event.data?.data || {}; const finishReason = data.choices?.[0]?.finish_reason;
pageDebug('halo-chat-event-consumed', { event, finishReason });
if (typeof data.content === 'string') { task.rawContent = data.content; const parsed = parseHaloToolCalls(data.content); const delta = appendDelta(task.content, parsed.content); task.content = parsed.content; task.toolCalls = parsed.toolCalls; if (delta) bridgeRequest(`/bridge/tasks/${task.bridgeId}/delta`, { method: 'POST', body: JSON.stringify({ content: delta }) }).catch(error => failActiveTask(error.message)); }
if (data.usage) task.usage = data.usage;
if (data.done || finishReason === 'stop' || finishReason === 'tool_calls') { task.finished = true; activeHaloTask = null; pageDebug('halo-complete', { finishReason, rawContent: task.rawContent, parsedContent: task.content, toolCalls: task.toolCalls }); bridgeRequest(`/bridge/tasks/${task.bridgeId}/complete`, { method: 'POST', body: JSON.stringify({ content: task.content, raw_content: task.rawContent, tool_calls: task.toolCalls, usage: data.usage || task.usage, halo_finish_reason: finishReason }) }).catch(error => console.error('[Halo Bridge]', error)); }
}
async function haloJson(path, options = {}) {
const response = await fetch(path, {
...options,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
const data = await response.json();
if (!response.ok) throw new Error(data?.detail || data?.message || `Halo API 请求失败 (${response.status})`);
return data;
}
async function getModelItem(modelId) {
const models = await haloJson('/api/models');
const list = Array.isArray(models) ? models : models.data || [];
return list.find(item => item?.id === modelId || item?.model_id === modelId) || { id: modelId, name: modelId };
const response = await fetch(path, { ...options, credentials: 'include', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
const raw = await response.text(); pageDebug('halo-http-response', { path, method: options.method || 'GET', requestBody: options.body || null, status: response.status, headers: Object.fromEntries(response.headers.entries()), raw });
let data; try { data = raw ? JSON.parse(raw) : {}; } catch (error) { throw new Error(`Halo 响应不是 JSON:${error.message}`); }
if (!response.ok) throw new Error(data?.detail || data?.message || `Halo API 请求失败 (${response.status})`); return data;
}
function textContent(content) { return typeof content === 'string' ? content : Array.isArray(content) ? content.filter(part => part?.type === 'text').map(part => part.text).join('\n') : ''; }
function normalizeMessages(messages) {
return messages.map(message => ({
role: message.role,
content: typeof message.content === 'string'
? message.content
: Array.isArray(message.content)
? message.content.filter(part => part?.type === 'text').map(part => part.text).join('\n')
: ''
}));
const toolSchemas = [];
const normalized = messages.map(message => {
const text = textContent(message.content);
if (message.role === 'tool') return { role: 'user', content: `<||DSML||tool_result call_id="${message.tool_call_id}">\n${text}\n<||DSML||/tool_result>` };
if (!Array.isArray(message.tool_calls) || !message.tool_calls.length) return { role: message.role, content: text };
const calls = message.tool_calls.map(call => `<||DSML||invoke id="${call.id}" name="${call.function?.name}">\n${call.function?.arguments || '{}'}\n<||DSML||/invoke>`).join('\n');
return { role: message.role, content: [text, '<||DSML||tool_calls>', calls, '<||DSML||/tool_calls>'].filter(Boolean).join('\n') };
});
return { normalized, toolSchemas };
}
function getChatId() { const match = location.pathname.match(/^\/c\/([^/?#]+)/); if (!match) throw new Error('请打开一个 Halo 对话页面(URL 应为 /c/{chat_id})。'); return match[1]; }
async function getModelItem(modelId) { const models = await haloJson('/api/models'); const list = Array.isArray(models) ? models : models.data || []; return list.find(item => item?.id === modelId || item?.model_id === modelId) || { id: modelId, name: modelId }; }
async function processTask(task) {
await connectSocket();
if (activeHaloTask) throw new Error('Halo 页面正在处理另一条请求。');
const chatId = getChatId();
const chat = await haloJson(`/api/v1/chats/${chatId}`);
const chatState = chat.chat || {};
const model = chatState.models?.[0];
if (!model) throw new Error('当前 Halo 对话未选择模型。');
const messages = normalizeMessages(task.request.messages || []);
const system = messages.filter(message => message.role === 'system').map(message => message.content).filter(Boolean).join('\n\n');
const messageId = uuid();
const modelItem = await getModelItem(model);
activeHaloTask = {
bridgeId: task.id,
chatId,
messageId,
content: '',
usage: null,
finished: false
};
await bridgeRequest(`/bridge/tasks/${task.id}/started`, {
method: 'POST',
body: JSON.stringify({ chat_id: chatId, message_id: messageId, model })
});
const payload = {
// 必须启用流式响应,才能通过 Socket.IO 实时转发回复。
stream: true,
model,
messages,
params: { function_calling: null, system },
files: [],
tool_servers: [],
features: { memory: false, image_generation: false, code_interpreter: false, web_search: false },
variables: {},
model_item: modelItem,
session_id: socketSessionId,
chat_id: chatId,
id: messageId,
background_tasks: { follow_up_generation: true },
stream_options: { include_usage: true }
};
const submitted = await haloJson('/api/chat/completions', {
method: 'POST', body: JSON.stringify(payload)
});
if (!submitted.status) throw new Error(submitted.message || 'Halo 未接受模型请求。');
await connectSocket(); if (activeHaloTask) throw new Error('Halo 页面正在处理另一条请求。');
const chatId = getChatId(); const chat = await haloJson(`/api/v1/chats/${chatId}`); const model = chat.chat?.models?.[0]; if (!model) throw new Error('当前 Halo 对话未选择模型。');
const { normalized: messages } = normalizeMessages(task.request.messages || []); const system = messages.filter(message => message.role === 'system').map(message => message.content).filter(Boolean).join('\n\n'); const messageId = uuid(); const modelItem = await getModelItem(model);
activeHaloTask = { bridgeId: task.id, chatId, messageId, content: '', rawContent: '', toolCalls: [], usage: null, finished: false };
await bridgeRequest(`/bridge/tasks/${task.id}/started`, { method: 'POST', body: JSON.stringify({ chat_id: chatId, message_id: messageId, model }) });
const toolContext = Array.isArray(task.request.tools) && task.request.tools.length ? `\n\n可用工具定义(当需要工具时,必须只输出 DSML tool_calls,等待 tool_result 后继续):\n${JSON.stringify(task.request.tools)}` : '';
const payload = { stream: true, model, messages: messages.map(message => message.role === 'system' ? { ...message, content: `${message.content}${toolContext}` } : message), params: { function_calling: null, system: `${system}${toolContext}` }, files: [], tool_servers: [], features: { memory: false, image_generation: false, code_interpreter: false, web_search: false }, variables: {}, model_item: modelItem, session_id: socketSessionId, chat_id: chatId, id: messageId, background_tasks: { follow_up_generation: true }, stream_options: { include_usage: true } };
pageDebug('halo-http-request', { path: '/api/chat/completions', method: 'POST', payload, tool_choice: task.request.tool_choice, parallel_tool_calls: task.request.parallel_tool_calls });
const submitted = await haloJson('/api/chat/completions', { method: 'POST', body: JSON.stringify(payload) }); if (!submitted.status) throw new Error(submitted.message || 'Halo 未接受模型请求。');
}
async function poll() {
if (running) return;
try {
const { task } = await bridgeRequest('/bridge/tasks/next');
if (!task) return;
running = true;
console.log('[Halo Bridge] 开始代理 Halo API 会话', task.id);
try {
await processTask(task);
} catch (error) {
await failActiveTask(error instanceof Error ? error.message : String(error));
if (!activeHaloTask) {
await bridgeRequest(`/bridge/tasks/${task.id}/failed`, {
method: 'POST', body: JSON.stringify({ message: error instanceof Error ? error.message : String(error) })
}).catch(() => {});
}
}
} catch (error) {
console.error('[Halo Bridge]', error);
} finally {
running = false;
}
}
connectSocket().catch(error => console.warn('[Halo Bridge] Socket.IO 尚未就绪:', error.message));
setInterval(poll, POLL_INTERVAL_MS);
poll();
console.log('[Halo Bridge] 网页会话代理脚本已启动。');
async function poll() { if (running) return; try { const { task } = await bridgeRequest('/bridge/tasks/next'); if (!task) return; running = true; console.log('[Halo Bridge] 开始代理 Halo API 会话', task.id); try { await processTask(task); } catch (error) { await failActiveTask(error.message); await bridgeRequest(`/bridge/tasks/${task.id}/failed`, { method: 'POST', body: JSON.stringify({ message: error.message }) }).catch(() => {}); } } catch (error) { console.error('[Halo Bridge]', error); } finally { running = false; } }
connectSocket().catch(error => console.warn('[Halo Bridge] Socket.IO 尚未就绪:', error.message)); setInterval(poll, POLL_INTERVAL_MS); poll(); console.log('[Halo Bridge] 网页会话代理脚本已启动。');
})();
+74 -71
View File
@@ -6,19 +6,21 @@ const { randomUUID } = require('crypto');
const HOST = '127.0.0.1';
const PORT = Number(process.env.HALO_BRIDGE_PORT || 8787);
const TASK_TIMEOUT_MS = Number(process.env.HALO_BRIDGE_TIMEOUT_MS || 300000);
const MAX_BODY_BYTES = Number(process.env.HALO_BRIDGE_MAX_BODY_BYTES || 1024 * 1024);
const DEBUG = process.env.HALO_BRIDGE_DEBUG === '1';
const tasks = new Map();
let activeTaskId = null;
let lastPagePollAt = 0;
let debugSequence = 0;
function log(message) {
console.log(`[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}] ${message}`);
}
function debug(message, data) {
function debugEvent(taskId, direction, type, data) {
if (!DEBUG) return;
const suffix = data === undefined ? '' : ` | ${JSON.stringify(data)}`;
log(`[DEBUG] ${message}${suffix}`);
const event = { sequence: ++debugSequence, at: new Date().toISOString(), taskId: taskId || null, direction, type, data };
console.log(`[DEBUG] ${JSON.stringify(event, null, 2)}`);
}
function preview(text, maxLength = 120) {
@@ -41,19 +43,30 @@ function sendJson(response, statusCode, data) {
}
function openAiError(response, statusCode, message, type = 'invalid_request_error') {
if (!response.headersSent) sendJson(response, statusCode, { error: { message, type, param: null, code: null } });
const data = { error: { message, type, param: null, code: null } };
if (!response.headersSent) sendJson(response, statusCode, data);
}
function sendSse(response, data) {
if (!response.writableEnded) response.write(`data: ${JSON.stringify(data)}\n\n`);
function sendSse(task, data) {
debugEvent(task.id, 'bridge->openai', 'sse', data);
if (!task.response.writableEnded) task.response.write(`data: ${JSON.stringify(data)}\n\n`);
}
function readJson(request) {
function sendDone(task) {
debugEvent(task.id, 'bridge->openai', 'sse-done', '[DONE]');
if (!task.response.writableEnded) task.response.write('data: [DONE]\n\n');
}
function readJson(request, path) {
return new Promise((resolve, reject) => {
let body = '';
request.on('data', chunk => {
body += chunk;
if (body.length > 1024 * 1024) request.destroy(new Error('请求体过大'));
if (Buffer.byteLength(body) > MAX_BODY_BYTES) {
const error = new Error(`请求体过大,限制为 ${MAX_BODY_BYTES} 字节。`);
debugEvent(null, 'openai->bridge', 'body-too-large', { path, bytes: Buffer.byteLength(body), maxBytes: MAX_BODY_BYTES });
request.destroy(error);
}
});
request.on('end', () => {
try { resolve(body ? JSON.parse(body) : {}); }
@@ -66,11 +79,7 @@ function readJson(request) {
function messageText(content) {
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return '';
return content
.filter(part => part?.type === 'text' && typeof part.text === 'string')
.map(part => part.text)
.join('\n')
.trim();
return content.filter(part => part?.type === 'text' && typeof part.text === 'string').map(part => part.text).join('\n').trim();
}
function latestUserText(messages) {
@@ -81,13 +90,22 @@ function latestUserText(messages) {
return '';
}
function validateToolHistory(messages) {
const calls = new Set();
for (const message of messages) {
if (message?.role === 'assistant' && Array.isArray(message.tool_calls)) {
for (const call of message.tool_calls) if (call?.id) calls.add(call.id);
}
if (message?.role === 'tool') {
if (!message.tool_call_id) return 'role: tool 消息缺少 tool_call_id。';
if (!calls.has(message.tool_call_id)) return `tool_call_id ${message.tool_call_id} 未匹配前序 assistant.tool_calls。`;
}
}
return '';
}
function taskView(task) {
return {
id: task.id,
model: task.model,
request: task.request,
createdAt: task.createdAt
};
return { id: task.id, model: task.model, request: task.request, createdAt: task.createdAt };
}
function sseChunk(task, delta, finishReason = null, usage) {
@@ -107,23 +125,31 @@ function sendCompletion(task, result) {
clearTimeout(task.timeout);
if (activeTaskId === task.id) activeTaskId = null;
const content = typeof result.content === 'string' ? result.content : task.content;
const toolCalls = Array.isArray(result.tool_calls) ? result.tool_calls : [];
const finishReason = toolCalls.length ? 'tool_calls' : 'stop';
debugEvent(task.id, 'page->bridge', 'task-complete', result);
if (task.wantsStream) {
sendSse(task.response, sseChunk(task, {}, 'stop', result.usage));
if (!task.response.writableEnded) {
task.response.write('data: [DONE]\n\n');
task.response.end();
if (toolCalls.length) {
sendSse(task, sseChunk(task, {
tool_calls: toolCalls.map((call, index) => ({ index, id: call.id, type: 'function', function: call.function }))
}));
}
sendSse(task, sseChunk(task, {}, finishReason, result.usage));
sendDone(task);
task.response.end();
} else if (!task.response.writableEnded) {
sendJson(task.response, 200, {
const response = {
id: `chatcmpl-${task.id}`,
object: 'chat.completion',
created: task.created,
model: task.model,
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
choices: [{ index: 0, message: { role: 'assistant', content: content || null, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) }, finish_reason: finishReason }],
usage: result.usage || undefined,
halo: { task_id: task.id, chat_id: task.chatId, message_id: task.messageId }
});
};
debugEvent(task.id, 'bridge->openai', 'json-completion', response);
sendJson(task.response, 200, response);
}
log(`任务完成 ${task.id} | 回复: ${preview(content)}`);
setTimeout(() => tasks.delete(task.id), 10 * 60 * 1000).unref();
@@ -134,10 +160,11 @@ function failTask(task, message) {
task.finished = true;
clearTimeout(task.timeout);
if (activeTaskId === task.id) activeTaskId = null;
debugEvent(task.id, 'bridge', 'task-failed', { message, accumulatedContent: task.content });
log(`任务失败 ${task.id} | ${message}`);
if (task.wantsStream && task.response.headersSent && !task.response.writableEnded) {
sendSse(task.response, { error: { message, type: 'server_error', param: null, code: null } });
task.response.write('data: [DONE]\n\n');
sendSse(task, { error: { message, type: 'server_error', param: null, code: null } });
sendDone(task);
task.response.end();
} else if (!task.response.writableEnded) {
openAiError(task.response, 504, message, 'server_error');
@@ -146,78 +173,55 @@ function failTask(task, message) {
}
async function handleChatCompletions(request, response) {
const payload = await readJson(request);
const payload = await readJson(request, '/v1/chat/completions');
debugEvent(null, 'openai->bridge', 'chat-completions-request', { headers: request.headers, bytes: Buffer.byteLength(JSON.stringify(payload)), payload });
if (!Array.isArray(payload.messages)) return openAiError(response, 400, 'messages 必须是数组。');
const toolHistoryError = validateToolHistory(payload.messages);
if (toolHistoryError) return openAiError(response, 400, toolHistoryError);
const prompt = latestUserText(payload.messages);
if (!prompt) return openAiError(response, 400, 'messages 中缺少非空 user 文本消息。');
if (!Array.isArray(payload.messages) || payload.messages.some(message => message?.role === 'tool' || message?.tool_calls)) {
return openAiError(response, 400, '当前网页会话代理尚不支持 OpenAI tool_calls / tool 消息。');
}
if (activeTaskId) return openAiError(response, 429, 'Halo 页面正在处理另一条请求,请等待当前任务完成。', 'rate_limit_error');
const id = randomUUID();
const task = {
id,
prompt,
request: payload,
model: payload.model || 'halo-webui',
wantsStream: payload.stream === true,
response,
createdAt: Date.now(),
created: Math.floor(Date.now() / 1000),
claimed: false,
finished: false,
content: ''
};
const task = { id, prompt, request: payload, model: payload.model || 'halo-webui', wantsStream: payload.stream === true, response, createdAt: Date.now(), created: Math.floor(Date.now() / 1000), claimed: false, finished: false, content: '' };
tasks.set(id, task);
activeTaskId = id;
task.timeout = setTimeout(() => failTask(task, '等待 Halo 网页会话回复超时。请确认页面已打开、已登录且猴脚本正在运行。'), TASK_TIMEOUT_MS);
debugEvent(id, 'bridge', 'task-created', { roles: payload.messages.map(message => message.role), tools: payload.tools, tool_choice: payload.tool_choice, parallel_tool_calls: payload.parallel_tool_calls });
log(`收到请求 ${id} | 模型: ${task.model} | 内容: ${preview(prompt)}`);
if (task.wantsStream) {
response.writeHead(200, {
...headers('text/event-stream; charset=utf-8'),
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
});
sendSse(response, sseChunk(task, { role: 'assistant' }));
response.writeHead(200, { ...headers('text/event-stream; charset=utf-8'), 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' });
sendSse(task, sseChunk(task, { role: 'assistant' }));
}
}
async function route(request, response) {
const url = new URL(request.url, `http://${request.headers.host}`);
if (request.method === 'OPTIONS') return sendJson(response, 204, {});
if (request.method === 'GET' && url.pathname === '/health') {
return sendJson(response, 200, {
ok: true,
activeTaskId,
taskCount: tasks.size,
pageConnected: Date.now() - lastPagePollAt < 5000,
lastPagePollAt: lastPagePollAt || null,
activeTask: activeTaskId ? taskView(tasks.get(activeTaskId)) : null
});
}
if (request.method === 'GET' && url.pathname === '/health') return sendJson(response, 200, { ok: true, debug: DEBUG, activeTaskId, taskCount: tasks.size, pageConnected: Date.now() - lastPagePollAt < 5000, lastPagePollAt: lastPagePollAt || null, activeTask: activeTaskId ? taskView(tasks.get(activeTaskId)) : null });
if (request.method === 'POST' && url.pathname === '/v1/chat/completions') return handleChatCompletions(request, response);
if (request.method === 'GET' && url.pathname === '/bridge/tasks/next') {
lastPagePollAt = Date.now();
const task = activeTaskId ? tasks.get(activeTaskId) : null;
if (!task || task.claimed || task.finished) return sendJson(response, 200, { task: null });
task.claimed = true;
debugEvent(task.id, 'bridge->page', 'task-issued', taskView(task));
log(`网页已接收任务 ${task.id}`);
return sendJson(response, 200, { task: taskView(task) });
}
if (request.method === 'POST' && url.pathname === '/bridge/debug') {
const event = await readJson(request);
debug(`页面 ${event.event || '事件'}`, event.data);
const event = await readJson(request, '/bridge/debug');
debugEvent(event.taskId, 'page->bridge', event.event || 'page-event', event);
return sendJson(response, 200, { ok: true });
}
const match = url.pathname.match(/^\/bridge\/tasks\/([^/]+)\/(started|delta|complete|failed)$/);
if (request.method === 'POST' && match) {
const [, taskId, action] = match;
const task = tasks.get(taskId);
if (!task || task.finished) return openAiError(response, 404, '任务不存在或已完成。');
const result = await readJson(request);
const result = await readJson(request, url.pathname);
debugEvent(taskId, 'page->bridge', `task-${action}`, result);
if (action === 'started') {
task.chatId = result.chat_id;
task.messageId = result.message_id;
@@ -227,13 +231,11 @@ async function route(request, response) {
const content = typeof result.content === 'string' ? result.content : '';
if (content) {
task.content += content;
if (task.wantsStream) sendSse(task.response, sseChunk(task, { content }));
if (task.wantsStream) sendSse(task, sseChunk(task, { content }));
}
} else if (action === 'complete') {
sendCompletion(task, { ...result, content: result.content || task.content });
} else {
failTask(task, result.message || 'Halo 网页会话代理失败。');
}
} else failTask(task, result.message || 'Halo 网页会话代理失败。');
return sendJson(response, 200, { ok: true });
}
return openAiError(response, 404, '接口不存在。');
@@ -241,12 +243,13 @@ async function route(request, response) {
http.createServer((request, response) => {
route(request, response).catch(error => {
debugEvent(null, 'bridge', 'route-error', { message: error.message, stack: error.stack });
console.error('[Halo Bridge]', error);
if (!response.headersSent) openAiError(response, 400, error.message);
else response.end();
});
}).listen(PORT, HOST, () => {
log(`服务已启动:http://${HOST}:${PORT}`);
log(`日志模式:${DEBUG ? 'DEBUG(显示请求与增量详情)' : 'Release(精简)'}`);
log(`日志模式:${DEBUG ? 'DEBUG(完整原样输出,含认证字段)' : 'Release(精简)'}`);
log('等待 Halo 页面猴脚本连接;请保持页面已登录并打开。');
});
@@ -24,8 +24,9 @@ echo Halo OpenAI Bridge DEBUG is starting
echo URL: http://127.0.0.1:8787
echo Health: http://127.0.0.1:8787/health
echo ================================================
echo DEBUG logs include request metadata, Halo submission,
echo Socket.IO events, and forwarded content deltas.
echo DEBUG prints complete raw OpenAI, Halo HTTP, Socket.IO,
echo and SSE data. It includes tokens and other credentials.
echo Do not share this console output or screenshots.
echo Press Ctrl+C to stop.
echo.
set "HALO_BRIDGE_DEBUG=1"