增加调试模式的全量日志功能。
修复工具调用2
This commit is contained in:
@@ -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] 网页会话代理脚本已启动。');
|
||||
})();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user