- 一步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>
1233 lines
34 KiB
HTML
1233 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>
|
||
<!-- 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,'&').replace(/</g,'<').replace(/>/g,'>').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>
|