1143 lines
34 KiB
HTML
1143 lines
34 KiB
HTML
<!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, #btnSearch {
|
||
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, #btnSearch:hover { border-color: var(--accent); color: var(--accent); }
|
||
#btnSearch.active {
|
||
background: var(--accent-soft);
|
||
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; } }
|
||
|
||
/* 错误消息 */
|
||
.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="btnSearch" title="开启后,本轮提问会先联网搜索">联网搜索:关</button>
|
||
<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 发送 · Shift+Enter 换行 · 支持图片多模态</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 webSearchEnabled = 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 btnSearch = document.getElementById('btnSearch');
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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;
|
||
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) {
|
||
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 txt = document.createElement('span');
|
||
bub.appendChild(txt);
|
||
row.appendChild(av);
|
||
row.appendChild(bub);
|
||
msgBox.appendChild(row);
|
||
scrollToBottom();
|
||
return { bub, txt };
|
||
}
|
||
|
||
async function streamChat(messages, aiBubble, webSearch = false) {
|
||
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,
|
||
web_search: webSearch,
|
||
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 (parsed && typeof parsed === 'object' && parsed.error) {
|
||
throw new Error(parsed.error);
|
||
}
|
||
if (typeof parsed === 'string') {
|
||
full += parsed;
|
||
txtEl.innerHTML = renderMarkdown(full);
|
||
scrollToBottom();
|
||
}
|
||
} 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, webSearchEnabled);
|
||
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; // 4MB(API 通常限制)
|
||
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);
|
||
btnSearch.addEventListener('click', () => {
|
||
if (pending) return;
|
||
webSearchEnabled = !webSearchEnabled;
|
||
updateSearchButton();
|
||
});
|
||
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();
|
||
});
|
||
|
||
// 自动聚焦 & 初始化
|
||
updateSearchButton();
|
||
loadOpenAIProfiles().catch(e => alert(e.message));
|
||
loadSearchProfiles().catch(e => alert(e.message));
|
||
loadConversationList();
|
||
inputBox.focus();
|
||
</script>
|
||
</body>
|
||
</html>
|