Files
chat/index.html
T
jackyu66gitandClaude 3633952c6b feat: 大模型聊天工具
- 一步API接入,支持 Claude Fable 5 / Sonnet 4.6 / Opus 4.8
- Node.js 代理服务,API Key 通过 config.json 本地配置
- 流式响应 + Markdown 渲染 + 代码高亮
- 多对话管理,服务端持久化 + localStorage 双保险
- 深色/浅色主题,响应式布局

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 14:49:48 +08:00

1233 lines
34 KiB
HTML
Raw 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>
<!-- Markdown 渲染 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/14.1.0/marked.min.js"></script>
<!-- 代码高亮 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
/* ===== 重置 & 基础 ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--bg-input: #1e2a4a;
--bg-message-user: #0f3460;
--bg-message-assistant: #1a1a2e;
--bg-hover: #1e3050;
--border-color: #2a3a5c;
--text-primary: #e4e6eb;
--text-secondary: #b0b3b8;
--text-muted: #8a8d91;
--accent: #4f8cff;
--accent-hover: #6ba0ff;
--accent-light: rgba(79,140,255,0.15);
--danger: #e74c3c;
--success: #2ecc71;
--warning: #f39c12;
--shadow: 0 4px 24px rgba(0,0,0,0.3);
--radius: 12px;
--radius-sm: 8px;
--transition: 0.2s ease;
}
[data-theme="light"] {
--bg-primary: #f5f6fa;
--bg-secondary: #ffffff;
--bg-tertiary: #e8ecf1;
--bg-input: #f0f2f5;
--bg-message-user: #dce8ff;
--bg-message-assistant: #ffffff;
--bg-hover: #e8ecf1;
--border-color: #dce1e8;
--text-primary: #1c1e21;
--text-secondary: #606770;
--text-muted: #8a8d91;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-light: rgba(37,99,235,0.1);
--shadow: 0 4px 24px rgba(0,0,0,0.08);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
display: flex;
overflow: hidden;
line-height: 1.6;
}
/* ===== 侧边栏 ===== */
.sidebar {
width: 280px;
min-width: 280px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: transform var(--transition);
z-index: 100;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h1 {
font-size: 1.2rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-header h1 .logo {
width: 32px; height: 32px;
background: linear-gradient(135deg, var(--accent), #7c3aed);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 1rem;
color: #fff;
}
.sidebar-actions {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
.btn-new-chat {
width: 100%;
padding: 10px 16px;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background var(--transition);
font-weight: 600;
}
.btn-new-chat:hover { background: var(--accent-hover); }
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conversation-item {
padding: 10px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
transition: background var(--transition);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
}
.conversation-item:hover { background: var(--bg-hover); }
.conversation-item.active { background: var(--accent-light); color: var(--accent); font-weight: 500; }
.conversation-item .title {
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.conversation-item .delete-btn {
opacity: 0;
background: none; border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
transition: opacity var(--transition), color var(--transition);
}
.conversation-item:hover .delete-btn { opacity: 1; }
.conversation-item .delete-btn:hover { color: var(--danger); }
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.theme-toggle {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-secondary);
cursor: pointer;
}
.theme-toggle input { display: none; }
.theme-toggle .toggle-track {
width: 40px; height: 22px;
background: var(--border-color);
border-radius: 11px;
position: relative;
transition: background var(--transition);
}
.theme-toggle .toggle-track::after {
content: '';
position: absolute;
top: 2px; left: 2px;
width: 18px; height: 18px;
background: #fff;
border-radius: 50%;
transition: transform var(--transition);
}
.theme-toggle input:checked + .toggle-track { background: var(--accent); }
.theme-toggle input:checked + .toggle-track::after { transform: translateX(18px); }
/* ===== 主聊天区 ===== */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-header {
padding: 12px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-secondary);
}
.chat-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.chat-title-row .title {
font-weight: 600;
font-size: 0.9rem;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-controls-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chat-header select, .chat-header input[type="number"] {
padding: 6px 10px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.8rem;
outline: none;
}
.chat-header select:focus, .chat-header input[type="number"]:focus { border-color: var(--accent); }
.header-control {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--text-muted);
}
.header-control label { white-space: nowrap; }
.mobile-menu-btn {
display: none;
background: none;
border: none;
color: var(--text-primary);
font-size: 1.4rem;
cursor: pointer;
padding: 4px;
}
/* ===== 消息区 ===== */
.messages {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.messages .empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
text-align: center;
gap: 12px;
}
.empty-state .icon {
font-size: 4rem;
opacity: 0.5;
}
.empty-state h2 {
font-size: 1.5rem;
color: var(--text-secondary);
font-weight: 600;
}
.empty-state p {
font-size: 0.9rem;
max-width: 400px;
line-height: 1.8;
}
.message {
display: flex;
gap: 12px;
max-width: 85%;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message .avatar {
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1rem;
flex-shrink: 0;
}
.message.user .avatar {
background: linear-gradient(135deg, var(--accent), #7c3aed);
color: #fff;
}
.message.assistant .avatar {
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
}
.message .content {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 0.9rem;
line-height: 1.7;
word-break: break-word;
}
.message.user .content {
background: var(--bg-message-user);
border-bottom-right-radius: 4px;
}
.message.assistant .content {
background: var(--bg-message-assistant);
border: 1px solid var(--border-color);
border-bottom-left-radius: 4px;
}
/* Markdown 样式 */
.message .content h1, .message .content h2, .message .content h3 {
margin: 12px 0 6px;
font-weight: 600;
}
.message .content h1 { font-size: 1.3rem; }
.message .content h2 { font-size: 1.15rem; }
.message .content h3 { font-size: 1.05rem; }
.message .content p { margin: 6px 0; }
.message .content ul, .message .content ol { margin: 8px 0; padding-left: 20px; }
.message .content li { margin: 4px 0; }
.message .content code {
background: var(--bg-input);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85em;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
}
.message .content pre {
background: #1e1e2e;
border-radius: 8px;
padding: 14px;
overflow-x: auto;
margin: 10px 0;
position: relative;
}
.message .content pre code {
background: none;
padding: 0;
font-size: 0.85em;
}
.message .content blockquote {
border-left: 3px solid var(--accent);
padding-left: 12px;
color: var(--text-secondary);
margin: 8px 0;
}
.message .content table {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
}
.message .content th, .message .content td {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: left;
font-size: 0.85rem;
}
.message .content th { background: var(--bg-input); font-weight: 600; }
.message .content a { color: var(--accent); text-decoration: none; }
.message .content a:hover { text-decoration: underline; }
.message .content img { max-width: 100%; border-radius: 8px; }
.copy-btn {
position: absolute;
top: 8px; right: 8px;
background: rgba(255,255,255,0.1);
border: none;
color: #aaa;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.7rem;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition);
}
.message .content pre:hover .copy-btn { opacity: 1; }
.copy-btn:hover { background: rgba(255,255,255,0.2); color: #fff; }
.message-actions {
display: flex;
gap: 8px;
margin-top: 6px;
opacity: 0;
transition: opacity var(--transition);
}
.message:hover .message-actions { opacity: 1; }
.msg-time {
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 4px;
}
.message-actions button {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: color var(--transition), background var(--transition);
}
.message-actions button:hover { color: var(--text-primary); background: var(--bg-hover); }
/* 加载动画 */
.typing-indicator {
display: flex;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 8px; height: 8px;
background: var(--accent);
border-radius: 50%;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }
}
/* ===== 输入区 ===== */
.input-area {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.input-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.input-row textarea {
flex: 1;
padding: 12px 16px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius);
color: var(--text-primary);
font-size: 0.9rem;
font-family: inherit;
line-height: 1.6;
resize: none;
outline: none;
min-height: 48px;
max-height: 200px;
transition: border-color var(--transition);
}
.input-row textarea:focus { border-color: var(--accent); }
.input-row textarea::placeholder { color: var(--text-muted); }
.btn-send {
width: 48px; height: 48px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 50%;
font-size: 1.2rem;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background var(--transition), transform var(--transition);
flex-shrink: 0;
}
.btn-send:hover { background: var(--accent-hover); transform: scale(1.05); }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-send:disabled:hover { background: var(--accent); }
.btn-stop {
width: 48px; height: 48px;
background: var(--danger);
color: #fff;
border: none;
border-radius: 50%;
font-size: 1rem;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.input-hint {
text-align: center;
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 8px;
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 10px 20px;
border-radius: 8px;
font-size: 0.85rem;
z-index: 1000;
animation: slideUp 0.3s ease, fadeOut 0.3s ease 1.7s forwards;
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
@keyframes slideUp {
from { transform: translateX(-50%) translateY(20px); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
@keyframes fadeOut {
to { opacity: 0; }
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0; top: 0; bottom: 0;
transform: translateX(-100%);
}
.sidebar.open { transform: translateX(0); }
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 99;
}
.sidebar-overlay.open { display: block; }
.mobile-menu-btn { display: block; }
.message { max-width: 95%; }
.chat-header { padding: 12px 16px; }
.messages { padding: 16px; }
.input-area { padding: 12px 16px; }
.header-control { display: none; }
}
/* 滚动条 */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
</style>
</head>
<body>
<!-- 侧边栏遮罩 -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- 侧边栏 -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<h1><span class="logo">🤖</span> 大模型聊天</h1>
</div>
<div class="sidebar-actions">
<button class="btn-new-chat" onclick="newChat()"> 新建对话</button>
</div>
<div class="conversation-list" id="conversationList"></div>
<div class="sidebar-footer">
<label class="theme-toggle">
<span>☀️ 浅色模式</span>
<input type="checkbox" id="themeToggle" onchange="toggleTheme()">
<span class="toggle-track"></span>
</label>
</div>
</aside>
<!-- 主聊天区 -->
<main class="main">
<div class="chat-header">
<div class="chat-title-row">
<button class="mobile-menu-btn" onclick="toggleSidebar()" title="菜单"></button>
<span class="title" id="chatTitle">新对话</span>
</div>
<div class="chat-controls-row">
<div class="header-control">
<label>模型</label>
<select id="modelSelect">
<option value="claude-fable-5">Claude Fable 5</option>
<option value="claude-sonnet-4-6">Claude Sonnet 4.6</option>
<option value="claude-opus-4-8">Claude Opus 4.8</option>
</select>
</div>
<div class="header-control">
<label>Max Tokens</label>
<input type="number" id="maxTokensInput" value="50000" min="1" max="128000" style="width:90px;text-align:center;">
</div>
<button class="btn-new-chat" onclick="clearChat()" style="padding:6px 12px;font-size:0.8rem;">🗑 清空</button>
</div>
</div>
<div class="messages" id="messages">
<div class="empty-state">
<div class="icon">💬</div>
<h2>大模型聊天工具</h2>
<p>通过 API 代理接入多种主流大模型,让对话更智能。</p>
<p style="font-size:0.8rem;">API Key 已通过本地配置文件加载,开箱即用</p>
</div>
</div>
<div class="input-area">
<div class="input-row">
<textarea id="userInput" placeholder="输入消息,Enter 发送,Shift+Enter 换行" rows="1" onkeydown="handleKeyDown(event)"></textarea>
<button class="btn-send" id="sendBtn" onclick="sendMessage()" title="发送"></button>
</div>
<div class="input-hint">回答由 AI 生成,请谨慎甄别</div>
</div>
</main>
<script>
// ===== Markdown 降级方案(CDN 加载失败时使用) =====
function safeMarkdown(text) {
if (typeof marked !== 'undefined') {
try { return marked.parse(text); } catch(e) {}
}
// 降级:纯文本 + 简单换行
return (text || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>');
}
function safeHighlight() {
if (typeof hljs === 'undefined') return;
try {
document.querySelectorAll('.message .content pre code').forEach(block => {
if (!block.dataset.highlighted) {
hljs.highlightElement(block);
block.dataset.highlighted = '1';
const pre = block.parentElement;
if (!pre.querySelector('.copy-btn')) {
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = '复制';
btn.onclick = () => {
navigator.clipboard.writeText(block.textContent).then(() => {
btn.textContent = '已复制!';
setTimeout(() => btn.textContent = '复制', 1500);
});
};
pre.style.position = 'relative';
pre.appendChild(btn);
}
}
});
} catch(e) {}
}
// ===== 配置 marked.js =====
if (typeof marked !== 'undefined') {
marked.setOptions({
breaks: true,
gfm: true,
highlight: function(code, lang) {
if (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) {
try { return hljs.highlight(code, { language: lang }).value; } catch(e) {}
}
return code;
}
});
}
// ===== 状态管理 =====
let conversations = [];
let currentConversationId = null;
let abortController = null;
// 从服务端加载对话
async function loadConversationsFromServer() {
try {
const resp = await fetch('/api/conversations');
if (resp.ok) {
const data = await resp.json();
if (Array.isArray(data) && data.length > 0) {
conversations = data;
}
}
} catch(e) { console.log('从服务端加载对话失败,回退到本地:', e.message); }
// 回退到 localStorage
if (conversations.length === 0) {
conversations = JSON.parse(localStorage.getItem('yibu_conversations') || '[]');
}
if (conversations.length === 0) {
newChat(false);
} else {
currentConversationId = localStorage.getItem('yibu_current_conv') || conversations[0].id;
if (!conversations.find(c => c.id === currentConversationId)) {
currentConversationId = conversations[0].id;
}
}
renderConversationList();
loadConversation();
}
// 持久化对话:localStorage 立即写,服务端防抖
let _saveTimer = null;
function persistConversations() {
// localStorage 立即保存,关掉页面也不丢
localStorage.setItem('yibu_conversations', JSON.stringify(conversations));
// 服务端防抖 300ms
clearTimeout(_saveTimer);
_saveTimer = setTimeout(async () => {
try {
await fetch('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(conversations)
});
} catch(e) { console.log('保存到服务端失败:', e.message); }
}, 300);
}
// 关闭页面前用 sendBeacon 确保服务端落盘
window.addEventListener('beforeunload', () => {
if (_saveTimer) {
clearTimeout(_saveTimer);
navigator.sendBeacon('/api/conversations', JSON.stringify(conversations));
}
});
// ===== 初始化 =====
function init() {
// 恢复主题
const theme = localStorage.getItem('yibu_theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
document.getElementById('themeToggle').checked = theme === 'light';
// 恢复模型选择
const savedModel = localStorage.getItem('yibu_model');
if (savedModel) document.getElementById('modelSelect').value = savedModel;
// 恢复 max_tokens(忽略异常低的值)
const savedMaxTokens = parseInt(localStorage.getItem('yibu_max_tokens'));
if (savedMaxTokens >= 10000) document.getElementById('maxTokensInput').value = savedMaxTokens;
// 从服务端恢复对话
loadConversationsFromServer();
// 监听模型、温度、max_tokens 变化
document.getElementById('modelSelect').addEventListener('change', function() {
localStorage.setItem('yibu_model', this.value);
});
document.getElementById('maxTokensInput').addEventListener('change', function() {
localStorage.setItem('yibu_max_tokens', this.value);
});
}
// ===== 对话管理 =====
function newChat(save = true) {
const id = 'conv_' + Date.now();
const conv = { id, title: '新对话', messages: [], createdAt: new Date().toISOString() };
conversations.unshift(conv);
currentConversationId = id;
if (save) {
localStorage.setItem('yibu_current_conv', currentConversationId);
persistConversations();
}
renderConversationList();
loadConversation();
}
// 切换对话:只改 currentId,不修改数据,无需 persistConversations
function switchConversation(id) {
if (abortController) {
abortController.abort();
abortController = null;
}
currentConversationId = id;
localStorage.setItem('yibu_current_conv', id);
renderConversationList();
loadConversation();
}
function deleteConversation(id, e) {
e.stopPropagation();
if (!confirm('确定删除此对话吗?')) return;
conversations = conversations.filter(c => c.id !== id);
if (currentConversationId === id) {
currentConversationId = conversations.length > 0 ? conversations[0].id : null;
}
persistConversations();
localStorage.setItem('yibu_current_conv', currentConversationId || '');
renderConversationList();
if (conversations.length === 0) {
newChat(false);
} else {
loadConversation();
}
}
function getCurrentConversation() {
return conversations.find(c => c.id === currentConversationId);
}
function renderConversationList() {
const list = document.getElementById('conversationList');
if (conversations.length === 0) {
list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem;">暂无对话</div>';
return;
}
list.innerHTML = conversations.map(c => `
<div class="conversation-item ${c.id === currentConversationId ? 'active' : ''}" onclick="switchConversation('${c.id}')">
<span class="title">${escapeHtml(c.title)}</span>
<button class="delete-btn" onclick="deleteConversation('${c.id}', event)">✕</button>
</div>
`).join('');
}
function loadConversation() {
const conv = getCurrentConversation();
const messagesDiv = document.getElementById('messages');
document.getElementById('chatTitle').textContent = conv ? conv.title : '新对话';
if (!conv || conv.messages.length === 0) {
messagesDiv.innerHTML = `
<div class="empty-state">
<div class="icon">💬</div>
<h2>大模型聊天工具</h2>
<p>通过 API 代理接入多种主流大模型,让对话更智能。</p>
</div>`;
return;
}
messagesDiv.innerHTML = conv.messages.map((m, i) => renderMessage(m, i === conv.messages.length - 1)).join('');
scrollToBottom();
// 高亮最后一条 assistant 消息中的代码块
if (conv.messages[conv.messages.length - 1].role === 'assistant') {
safeHighlight();
}
}
function saveConversation() {
persistConversations();
}
function clearChat() {
if (!confirm('确定清空当前对话吗?')) return;
const conv = getCurrentConversation();
if (conv) {
conv.messages = [];
conv.title = '新对话';
saveConversation();
loadConversation();
}
}
// ===== 消息渲染 =====
function renderMessage(m, isLast) {
const role = m.role;
const avatarIcon = role === 'user' ? '👤' : '🤖';
const contentHtml = role === 'assistant'
? safeMarkdown(m.content || '')
: escapeHtml(m.content || '');
const actionsHtml = role === 'assistant'
? `<div class="message-actions" style="opacity:1">
<button onclick="copyAssistantContent(this)" title="复制回复">📋 复制</button>
${isLast ? '<button onclick="regenerate()" title="重新生成">🔄 重新生成</button>' : ''}
</div>`
: '';
return `
<div class="message ${role}">
<div class="avatar">${avatarIcon}</div>
<div>
<div class="content">${contentHtml}</div>
${m.time ? `<div class="msg-time">${formatTime(m.time)}</div>` : ''}
${actionsHtml}
</div>
</div>`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatTime(iso) {
const d = new Date(iso);
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
// 当天只显示时间,否则显示日期+时间
if (d.toDateString() === now.toDateString()) return time;
return `${d.getMonth()+1}/${d.getDate()} ${time}`;
}
function scrollToBottom() {
const messagesDiv = document.getElementById('messages');
requestAnimationFrame(() => {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
});
}
// ===== API 调用 =====
async function sendMessage() {
const input = document.getElementById('userInput');
const content = input.value.trim();
if (!content) return;
const conv = getCurrentConversation();
if (!conv) { newChat(); return sendMessage(); }
// 添加用户消息
conv.messages.push({ role: 'user', content, time: new Date().toISOString() });
if (conv.title === '新对话' && conv.messages.length === 1) {
conv.title = content.slice(0, 30) + (content.length > 30 ? '...' : '');
}
saveConversation();
renderConversationList();
loadConversation();
// 清空输入
input.value = '';
input.style.height = 'auto';
// 显示发送中状态
const sendBtn = document.getElementById('sendBtn');
sendBtn.disabled = true;
// 添加 assistant 占位消息
conv.messages.push({ role: 'assistant', content: '', time: new Date().toISOString() });
saveConversation();
loadConversation();
// 添加加载动画
const messagesDiv = document.getElementById('messages');
const lastContent = messagesDiv.querySelector('.message:last-child .content');
if (lastContent) {
lastContent.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
}
// 构建请求
const model = document.getElementById('modelSelect').value;
// 限制最大 128000,防止手滑打太多 0
let maxTokens = parseInt(document.getElementById('maxTokensInput').value) || 50000;
if (maxTokens > 128000) { maxTokens = 128000; showToast('Max Tokens 已自动限制为 128000', ''); }
// 排除空的 assistant 占位消息
const messages = conv.messages
.filter(m => m.role !== 'assistant' || m.content !== '')
.map(m => ({ role: m.role, content: m.content }));
const body = {
model,
messages,
max_tokens: maxTokens,
stream: true
};
abortController = new AbortController();
try {
const response = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
signal: abortController.signal
});
if (!response.ok) {
const errorText = await response.text();
let errorMsg;
try {
const err = JSON.parse(errorText);
errorMsg = err.error?.message || err.message || `HTTP ${response.status}`;
} catch {
errorMsg = `HTTP ${response.status}: ${errorText.slice(0, 200)}`;
}
throw new Error(errorMsg);
}
// 将发送按钮替换为停止按钮
sendBtn.style.display = 'none';
const stopBtn = document.createElement('button');
stopBtn.className = 'btn-stop';
stopBtn.textContent = '■';
stopBtn.onclick = () => {
abortController.abort();
abortController = null;
stopBtn.replaceWith(sendBtn);
sendBtn.style.display = '';
sendBtn.disabled = false;
};
sendBtn.parentElement.insertBefore(stopBtn, sendBtn);
// 读取流
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullContent = '';
let buffer = '';
let finishReason = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
const fr = parsed.choices?.[0]?.finish_reason;
if (fr) finishReason = fr;
if (delta) {
fullContent += delta;
const conv = getCurrentConversation();
if (conv && conv.messages.length > 0) {
conv.messages[conv.messages.length - 1].content = fullContent;
}
const lastMsgContent = messagesDiv.querySelector('.message:last-child .content');
if (lastMsgContent) {
lastMsgContent.innerHTML = safeMarkdown(fullContent);
safeHighlight();
}
scrollToBottom();
}
} catch (e) {}
}
}
// 如果因 token 限制被截断,加标记
if (finishReason === 'length') {
fullContent += '\n\n> ⚠️ 回复因 token 限制被截断,可调大 Max Tokens 后重新生成';
}
conv.messages[conv.messages.length - 1].content = fullContent;
saveConversation();
// 最终渲染
const lastMsgContent = messagesDiv.querySelector('.message:last-child .content');
if (lastMsgContent) {
lastMsgContent.innerHTML = safeMarkdown(fullContent);
safeHighlight();
}
scrollToBottom();
// 恢复发送按钮
if (stopBtn.parentElement) {
stopBtn.replaceWith(sendBtn);
}
sendBtn.style.display = '';
sendBtn.disabled = false;
abortController = null;
} catch (error) {
if (error.name === 'AbortError') {
// 用户主动停止
const conv = getCurrentConversation();
if (conv && conv.messages.length > 0 && conv.messages[conv.messages.length - 1].role === 'assistant') {
if (!conv.messages[conv.messages.length - 1].content) {
conv.messages[conv.messages.length - 1].content = '_[已停止生成]_';
}
saveConversation();
loadConversation();
}
} else {
console.error('API 错误:', error);
showToast(`请求失败: ${error.message}`, 'error');
// 移除空的 assistant 消息并显示错误
const conv = getCurrentConversation();
if (conv && conv.messages.length > 0 && conv.messages[conv.messages.length - 1].role === 'assistant') {
if (!conv.messages[conv.messages.length - 1].content) {
conv.messages[conv.messages.length - 1].content = `❌ **请求失败**: ${error.message}`;
}
saveConversation();
loadConversation();
}
}
sendBtn.style.display = '';
sendBtn.disabled = false;
abortController = null;
}
}
function regenerate() {
const conv = getCurrentConversation();
if (!conv) return;
// 移除最后一条 assistant 消息和最后一条 user 消息
if (conv.messages.length >= 2) {
const lastMsg = conv.messages[conv.messages.length - 1];
const secondLast = conv.messages[conv.messages.length - 2];
if (lastMsg.role === 'assistant' && secondLast.role === 'user') {
const userContent = secondLast.content;
conv.messages = conv.messages.slice(0, -2);
saveConversation();
document.getElementById('userInput').value = userContent;
sendMessage();
return;
}
}
showToast('无法重新生成', 'error');
}
function copyAssistantContent(btn) {
const contentEl = btn.closest('.message').querySelector('.content');
const html = contentEl.innerHTML;
const plainText = (contentEl.textContent || contentEl.innerText || '').trim();
// 同时写入 HTML 和纯文本,粘贴时自动匹配目标格式
const item = new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' }),
});
navigator.clipboard.write([item]).then(() => {
showToast('已复制(支持保留格式粘贴)', 'success');
}).catch(() => {
// 降级为纯文本
navigator.clipboard.writeText(plainText).then(() => {
showToast('已复制', 'success');
}).catch(() => {
showToast('复制失败,请手动选择', 'error');
});
});
}
// ===== 主题切换 =====
function toggleTheme() {
const isLight = document.getElementById('themeToggle').checked;
const theme = isLight ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('yibu_theme', theme);
}
// ===== 侧边栏 =====
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay');
sidebar.classList.toggle('open');
overlay.classList.toggle('open');
}
document.getElementById('sidebarOverlay').addEventListener('click', toggleSidebar);
// ===== 键盘事件 =====
function handleKeyDown(e) {
// 输入法组合中(如英文确认、拼音选词),不发送
if (e.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
// 自动调整 textarea 高度
document.getElementById('userInput').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
});
// ===== Toast =====
function showToast(message, type = '') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
// ===== 启动 =====
init();
</script>
</body>
</html>