Files
2026-06-11 18:04:47 +08:00

1298 lines
39 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ .Title }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f6f8fc;
--surface: #ffffff;
--surface2: #eef2f8;
--border: #d8deea;
--accent: #5b5ce2;
--accent-h: #4b4dcc;
--text: #172033;
--text-dim: #667085;
--user-bg: #eef2ff;
--ai-bg: #ffffff;
--danger: #dc2626;
--on-accent: #ffffff;
--accent-soft: rgba(91,92,226,.10);
--accent-border: rgba(91,92,226,.24);
--code-bg: #f3f5fa;
--overlay: rgba(15,23,42,.25);
--shadow: 0 18px 60px rgba(15,23,42,.16);
--scrollbar-thumb: #c8d2e3;
--radius: 12px;
--font: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
height: 100dvh;
display: flex;
flex-direction: row;
overflow: hidden;
}
/* ── 侧边栏 ── */
#sidebar {
width: 260px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.sidebar-header {
padding: 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#btnNewChat {
width: 100%;
background: var(--accent);
border: none;
color: var(--on-accent);
border-radius: 9px;
padding: 10px 14px;
font-size: 0.88rem;
cursor: pointer;
transition: background .15s;
}
#btnNewChat:hover { background: var(--accent-h); }
#convList {
flex: 1;
overflow-y: auto;
padding: 8px;
}
#convList::-webkit-scrollbar { width: 5px; }
#convList::-webkit-scrollbar-track { background: transparent; }
#convList::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
.conv-empty {
padding: 14px 10px;
color: var(--text-dim);
font-size: 0.82rem;
text-align: center;
}
.conv-item {
position: relative;
padding: 10px 34px 10px 12px;
border-radius: 8px;
color: var(--text-dim);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background .15s, color .15s;
}
.conv-item:hover { background: var(--surface2); color: var(--text); }
.conv-item.active { background: var(--surface2); color: var(--text); }
.conv-delete {
display: none;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--danger);
cursor: pointer;
font-size: 1rem;
padding: 2px 5px;
}
.conv-item:hover .conv-delete { display: block; }
#main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── 顶栏 ── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
header .brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.05rem;
font-weight: 600;
}
header .brand svg { color: var(--accent); }
header .model-badge {
font-size: 0.72rem;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-dim);
padding: 3px 10px;
border-radius: 20px;
}
header .profile-select {
max-width: 260px;
cursor: pointer;
outline: none;
}
header .profile-select:disabled {
opacity: .65;
cursor: not-allowed;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
#btnClear, #btnPreset {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 8px;
padding: 5px 14px;
font-size: 0.82rem;
cursor: pointer;
transition: all .15s;
}
#btnClear:hover { border-color: var(--danger); color: var(--danger); }
#btnPreset:hover { border-color: var(--accent); color: var(--accent); }
/* ── 消息区 ── */
#messages {
flex: 1;
overflow-y: auto;
padding: 24px 0;
scroll-behavior: smooth;
}
#messages::-webkit-scrollbar { width: 5px; }
#messages::-webkit-scrollbar-track { background: transparent; }
#messages::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
.msg-row {
display: flex;
padding: 6px 20px;
gap: 12px;
animation: fadeUp .2s ease;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-row.user { flex-direction: row-reverse; }
.avatar {
width: 34px; height: 34px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.85rem; font-weight: 700; flex-shrink: 0;
}
.msg-row.user .avatar { background: var(--accent); }
.msg-row.assistant .avatar { background: var(--surface2); border: 1px solid var(--border); }
.bubble {
max-width: min(72%, 680px);
background: var(--ai-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
font-size: 0.92rem;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.msg-row.user .bubble {
background: var(--user-bg);
border-color: var(--accent-border);
}
/* 气泡内图片预览 */
.bubble img {
max-width: 100%;
max-height: 260px;
border-radius: 8px;
display: block;
margin-bottom: 8px;
}
/* 代码块 */
.bubble pre {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
overflow-x: auto;
font-size: 0.82rem;
margin: 8px 0;
}
.bubble code { font-family: 'Fira Code', Consolas, monospace; }
.bubble :not(pre) > code {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 4px;
font-size: .88em;
}
/* 打字光标 */
.typing-cursor::after {
content: '▋';
animation: blink .7s step-end infinite;
color: var(--accent);
}
@keyframes blink { 50% { opacity: 0; } }
/* 执行过程 */
.trace-panel {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 8px;
color: var(--text-dim);
font-size: 0.78rem;
line-height: 1.45;
white-space: normal;
}
.trace-panel:empty { display: none; }
.reasoning-panel {
display: none;
margin-bottom: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface2);
color: var(--text-dim);
font-size: 0.78rem;
white-space: normal;
overflow: hidden;
}
.reasoning-panel.show { display: block; }
.reasoning-title {
padding: 6px 9px;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
.reasoning-content {
padding: 7px 9px;
white-space: pre-wrap;
max-height: 220px;
overflow-y: auto;
font-family: Consolas, 'Fira Code', monospace;
}
.trace-item {
border-left: 2px solid var(--accent-border);
padding-left: 8px;
}
.trace-item.running { color: var(--text-dim); }
.trace-item.success { opacity: .86; }
.trace-item.error {
color: var(--danger);
border-left-color: var(--danger);
}
.trace-detail {
margin-top: 4px;
font-family: Consolas, 'Fira Code', monospace;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
white-space: pre-wrap;
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 {
color: var(--danger);
font-size: 0.82rem;
padding: 4px 0;
}
/* ── 输入区 ── */
footer {
padding: 14px 20px 18px;
background: var(--surface);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
/* 图片预览条 */
#imagePreviewBar {
display: none;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 12px;
background: var(--surface2);
border-radius: 8px;
border: 1px solid var(--border);
}
#imagePreviewBar img {
height: 52px;
border-radius: 6px;
}
#imagePreviewBar .img-name {
font-size: 0.8rem;
color: var(--text-dim);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#btnRemoveImage {
background: none; border: none;
color: var(--text-dim); cursor: pointer;
font-size: 1.1rem; padding: 0 4px;
transition: color .15s;
}
#btnRemoveImage:hover { color: var(--danger); }
/* 输入框行 */
.input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
#inputBox {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 0.93rem;
font-family: var(--font);
padding: 11px 14px;
resize: none;
min-height: 44px;
max-height: 180px;
overflow-y: auto;
line-height: 1.5;
outline: none;
transition: border-color .15s;
}
#inputBox:focus { border-color: var(--accent); }
#inputBox::placeholder { color: var(--text-dim); }
.icon-btn {
width: 42px; height: 42px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text-dim);
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all .15s; flex-shrink: 0;
}
.icon-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
#btnSend {
width: 42px; height: 42px;
border-radius: 10px;
border: none;
background: var(--accent);
color: var(--on-accent);
display: flex; align-items: center; justify-content: center;
cursor: pointer; flex-shrink: 0;
transition: background .15s, transform .1s;
}
#btnSend:hover { background: var(--accent-h); }
#btnSend:active { transform: scale(.93); }
#btnSend:disabled { background: var(--surface2); color: var(--text-dim); cursor: not-allowed; }
#fileInput { display: none; }
.hint {
text-align: center;
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 8px;
}
/* 预设提示词弹窗 */
#presetModal {
display: none;
position: fixed;
inset: 0;
z-index: 10;
align-items: center;
justify-content: center;
background: var(--overlay);
padding: 20px;
}
#presetModal.show { display: flex; }
.preset-dialog {
width: min(560px, 100%);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
}
.preset-dialog h2 {
font-size: 1rem;
margin-bottom: 8px;
}
.preset-dialog p {
color: var(--text-dim);
font-size: 0.82rem;
line-height: 1.6;
margin-bottom: 12px;
}
#presetInput {
width: 100%;
min-height: 180px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font-family: var(--font);
font-size: 0.9rem;
line-height: 1.6;
padding: 10px 12px;
resize: vertical;
outline: none;
}
#presetInput:focus { border-color: var(--accent); }
.preset-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.preset-actions button {
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text-dim);
border-radius: 8px;
padding: 7px 14px;
cursor: pointer;
}
#btnSavePreset {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
#btnClearPreset:hover { border-color: var(--danger); color: var(--danger); }
/* 欢迎页 */
#welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
height: 100%;
color: var(--text-dim);
pointer-events: none;
user-select: none;
}
#welcome svg { opacity: .35; }
#welcome p { font-size: 0.92rem; }
@media (max-width: 720px) {
#sidebar { width: 210px; }
.bubble { max-width: min(84%, 680px); }
header { gap: 10px; }
header .model-badge { display: none; }
}
</style>
</head>
<body>
<aside id="sidebar">
<div class="sidebar-header">
<button id="btnNewChat">+ 新对话</button>
</div>
<div id="convList">
<div class="conv-empty">暂无历史对话</div>
</div>
</aside>
<div id="main">
<header>
<div class="brand">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z"/>
<path d="M8 12h8M12 8v8"/>
</svg>
{{ .Title }}
</div>
<div class="header-actions">
<select id="modelSelect" class="model-badge profile-select" title="切换 OpenAI 配置">
<option value="{{ .OpenAIName }}">{{ .Model }}</option>
</select>
<select id="searchSelect" class="model-badge profile-select" title="切换搜索源"></select>
<button id="btnPreset" title="设置预先提示词">预设</button>
<button id="btnClear" title="开始新对话">新对话</button>
</div>
</header>
<div id="messages">
<div id="welcome">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
<p>发送消息开始对话,支持上传图片进行多模态对话</p>
</div>
</div>
<footer>
<div id="imagePreviewBar">
<img id="previewImg" src="" alt="预览" />
<span class="img-name" id="imgName"></span>
<button id="btnRemoveImage" title="移除图片"></button>
</div>
<div class="input-row">
<label class="icon-btn" for="fileInput" title="上传图片">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
</label>
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/webp,image/gif" />
<textarea id="inputBox" placeholder="输入消息,Enter 发送,Shift+Enter 换行…" rows="1"></textarea>
<button id="btnSend" title="发送">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<p class="hint">Enter 发送 &nbsp;·&nbsp; Shift+Enter 换行 &nbsp;·&nbsp; 支持图片多模态 &nbsp;·&nbsp; 工具路由会自动判断是否需要联网搜索</p>
</footer>
</div>
<div id="presetModal">
<div class="preset-dialog">
<h2>预先提示词</h2>
<p>保存后,每个新对话会先发送该提示词并生成开场白,开场白完成后才能开始聊天。留空表示关闭。</p>
<textarea id="presetInput" placeholder="例如:你是一个中文助理,回答简洁,先主动问候用户并询问需要什么帮助..."></textarea>
<div class="preset-actions">
<button id="btnClearPreset">清空</button>
<button id="btnClosePreset">取消</button>
<button id="btnSavePreset">保存</button>
</div>
</div>
</div>
<script>
// ── 状态 ──────────────────────────────────────────────────
let history = []; // {role, content, image_url?}
let currentConvId = null;
let pending = false;
let openAIProfiles = [];
let activeOpenAIName = '{{ .OpenAIName }}';
let searchProfiles = [];
let activeSearchName = '';
let imageB64 = ''; // 当前待发送图片的 data URI
let imageName = '';
// ── DOM ──────────────────────────────────────────────────
const msgBox = document.getElementById('messages');
const welcome = document.getElementById('welcome');
const inputBox = document.getElementById('inputBox');
const btnSend = document.getElementById('btnSend');
const btnClear = document.getElementById('btnClear');
const btnPreset = document.getElementById('btnPreset');
const modelSelect = document.getElementById('modelSelect');
const searchSelect = document.getElementById('searchSelect');
const btnNewChat = document.getElementById('btnNewChat');
const convList = document.getElementById('convList');
const presetModal = document.getElementById('presetModal');
const presetInput = document.getElementById('presetInput');
const btnSavePreset = document.getElementById('btnSavePreset');
const btnClosePreset = document.getElementById('btnClosePreset');
const btnClearPreset = document.getElementById('btnClearPreset');
const fileInput = document.getElementById('fileInput');
const previewBar = document.getElementById('imagePreviewBar');
const previewImg = document.getElementById('previewImg');
const imgNameEl = document.getElementById('imgName');
const btnRemove = document.getElementById('btnRemoveImage');
// ── 工具函数 ──────────────────────────────────────────────
function escHtml(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderMarkdown(text) {
text = escHtml(text || '');
text = text.replace(/```([\s\S]*?)```/g, (_, code) =>
`<pre><code>${code.trim()}</code></pre>`);
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
text = text.replace(/\n/g, '<br>');
return text;
}
function scrollToBottom() {
msgBox.scrollTop = msgBox.scrollHeight;
}
function hideWelcome() {
if (welcome) welcome.style.display = 'none';
}
function resetMessages() {
msgBox.innerHTML = '';
msgBox.appendChild(welcome);
welcome.style.display = '';
}
function updateActiveConversation() {
document.querySelectorAll('.conv-item').forEach(item => {
item.classList.toggle('active', item.dataset.id === currentConvId);
});
}
function getPresetPrompt() {
return (localStorage.getItem('presetPrompt') || '').trim();
}
function savePresetPrompt(value) {
localStorage.setItem('presetPrompt', value || '');
}
function openPresetModal() {
presetInput.value = localStorage.getItem('presetPrompt') || '';
presetModal.classList.add('show');
presetInput.focus();
}
function closePresetModal() {
presetModal.classList.remove('show');
inputBox.focus();
}
function setInputDisabled(disabled) {
btnSend.disabled = disabled;
inputBox.disabled = disabled;
fileInput.disabled = disabled;
modelSelect.disabled = disabled || openAIProfiles.length <= 1;
searchSelect.disabled = disabled || searchProfiles.length <= 1;
}
async function loadOpenAIProfiles() {
const res = await fetch('/api/openai');
if (!res.ok) {
const err = await res.json().catch(() => ({ error: '加载模型配置失败' }));
throw new Error(err.error || '加载模型配置失败');
}
const data = await res.json();
openAIProfiles = Array.isArray(data.profiles) ? data.profiles : [];
activeOpenAIName = data.active || activeOpenAIName;
modelSelect.innerHTML = '';
for (const profile of openAIProfiles) {
const opt = document.createElement('option');
opt.value = profile.name;
opt.textContent = `${profile.name} · ${profile.model}`;
opt.selected = profile.name === activeOpenAIName;
modelSelect.appendChild(opt);
}
modelSelect.disabled = pending || openAIProfiles.length <= 1;
}
async function loadSearchProfiles() {
const res = await fetch('/api/search');
if (!res.ok) {
const err = await res.json().catch(() => ({ error: '加载搜索配置失败' }));
throw new Error(err.error || '加载搜索配置失败');
}
const data = await res.json();
searchProfiles = Array.isArray(data.profiles) ? data.profiles : [];
activeSearchName = data.active || '';
searchSelect.innerHTML = '';
for (const profile of searchProfiles) {
const opt = document.createElement('option');
opt.value = profile.name;
opt.textContent = `${profile.name} · ${profile.provider}`;
opt.selected = profile.name === activeSearchName;
searchSelect.appendChild(opt);
}
searchSelect.disabled = pending || searchProfiles.length <= 1;
}
// ── 对话列表 ──────────────────────────────────────────────
async function loadConversationList() {
try {
const res = await fetch('/api/conversations');
if (!res.ok) throw new Error('加载对话列表失败');
const convs = await res.json();
convList.innerHTML = '';
if (!convs.length) {
convList.innerHTML = '<div class="conv-empty">暂无历史对话</div>';
return;
}
for (const conv of convs) {
const item = document.createElement('div');
item.className = 'conv-item';
item.dataset.id = conv.id;
item.title = conv.title || '新对话';
item.textContent = conv.title || '新对话';
item.addEventListener('click', () => switchConversation(conv.id));
const del = document.createElement('button');
del.className = 'conv-delete';
del.textContent = '×';
del.title = '删除对话';
del.addEventListener('click', e => {
e.stopPropagation();
deleteConversation(conv.id);
});
item.appendChild(del);
convList.appendChild(item);
}
updateActiveConversation();
} catch (e) {
convList.innerHTML = `<div class="conv-empty">${escHtml(e.message)}</div>`;
}
}
async function createConversation() {
const res = await fetch('/api/conversations', { method: 'POST' });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: '创建对话失败' }));
throw new Error(err.error || '创建对话失败');
}
return res.json();
}
async function switchConversation(id) {
if (pending) return;
const res = await fetch(`/api/conversations/${encodeURIComponent(id)}`);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: '加载对话失败' }));
alert(err.error || '加载对话失败');
return;
}
const conv = await res.json();
currentConvId = conv.id;
history = Array.isArray(conv.messages) ? conv.messages : [];
renderHistory();
updateActiveConversation();
inputBox.focus();
}
function resetNewConversation({ preserveInput = false } = {}) {
const keepText = preserveInput ? inputBox.value : '';
currentConvId = null;
history = [];
resetMessages();
clearImage();
updateActiveConversation();
inputBox.value = keepText;
autoResize();
inputBox.focus();
}
async function newConversation() {
if (pending) return;
if (getPresetPrompt()) {
await startPresetConversation();
return;
}
resetNewConversation();
}
async function deleteConversation(id) {
if (!confirm('确定删除这个对话吗?')) return;
const res = await fetch(`/api/conversations/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: '删除对话失败' }));
alert(err.error || '删除对话失败');
return;
}
if (id === currentConvId) resetNewConversation();
await loadConversationList();
}
function renderHistory() {
resetMessages();
for (const msg of history) {
if (msg.hidden) continue;
addBubble(msg.role, msg.content || '', msg.image_url || msg.imageURL || '');
}
scrollToBottom();
}
// ── 添加消息气泡 ──────────────────────────────────────────
function addBubble(role, content, imageURL) {
hideWelcome();
const row = document.createElement('div');
row.className = `msg-row ${role}`;
const av = document.createElement('div');
av.className = 'avatar';
av.textContent = role === 'user' ? 'U' : 'AI';
const bub = document.createElement('div');
bub.className = 'bubble';
if (imageURL) {
const img = document.createElement('img');
img.src = imageURL;
bub.appendChild(img);
}
const txt = document.createElement('span');
txt.innerHTML = renderMarkdown(content);
bub.appendChild(txt);
row.appendChild(av);
row.appendChild(bub);
msgBox.appendChild(row);
scrollToBottom();
return { bub, txt };
}
// ── 添加 AI 占位气泡(流式用)──────────────────────────────
function addAIBubble() {
hideWelcome();
const row = document.createElement('div');
row.className = 'msg-row assistant';
const av = document.createElement('div');
av.className = 'avatar';
av.textContent = 'AI';
const bub = document.createElement('div');
bub.className = 'bubble typing-cursor';
const trace = document.createElement('div');
trace.className = 'trace-panel';
const reasoning = document.createElement('div');
reasoning.className = 'reasoning-panel';
reasoning.innerHTML = '<div class="reasoning-title">思考过程(模型返回)</div><div class="reasoning-content"></div>';
const txt = document.createElement('span');
txt.className = 'answer-text';
const stats = document.createElement('div');
stats.className = 'token-stats';
bub.appendChild(trace);
bub.appendChild(reasoning);
bub.appendChild(txt);
bub.appendChild(stats);
row.appendChild(av);
row.appendChild(bub);
msgBox.appendChild(row);
scrollToBottom();
return { bub, txt, trace, reasoning, 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) {
if (!aiBubble.trace) return;
const item = document.createElement('div');
item.className = `trace-item ${frame.status || ''}`;
const prefix = [frame.tool, frame.stage].filter(Boolean).join('/');
const label = frame.message || [frame.tool, frame.stage, frame.status].filter(Boolean).join(' ');
item.textContent = prefix ? `${prefix}${label}` : label;
const data = frame.data || {};
const details = [];
if (data.arguments) details.push(`参数:\n${data.arguments}`);
if (data.sql) details.push(`SQL:\n${data.sql}`);
if (data.result_preview) details.push(`结果预览:\n${data.result_preview}`);
const stats = [];
if (typeof data.iteration === 'number') stats.push(`轮次: ${data.iteration}${data.max_iterations ? '/' + data.max_iterations : ''}`);
if (data.tool_call_id) stats.push(`调用 ID: ${data.tool_call_id}`);
if (data.database) stats.push(`数据库: ${data.database}`);
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 (typeof data.duration_ms === 'number') stats.push(`耗时: ${data.duration_ms}ms`);
if (data.truncated) stats.push(`已截断,最多 ${data.max_rows || ''} 行`);
if (data.reason) stats.push(`原因: ${data.reason}`);
if (data.error) stats.push(`错误: ${data.error}`);
if (stats.length) details.push(stats.join(' '));
if (details.length) {
const detail = document.createElement('div');
detail.className = 'trace-detail';
detail.textContent = details.join('\n\n');
item.appendChild(detail);
}
aiBubble.trace.appendChild(item);
scrollToBottom();
}
function appendReasoning(aiBubble, text) {
if (!aiBubble.reasoning || !text) return;
aiBubble.reasoning.classList.add('show');
const content = aiBubble.reasoning.querySelector('.reasoning-content');
content.textContent += text;
content.scrollTop = content.scrollHeight;
scrollToBottom();
}
async function streamChat(messages, aiBubble) {
const txtEl = aiBubble.txt;
let full = '';
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversation_id: currentConvId,
messages,
openai_name: activeOpenAIName,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: '请求失败' }));
throw new Error(err.error || '未知错误');
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split('\n\n');
buf = lines.pop(); // 未完整的块保留
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') {
aiBubble.bub.classList.remove('typing-cursor');
return full;
}
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'string') {
full += parsed;
txtEl.innerHTML = renderMarkdown(full);
scrollToBottom();
continue;
}
if (parsed && typeof parsed === 'object') {
if (parsed.type === 'error' || parsed.error) {
throw new Error(parsed.error || parsed.message || '流式响应错误');
}
if (parsed.type === 'delta') {
const delta = parsed.text || '';
if (delta) {
full += delta;
txtEl.innerHTML = renderMarkdown(full);
}
updateTokenStats(aiBubble, parsed.stats);
scrollToBottom();
continue;
}
if (parsed.type === 'stats') {
updateTokenStats(aiBubble, parsed.stats);
scrollToBottom();
continue;
}
if (parsed.type === 'reasoning') {
appendReasoning(aiBubble, parsed.text || '');
continue;
}
if (parsed.type === 'trace') {
appendTrace(aiBubble, parsed);
continue;
}
}
} catch (e) {
if (e instanceof SyntaxError) continue;
throw e;
}
}
}
aiBubble.bub.classList.remove('typing-cursor');
return full;
}
async function startPresetConversation({ preserveInput = false } = {}) {
const preset = getPresetPrompt();
if (!preset) {
resetNewConversation({ preserveInput });
return;
}
pending = true;
setInputDisabled(true);
const keepText = preserveInput ? inputBox.value : '';
let aiBubble = null;
try {
resetNewConversation({ preserveInput });
inputBox.value = keepText;
autoResize();
clearImage();
const conv = await createConversation();
currentConvId = conv.id;
await loadConversationList();
history = [
{ role: 'system', content: preset, hidden: true },
{
role: 'user',
content: '请根据以上预先提示词,用简短自然的方式输出开场白,然后等待用户开始聊天。',
hidden: true
}
];
aiBubble = addAIBubble();
const full = await streamChat(history, aiBubble);
history.push({ role: 'assistant', content: full });
scrollToBottom();
await loadConversationList();
} catch (e) {
if (!aiBubble) aiBubble = addAIBubble();
aiBubble.txt.innerHTML = `<span class="error-msg">错误: ${escHtml(e.message)}</span>`;
aiBubble.bub.classList.remove('typing-cursor');
} finally {
pending = false;
setInputDisabled(false);
inputBox.value = keepText;
autoResize();
inputBox.focus();
}
}
// ── 发送消息 ──────────────────────────────────────────────
async function sendMessage() {
if (pending) return;
const userText = inputBox.value.trim();
if (!userText && !imageB64) return;
if (!currentConvId && history.length === 0 && getPresetPrompt()) {
await startPresetConversation({ preserveInput: true });
return;
}
pending = true;
setInputDisabled(true);
let aiBubble = null;
try {
if (!currentConvId) {
const conv = await createConversation();
currentConvId = conv.id;
await loadConversationList();
}
// 加入历史
const userMsg = { role: 'user', content: userText };
if (imageB64) userMsg.image_url = imageB64;
history.push(userMsg);
// 显示用户气泡
addBubble('user', userText, imageB64);
// 清空输入
inputBox.value = '';
autoResize();
clearImage();
aiBubble = addAIBubble();
const full = await streamChat(history, aiBubble);
history.push({ role: 'assistant', content: full });
scrollToBottom();
await loadConversationList();
} catch (e) {
if (!aiBubble) aiBubble = addAIBubble();
aiBubble.txt.innerHTML = `<span class="error-msg">错误: ${escHtml(e.message)}</span>`;
aiBubble.bub.classList.remove('typing-cursor');
} finally {
pending = false;
setInputDisabled(false);
inputBox.focus();
}
}
// ── 图片处理 ──────────────────────────────────────────────
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4MBAPI 通常限制)
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
alert('图片格式不支持,仅支持 jpeg/png/webp/gif');
fileInput.value = '';
return;
}
if (file.size > MAX_IMAGE_SIZE) {
alert(`图片文件过大(${(file.size / 1024 / 1024).toFixed(1)} MB),请选择小于 4MB 的图片`);
fileInput.value = '';
return;
}
imageName = file.name;
const reader = new FileReader();
reader.onload = e => {
imageB64 = e.target.result; // data URI
previewImg.src = imageB64;
imgNameEl.textContent = `${imageName} (${(file.size / 1024).toFixed(0)} KB)`;
previewBar.style.display = 'flex';
};
reader.onerror = () => {
alert('图片读取失败,请尝试其他文件');
fileInput.value = '';
};
reader.readAsDataURL(file);
fileInput.value = '';
});
function clearImage() {
imageB64 = '';
imageName = '';
previewImg.src = '';
previewBar.style.display = 'none';
}
btnRemove.addEventListener('click', clearImage);
// ── 自动高度 ──────────────────────────────────────────────
function autoResize() {
inputBox.style.height = 'auto';
inputBox.style.height = Math.min(inputBox.scrollHeight, 180) + 'px';
}
inputBox.addEventListener('input', autoResize);
// ── 快捷键 ────────────────────────────────────────────────
inputBox.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
btnSend.addEventListener('click', sendMessage);
modelSelect.addEventListener('change', async () => {
if (pending) {
modelSelect.value = activeOpenAIName;
return;
}
const nextName = modelSelect.value;
const prevName = activeOpenAIName;
try {
const res = await fetch('/api/openai/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nextName }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: '切换模型失败' }));
throw new Error(err.error || '切换模型失败');
}
const data = await res.json();
activeOpenAIName = data.active;
modelSelect.value = activeOpenAIName;
} catch (e) {
modelSelect.value = prevName;
alert(e.message);
}
});
searchSelect.addEventListener('change', async () => {
if (pending) {
searchSelect.value = activeSearchName;
return;
}
const nextName = searchSelect.value;
const prevName = activeSearchName;
try {
const res = await fetch('/api/search/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nextName }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: '切换搜索源失败' }));
throw new Error(err.error || '切换搜索源失败');
}
const data = await res.json();
activeSearchName = data.active;
searchSelect.value = activeSearchName;
} catch (e) {
searchSelect.value = prevName;
alert(e.message);
}
});
btnNewChat.addEventListener('click', newConversation);
btnClear.addEventListener('click', newConversation);
btnPreset.addEventListener('click', openPresetModal);
btnClosePreset.addEventListener('click', closePresetModal);
btnSavePreset.addEventListener('click', () => {
savePresetPrompt(presetInput.value.trim());
closePresetModal();
});
btnClearPreset.addEventListener('click', () => {
presetInput.value = '';
savePresetPrompt('');
closePresetModal();
});
presetModal.addEventListener('click', e => {
if (e.target === presetModal) closePresetModal();
});
// 自动聚焦 & 初始化
loadOpenAIProfiles().catch(e => alert(e.message));
loadSearchProfiles().catch(e => alert(e.message));
loadConversationList();
inputBox.focus();
</script>
</body>
</html>