支持工具链
This commit is contained in:
+51
-30
@@ -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 发送 · Shift+Enter 换行 · 支持图片多模态</p>
|
||||
<p class="hint">Enter 发送 · Shift+Enter 换行 · 支持图片多模态 · 工具路由会自动判断是否需要联网搜索</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();
|
||||
|
||||
Reference in New Issue
Block a user