支持工具链

This commit is contained in:
2026-06-10 12:07:07 +08:00
parent 1e793ce814
commit fe2477dd97
9 changed files with 1632 additions and 545 deletions
+51 -30
View File
@@ -158,7 +158,7 @@
align-items: center;
gap: 8px;
}
#btnClear, #btnPreset, #btnSearch {
#btnClear, #btnPreset {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
@@ -169,13 +169,7 @@
transition: all .15s;
}
#btnClear:hover { border-color: var(--danger); color: var(--danger); }
#btnPreset:hover, #btnSearch:hover { border-color: var(--accent); color: var(--accent); }
#btnSearch.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
#btnPreset:hover { border-color: var(--accent); color: var(--accent); }
/* ── 消息区 ── */
#messages {
flex: 1;
@@ -292,6 +286,13 @@
overflow-x: auto;
}
.answer-text { display: inline; }
.token-stats {
margin-top: 8px;
color: var(--text-dim);
font-size: 0.76rem;
white-space: normal;
}
.token-stats:empty { display: none; }
/* 错误消息 */
.error-msg {
@@ -513,7 +514,6 @@
<option value="{{ .OpenAIName }}">{{ .Model }}</option>
</select>
<select id="searchSelect" class="model-badge profile-select" title="切换搜索源"></select>
<button id="btnSearch" title="开启后,本轮提问会先联网搜索">联网搜索:关</button>
<button id="btnPreset" title="设置预先提示词">预设</button>
<button id="btnClear" title="开始新对话">新对话</button>
</div>
@@ -554,7 +554,7 @@
</svg>
</button>
</div>
<p class="hint">Enter 发送 &nbsp;·&nbsp; Shift+Enter 换行 &nbsp;·&nbsp; 支持图片多模态</p>
<p class="hint">Enter 发送 &nbsp;·&nbsp; Shift+Enter 换行 &nbsp;·&nbsp; 支持图片多模态 &nbsp;·&nbsp; 工具路由会自动判断是否需要联网搜索</p>
</footer>
</div>
@@ -576,7 +576,6 @@
let history = []; // {role, content, image_url?}
let currentConvId = null;
let pending = false;
let webSearchEnabled = false;
let openAIProfiles = [];
let activeOpenAIName = '{{ .OpenAIName }}';
let searchProfiles = [];
@@ -591,7 +590,6 @@ const inputBox = document.getElementById('inputBox');
const btnSend = document.getElementById('btnSend');
const btnClear = document.getElementById('btnClear');
const btnPreset = document.getElementById('btnPreset');
const btnSearch = document.getElementById('btnSearch');
const modelSelect = document.getElementById('modelSelect');
const searchSelect = document.getElementById('searchSelect');
const btnNewChat = document.getElementById('btnNewChat');
@@ -666,16 +664,10 @@ function setInputDisabled(disabled) {
btnSend.disabled = disabled;
inputBox.disabled = disabled;
fileInput.disabled = disabled;
btnSearch.disabled = disabled;
modelSelect.disabled = disabled || openAIProfiles.length <= 1;
searchSelect.disabled = disabled || searchProfiles.length <= 1;
}
function updateSearchButton() {
btnSearch.classList.toggle('active', webSearchEnabled);
btnSearch.textContent = webSearchEnabled ? '联网搜索:开' : '联网搜索:关';
}
async function loadOpenAIProfiles() {
const res = await fetch('/api/openai');
if (!res.ok) {
@@ -869,13 +861,42 @@ function addAIBubble() {
const txt = document.createElement('span');
txt.className = 'answer-text';
const stats = document.createElement('div');
stats.className = 'token-stats';
bub.appendChild(trace);
bub.appendChild(txt);
bub.appendChild(stats);
row.appendChild(av);
row.appendChild(bub);
msgBox.appendChild(row);
scrollToBottom();
return { bub, txt, trace };
return { bub, txt, trace, stats };
}
function formatTokenStats(stats) {
if (!stats) return '';
const avgSpeed = typeof stats.completion_tokens_per_sec === 'number'
? stats.completion_tokens_per_sec.toFixed(1)
: '0.0';
const peakSpeed = typeof stats.peak_completion_tokens_per_sec === 'number'
? stats.peak_completion_tokens_per_sec.toFixed(1)
: '0.0';
const parts = [
`平均 ${avgSpeed} tokens/sec`,
`最高 ${peakSpeed} tokens/sec`,
`总 token ${stats.total_tokens || 0}`,
`输入 ${stats.prompt_tokens || 0}`,
`输出 ${stats.completion_tokens || 0}`,
];
const toolTokens = (stats.tool_prompt_tokens || 0) + (stats.tool_completion_tokens || 0);
if (toolTokens) parts.push(`工具 ${toolTokens}`);
if (stats.estimated) parts.push('本地估算');
return parts.join(' ');
}
function updateTokenStats(aiBubble, stats) {
if (!aiBubble.stats) return;
aiBubble.stats.textContent = formatTokenStats(stats);
}
function appendTrace(aiBubble, frame) {
@@ -893,6 +914,7 @@ function appendTrace(aiBubble, frame) {
if (typeof data.rows === 'number') stats.push(`行数: ${data.rows}`);
if (typeof data.columns === 'number') stats.push(`列数: ${data.columns}`);
if (typeof data.count === 'number') stats.push(`结果数: ${data.count}`);
if (Array.isArray(data.tools) && data.tools.length) stats.push(`工具: ${data.tools.join(', ')}`);
if (data.truncated) stats.push(`已截断,最多 ${data.max_rows || ''}`);
if (data.reason) stats.push(`原因: ${data.reason}`);
if (data.error) stats.push(`错误: ${data.error}`);
@@ -909,7 +931,7 @@ function appendTrace(aiBubble, frame) {
scrollToBottom();
}
async function streamChat(messages, aiBubble, webSearch = false) {
async function streamChat(messages, aiBubble) {
const txtEl = aiBubble.txt;
let full = '';
@@ -919,8 +941,7 @@ async function streamChat(messages, aiBubble, webSearch = false) {
body: JSON.stringify({
conversation_id: currentConvId,
messages,
web_search: webSearch,
openai_name: activeOpenAIName,
openai_name: activeOpenAIName,
}),
});
@@ -965,8 +986,14 @@ async function streamChat(messages, aiBubble, webSearch = false) {
if (delta) {
full += delta;
txtEl.innerHTML = renderMarkdown(full);
scrollToBottom();
}
updateTokenStats(aiBubble, parsed.stats);
scrollToBottom();
continue;
}
if (parsed.type === 'stats') {
updateTokenStats(aiBubble, parsed.stats);
scrollToBottom();
continue;
}
if (parsed.type === 'trace') {
@@ -1070,7 +1097,7 @@ async function sendMessage() {
clearImage();
aiBubble = addAIBubble();
const full = await streamChat(history, aiBubble, webSearchEnabled);
const full = await streamChat(history, aiBubble);
history.push({ role: 'assistant', content: full });
scrollToBottom();
await loadConversationList();
@@ -1145,11 +1172,6 @@ inputBox.addEventListener('keydown', e => {
});
btnSend.addEventListener('click', sendMessage);
btnSearch.addEventListener('click', () => {
if (pending) return;
webSearchEnabled = !webSearchEnabled;
updateSearchButton();
});
modelSelect.addEventListener('change', async () => {
if (pending) {
modelSelect.value = activeOpenAIName;
@@ -1218,7 +1240,6 @@ presetModal.addEventListener('click', e => {
});
// 自动聚焦 & 初始化
updateSearchButton();
loadOpenAIProfiles().catch(e => alert(e.message));
loadSearchProfiles().catch(e => alert(e.message));
loadConversationList();