356 lines
9.6 KiB
JavaScript
356 lines
9.6 KiB
JavaScript
const messagesEl = document.getElementById("messages");
|
||
const inputEl = document.getElementById("input");
|
||
const sendBtn = document.getElementById("sendBtn");
|
||
const newChatBtn = document.getElementById("newChatBtn");
|
||
const chatListEl = document.getElementById("chatList");
|
||
const clearBtn = document.getElementById("clearBtn");
|
||
|
||
const sidebarToggleBtn = document.getElementById("sidebarToggle");
|
||
const appRootEl = document.querySelector(".app");
|
||
|
||
let chats = JSON.parse(localStorage.getItem("ds_chats") || "{}");
|
||
let currentChatId = localStorage.getItem("ds_current_chat");
|
||
let sidebarHidden = localStorage.getItem("ds_sidebar_hidden") === "1";
|
||
|
||
/** ---------- 时间格式化 ---------- */
|
||
function formatChatDate(ts) {
|
||
try {
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
}).format(new Date(ts));
|
||
} catch {
|
||
const d = new Date(ts);
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
}
|
||
|
||
function formatMsgDateTime(ts) {
|
||
try {
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
}).format(new Date(ts));
|
||
} catch {
|
||
const d = new Date(ts);
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
const hh = String(d.getHours()).padStart(2, "0");
|
||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
|
||
}
|
||
}
|
||
|
||
/** ---------- 兼容旧数据:补齐 createdAt/updatedAt/ts ---------- */
|
||
function normalizeData() {
|
||
const now = Date.now();
|
||
for (const id of Object.keys(chats)) {
|
||
const chat = chats[id] || {};
|
||
if (!chat.title) chat.title = "New Chat";
|
||
if (!Array.isArray(chat.messages)) chat.messages = [];
|
||
|
||
if (!chat.createdAt) {
|
||
const first = chat.messages[0];
|
||
chat.createdAt = first?.ts || now;
|
||
}
|
||
if (!chat.updatedAt) {
|
||
const last = chat.messages[chat.messages.length - 1];
|
||
chat.updatedAt = last?.ts || chat.createdAt;
|
||
}
|
||
|
||
for (const m of chat.messages) {
|
||
if (!m.ts) m.ts = chat.createdAt;
|
||
}
|
||
|
||
chats[id] = chat;
|
||
}
|
||
}
|
||
normalizeData();
|
||
|
||
/** ---------- 保存 ---------- */
|
||
function save() {
|
||
localStorage.setItem("ds_chats", JSON.stringify(chats));
|
||
localStorage.setItem("ds_current_chat", currentChatId || "");
|
||
localStorage.setItem("ds_sidebar_hidden", sidebarHidden ? "1" : "0");
|
||
}
|
||
|
||
/** ---------- 侧栏显示/隐藏 ---------- */
|
||
function applySidebarState() {
|
||
if (!appRootEl) return;
|
||
appRootEl.classList.toggle("sidebar-hidden", sidebarHidden);
|
||
}
|
||
applySidebarState();
|
||
|
||
if (sidebarToggleBtn) {
|
||
sidebarToggleBtn.onclick = () => {
|
||
sidebarHidden = !sidebarHidden;
|
||
applySidebarState();
|
||
save();
|
||
};
|
||
}
|
||
|
||
/** ---------- Chat 操作 ---------- */
|
||
function newChat() {
|
||
const now = Date.now();
|
||
currentChatId = crypto.randomUUID();
|
||
chats[currentChatId] = {
|
||
title: "New Chat",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
messages: [],
|
||
};
|
||
save();
|
||
renderChatList();
|
||
renderMessages();
|
||
}
|
||
|
||
function deleteChat(id) {
|
||
if (!id || !chats[id]) return;
|
||
|
||
const title = chats[id].title || "This chat";
|
||
const ok = confirm(`Delete "${title}" ?`);
|
||
if (!ok) return;
|
||
|
||
delete chats[id];
|
||
|
||
// ✅ 关键:允许删除最后一个。删完后不自动 newChat()
|
||
if (currentChatId === id) {
|
||
const remainingIds = Object.keys(chats);
|
||
currentChatId = remainingIds[0] || null;
|
||
}
|
||
|
||
save();
|
||
renderChatList();
|
||
renderMessages();
|
||
}
|
||
|
||
/** ---------- 渲染左侧列表(显示创建日期 + 删除按钮 + 按更新时间排序)---------- */
|
||
function renderChatList() {
|
||
chatListEl.innerHTML = "";
|
||
|
||
const entries = Object.entries(chats);
|
||
entries.sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0));
|
||
|
||
// 如果没有任何 chat,列表保持空(不用提示,提示放在右侧 messages)
|
||
if (entries.length === 0) return;
|
||
|
||
for (const [id, chat] of entries) {
|
||
const li = document.createElement("li");
|
||
li.className = "chat-item";
|
||
if (id === currentChatId) li.classList.add("active");
|
||
|
||
const left = document.createElement("div");
|
||
left.className = "chat-item-left";
|
||
|
||
const title = document.createElement("div");
|
||
title.className = "chat-title";
|
||
title.textContent = chat.title || "New Chat";
|
||
|
||
const date = document.createElement("div");
|
||
date.className = "chat-date";
|
||
date.textContent = formatChatDate(chat.createdAt || Date.now());
|
||
|
||
left.appendChild(title);
|
||
left.appendChild(date);
|
||
|
||
const del = document.createElement("button");
|
||
del.className = "chat-del";
|
||
del.type = "button";
|
||
del.title = "Delete";
|
||
del.textContent = "🗑";
|
||
del.onclick = (e) => {
|
||
e.stopPropagation();
|
||
deleteChat(id);
|
||
};
|
||
|
||
li.appendChild(left);
|
||
li.appendChild(del);
|
||
|
||
li.onclick = () => {
|
||
currentChatId = id;
|
||
save();
|
||
renderChatList();
|
||
renderMessages();
|
||
};
|
||
|
||
chatListEl.appendChild(li);
|
||
}
|
||
}
|
||
|
||
/** ---------- 空状态 UI ---------- */
|
||
function renderEmptyState(kind) {
|
||
// kind: "no-chat" | "no-messages"
|
||
const div = document.createElement("div");
|
||
div.className = "empty-state";
|
||
|
||
if (kind === "no-chat") {
|
||
div.innerHTML = `
|
||
<h3>还没有会话</h3>
|
||
<div>你可以:</div>
|
||
<ul>
|
||
<li>点击左上角 <code>+</code> 创建新会话</li>
|
||
<li>或者直接在下方输入内容并发送(会自动创建新会话)</li>
|
||
<li>需要隐藏左侧列表:点顶部 <code>☰</code></li>
|
||
</ul>
|
||
`;
|
||
} else {
|
||
div.innerHTML = `
|
||
<h3>这个会话还没有消息</h3>
|
||
<div>在下方输入内容,按 <code>Enter</code> 发送;<code>Shift+Enter</code> 换行。</div>
|
||
`;
|
||
}
|
||
|
||
messagesEl.appendChild(div);
|
||
}
|
||
|
||
/** ---------- 渲染消息(每条消息显示日期时间)---------- */
|
||
function renderMessages() {
|
||
messagesEl.innerHTML = "";
|
||
|
||
// 没有任何 chat
|
||
if (!currentChatId || !chats[currentChatId]) {
|
||
renderEmptyState("no-chat");
|
||
return;
|
||
}
|
||
|
||
const chat = chats[currentChatId];
|
||
const msgs = chat.messages || [];
|
||
|
||
// 有 chat 但没消息
|
||
if (msgs.length === 0) {
|
||
renderEmptyState("no-messages");
|
||
return;
|
||
}
|
||
|
||
for (const m of msgs) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = `message ${m.role} ${m.thinking ? "thinking" : ""}`;
|
||
|
||
const content = document.createElement("div");
|
||
content.className = "msg-content";
|
||
|
||
if (m.role === "assistant" && !m.thinking) {
|
||
content.innerHTML = marked.parse(m.content || "");
|
||
content.querySelectorAll("pre code").forEach((block) => {
|
||
if (window.hljs) window.hljs.highlightElement(block);
|
||
});
|
||
} else {
|
||
content.textContent = m.content || "";
|
||
}
|
||
|
||
const meta = document.createElement("div");
|
||
meta.className = "msg-meta";
|
||
meta.textContent = formatMsgDateTime(m.ts || Date.now());
|
||
|
||
wrap.appendChild(content);
|
||
wrap.appendChild(meta);
|
||
|
||
messagesEl.appendChild(wrap);
|
||
}
|
||
|
||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||
}
|
||
|
||
/** ---------- 发送消息 ---------- */
|
||
sendBtn.onclick = async () => {
|
||
const text = inputEl.value.trim();
|
||
if (!text) return;
|
||
|
||
sendBtn.disabled = true;
|
||
sendBtn.textContent = "Sending...";
|
||
|
||
// ✅ 如果你把最后一个 chat 删光了:这里会自动新建
|
||
if (!currentChatId || !chats[currentChatId]) {
|
||
newChat();
|
||
}
|
||
const chat = chats[currentChatId];
|
||
|
||
const now = Date.now();
|
||
|
||
// user 消息带时间戳
|
||
chat.messages.push({ role: "user", content: text, ts: now });
|
||
chat.updatedAt = now;
|
||
inputEl.value = "";
|
||
|
||
// thinking 消息也带时间戳(后续只改内容/ts)
|
||
const thinkingMsg = { role: "assistant", content: "🤔 思考中…", thinking: true, ts: Date.now() };
|
||
chat.messages.push(thinkingMsg);
|
||
chat.updatedAt = thinkingMsg.ts;
|
||
|
||
renderMessages();
|
||
save();
|
||
|
||
try {
|
||
const res = await fetch("/api/chat", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
model: document.getElementById("modelSelect").value || "deepseek-chat",
|
||
messages: chat.messages.filter((m) => !m.thinking),
|
||
temperature: parseFloat(document.getElementById("tempInput").value || 0.7),
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
|
||
const data = await res.json();
|
||
|
||
thinkingMsg.thinking = false;
|
||
thinkingMsg.content = data.reply || "No response";
|
||
thinkingMsg.ts = Date.now(); // ✅ 用“收到回复的时间”
|
||
chat.updatedAt = thinkingMsg.ts;
|
||
|
||
if (chat.title === "New Chat") {
|
||
chat.title = text.slice(0, 20);
|
||
}
|
||
} catch (e) {
|
||
thinkingMsg.thinking = false;
|
||
thinkingMsg.content = "❌ 请求失败";
|
||
thinkingMsg.ts = Date.now();
|
||
chat.updatedAt = thinkingMsg.ts;
|
||
console.error(e);
|
||
} finally {
|
||
sendBtn.disabled = false;
|
||
sendBtn.textContent = "Send";
|
||
}
|
||
|
||
save();
|
||
renderChatList();
|
||
renderMessages();
|
||
};
|
||
|
||
/** Enter=send, Shift+Enter=newline */
|
||
inputEl.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendBtn.click();
|
||
}
|
||
});
|
||
|
||
newChatBtn.onclick = newChat;
|
||
|
||
clearBtn.onclick = () => {
|
||
if (!currentChatId || !chats[currentChatId]) return;
|
||
const chat = chats[currentChatId];
|
||
chat.messages = [];
|
||
chat.title = "New Chat";
|
||
chat.updatedAt = Date.now();
|
||
save();
|
||
renderChatList();
|
||
renderMessages();
|
||
};
|
||
|
||
/** ---------- 初始化 ---------- */
|
||
renderChatList();
|
||
renderMessages();
|