feat: 并入 chat 会话保存与多 agent 思考 UI 优化

This commit is contained in:
XianYuDaXian committed 2026-03-10 06:55:23 +08:00
1 parent 944896e7eb
commit 614f174c4a
3 files changed
+836 -90

No files matched your search

+257
View File
@@ -1146,3 +1146,260 @@ html[data-theme='dark'] .model-option.active {
width: 92vw;
}
}
/* --- 会话侧栏扩展 --- */
.chat-layout {
display: flex;
gap: 6px;
align-items: flex-start;
}
.chat-sidebar {
width: 180px;
flex-shrink: 0;
border: none;
border-radius: 16px;
background: transparent;
display: flex;
flex-direction: column;
overflow: hidden;
height: calc(100vh - 56px - 64px);
position: sticky;
top: 56px;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
min-height: 26px;
}
.sidebar-title {
font-size: 12px;
font-weight: 600;
color: var(--chat-ink);
}
.sidebar-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.sidebar-action-btn {
border: none;
background: transparent;
color: var(--chat-ink);
font-size: 10px;
padding: 0;
cursor: pointer;
}
.sidebar-action-btn.icon-btn {
width: 24px;
height: 24px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.sidebar-action-btn.icon-btn:hover {
background: var(--chat-muted);
}
.sidebar-list {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 6px;
border-radius: 10px;
cursor: pointer;
}
.session-item:hover {
background: var(--chat-muted);
}
.session-item.active {
background: var(--chat-soft);
}
.session-title {
flex: 1;
min-width: 0;
font-size: 11px;
color: var(--chat-ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1;
}
.session-delete {
width: 22px;
height: 22px;
border: none;
background: transparent;
color: var(--accents-4);
border-radius: 6px;
cursor: pointer;
opacity: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.session-item:hover .session-delete {
opacity: 1;
}
.session-delete:hover {
color: #b91c1c;
background: rgba(185, 28, 28, 0.08);
}
.session-rename-input {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--chat-ink);
border: 1px solid var(--chat-border);
border-radius: 6px;
padding: 2px 6px;
background: var(--chat-surface);
outline: none;
}
.session-unread {
width: 8px;
height: 8px;
border-radius: 999px;
background: #22c55e;
}
.sidebar-overlay {
display: none;
}
.sidebar-toggle {
display: none;
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--chat-border);
background: var(--chat-surface);
color: var(--chat-ink);
align-items: center;
justify-content: center;
cursor: pointer;
}
.sidebar-expand-btn {
display: none;
align-self: flex-start;
border: 1px solid var(--chat-border);
background: var(--chat-surface);
color: var(--chat-ink);
padding: 0;
width: 32px;
height: 32px;
border-radius: 999px;
cursor: pointer;
margin-top: 6px;
margin-left: 0;
align-items: center;
justify-content: center;
position: sticky;
top: 56px;
z-index: 5;
}
.sidebar-expand-btn:hover {
border-color: #b8b8b8;
background: var(--chat-muted);
}
@media (min-width: 1025px) {
.chat-layout.collapsed {
gap: 8px;
}
.chat-layout.collapsed .chat-sidebar {
width: 0;
border: none;
padding: 0;
overflow: hidden;
}
.chat-layout.collapsed .chat-shell {
padding-left: 0;
}
.chat-layout.collapsed .sidebar-expand-btn {
display: inline-flex;
margin-left: 0;
margin-right: 8px;
}
}
.chat-shell {
flex: 1;
min-width: 0;
border-left: 1px solid var(--chat-border);
padding-left: 16px;
}
@media (max-width: 1024px) {
.chat-layout {
position: relative;
}
.chat-sidebar {
position: fixed;
top: 56px;
left: 0;
height: calc(100% - 56px);
transform: translateX(-110%);
transition: transform 0.2s ease;
z-index: 50;
}
.chat-sidebar.open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(17, 17, 17, 0.18);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 40;
}
.sidebar-overlay.open {
opacity: 1;
pointer-events: auto;
}
.sidebar-toggle {
display: inline-flex;
}
}
+462 -12
View File
@@ -20,16 +20,393 @@
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const fileBadge = document.getElementById('fileBadge');
const chatSidebar = document.getElementById('chatSidebar');
const sidebarOverlay = document.getElementById('sidebarOverlay');
const sidebarToggle = document.getElementById('sidebarToggle');
const newChatBtn = document.getElementById('newChatBtn');
const collapseSidebarBtn = document.getElementById('collapseSidebarBtn');
const sidebarExpandBtn = document.getElementById('sidebarExpandBtn');
const sessionListEl = document.getElementById('sessionList');
let messageHistory = [];
let isSending = false;
let abortController = null;
let attachments = [];
let availableModels = [];
let activeStreamInfo = null;
let sessionsData = null;
const feedbackUrl = 'https://github.com/chenyme/grok2api/issues/new';
const STORAGE_KEY = 'grok2api_chat_sessions';
const SIDEBAR_STATE_KEY = 'grok2api_chat_sidebar_collapsed';
const MAX_CONTEXT_MESSAGES = 30;
const DEFAULT_SESSION_TITLES = ['新会话', 'New Session'];
const SEND_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"></path><path d="M22 2L15 22L11 13L2 9L22 2Z"></path></svg>';
const STOP_ICON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"></rect></svg>';
function generateId() {
return crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2);
}
function isDefaultTitleValue(title) {
return DEFAULT_SESSION_TITLES.includes(title);
}
function getMessageDisplay(msg) {
if (!msg) return '';
if (typeof msg.content === 'string') return msg.content;
if (typeof msg.display === 'string' && msg.display.trim()) return msg.display;
if (Array.isArray(msg.content)) {
const textParts = [];
let fileCount = 0;
for (const block of msg.content) {
if (!block) continue;
if (block.type === 'text' && block.text) {
textParts.push(block.text);
}
if (block.type === 'file') {
fileCount += 1;
}
}
const suffix = fileCount > 0 ? `\n[文件] ${fileCount} 个` : '';
return `${textParts.join('\n')}${suffix}`.trim() || '(复合内容)';
}
return '(复合内容)';
}
function serializeMessage(msg) {
if (!msg || typeof msg !== 'object') return msg;
if (Array.isArray(msg.content)) {
return {
...msg,
content: getMessageDisplay(msg)
};
}
return msg;
}
function saveSessions() {
if (!sessionsData) return;
const snapshot = {
activeId: sessionsData.activeId,
sessions: sessionsData.sessions.map((session) => ({
...session,
messages: Array.isArray(session.messages) ? session.messages.map(serializeMessage) : []
}))
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch (e) {
toast('会话保存失败,可能是浏览器存储空间不足', 'error');
}
}
function getActiveSession() {
if (!sessionsData) return null;
return sessionsData.sessions.find((s) => s.id === sessionsData.activeId) || null;
}
function trimMessageHistory(maxCount = MAX_CONTEXT_MESSAGES) {
if (!maxCount || maxCount <= 0) return;
if (messageHistory.length <= maxCount) return;
messageHistory = messageHistory.slice(-maxCount);
const session = getActiveSession();
if (session) {
session.messages = messageHistory.slice();
session.updatedAt = Date.now();
saveSessions();
renderSessionList();
}
}
function restoreActiveSession() {
const session = getActiveSession();
if (!session) return;
messageHistory = Array.isArray(session.messages) ? session.messages.slice() : [];
trimMessageHistory();
if (chatLog) chatLog.innerHTML = '';
if (!messageHistory.length) {
showEmptyState();
return;
}
hideEmptyState();
for (const msg of messageHistory) {
const text = getMessageDisplay(msg);
const entry = createMessage(msg.role, text);
if (entry && msg.role === 'assistant') {
updateMessage(entry, text, true);
}
}
if (activeStreamInfo && activeStreamInfo.sessionId === session.id && activeStreamInfo.entry.row) {
chatLog.appendChild(activeStreamInfo.entry.row);
}
scrollToBottom();
}
function syncCurrentSession() {
const session = getActiveSession();
if (!session) return;
session.messages = messageHistory.slice();
session.updatedAt = Date.now();
}
function updateSessionTitle(session) {
if (!session || session.isDefaultTitle === false) return;
const firstUser = session.messages.find((m) => m.role === 'user');
if (!firstUser) return;
const text = getMessageDisplay(firstUser);
if (!text) return;
const title = text.replace(/\n/g, ' ').trim().slice(0, 20);
if (title) {
session.title = title;
session.isDefaultTitle = false;
}
}
function renameSession(id, newTitle) {
if (!sessionsData) return;
const session = sessionsData.sessions.find((s) => s.id === id);
if (!session) return;
const trimmed = (newTitle || '').trim();
session.title = trimmed || '新会话';
session.isDefaultTitle = !trimmed && isDefaultTitleValue(session.title);
session.updatedAt = Date.now();
saveSessions();
renderSessionList();
}
function startRenameSession(sessionId, titleSpan) {
if (!sessionsData) return;
const session = sessionsData.sessions.find((s) => s.id === sessionId);
if (!session) return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'session-rename-input';
input.value = session.title || '';
input.maxLength = 40;
titleSpan.replaceWith(input);
input.focus();
input.select();
const commit = () => renameSession(sessionId, input.value);
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
}
if (e.key === 'Escape') {
input.value = session.title || '新会话';
input.blur();
}
});
}
function syncSessionModel() {
const session = getActiveSession();
if (!session) return;
session.model = (modelSelect && modelSelect.value) || '';
}
function restoreSessionModel() {
const session = getActiveSession();
if (!session || !session.model || !Array.isArray(availableModels)) return;
if (availableModels.includes(session.model)) {
setModelValue(session.model);
}
}
function renderSessionList() {
if (!sessionListEl || !sessionsData) return;
sessionListEl.innerHTML = '';
for (const session of sessionsData.sessions) {
const item = document.createElement('div');
item.className = `session-item${session.id === sessionsData.activeId ? ' active' : ''}`;
item.dataset.id = session.id;
const titleSpan = document.createElement('span');
titleSpan.className = 'session-title';
titleSpan.textContent = session.title || '新会话';
titleSpan.title = titleSpan.textContent;
titleSpan.addEventListener('dblclick', (e) => {
e.stopPropagation();
startRenameSession(session.id, titleSpan);
});
item.appendChild(titleSpan);
if (session.unread && session.id !== sessionsData.activeId) {
const dot = document.createElement('span');
dot.className = 'session-unread';
item.appendChild(dot);
}
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'session-delete';
delBtn.title = '删除';
delBtn.textContent = '×';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
deleteSession(session.id);
});
item.appendChild(delBtn);
item.addEventListener('click', () => switchSession(session.id));
sessionListEl.appendChild(item);
}
}
function createSession() {
if (!sessionsData) return;
const id = generateId();
const session = {
id,
title: '新会话',
isDefaultTitle: true,
createdAt: Date.now(),
updatedAt: Date.now(),
messages: []
};
sessionsData.sessions.unshift(session);
sessionsData.activeId = id;
messageHistory = [];
if (chatLog) chatLog.innerHTML = '';
showEmptyState();
saveSessions();
renderSessionList();
if (isMobileSidebar()) closeSidebar();
}
function deleteSession(id) {
if (!sessionsData) return;
const idx = sessionsData.sessions.findIndex((s) => s.id === id);
if (idx === -1) return;
sessionsData.sessions.splice(idx, 1);
if (!sessionsData.sessions.length) {
createSession();
return;
}
if (sessionsData.activeId === id) {
const newIdx = Math.min(idx, sessionsData.sessions.length - 1);
sessionsData.activeId = sessionsData.sessions[newIdx].id;
restoreActiveSession();
restoreSessionModel();
}
saveSessions();
renderSessionList();
}
function switchSession(id) {
if (!sessionsData || sessionsData.activeId === id) return;
syncCurrentSession();
syncSessionModel();
sessionsData.activeId = id;
const target = getActiveSession();
if (target) target.unread = false;
restoreActiveSession();
restoreSessionModel();
saveSessions();
renderSessionList();
if (isMobileSidebar()) closeSidebar();
}
function isMobileSidebar() {
return window.matchMedia('(max-width: 1024px)').matches;
}
function setSidebarCollapsed(collapsed) {
const layout = chatSidebar ? chatSidebar.closest('.chat-layout') : null;
if (!layout) return;
layout.classList.toggle('collapsed', collapsed);
try {
localStorage.setItem(SIDEBAR_STATE_KEY, collapsed ? '1' : '0');
} catch (e) {
// ignore storage errors
}
}
function openSidebar() {
if (isMobileSidebar()) {
if (chatSidebar) chatSidebar.classList.add('open');
if (sidebarOverlay) sidebarOverlay.classList.add('open');
return;
}
setSidebarCollapsed(false);
}
function closeSidebar() {
if (isMobileSidebar()) {
if (chatSidebar) chatSidebar.classList.remove('open');
if (sidebarOverlay) sidebarOverlay.classList.remove('open');
return;
}
setSidebarCollapsed(true);
}
function toggleSidebar() {
if (isMobileSidebar()) {
if (chatSidebar && chatSidebar.classList.contains('open')) {
closeSidebar();
} else {
openSidebar();
}
return;
}
const layout = chatSidebar ? chatSidebar.closest('.chat-layout') : null;
if (!layout) return;
setSidebarCollapsed(!layout.classList.contains('collapsed'));
}
function restoreSidebarState() {
try {
const raw = localStorage.getItem(SIDEBAR_STATE_KEY);
setSidebarCollapsed(raw === '1');
} catch (e) {
// ignore storage errors
}
}
function loadSessions() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
sessionsData = JSON.parse(raw);
if (!sessionsData || !Array.isArray(sessionsData.sessions)) {
sessionsData = null;
}
}
} catch (e) {
sessionsData = null;
}
if (!sessionsData || !sessionsData.sessions.length) {
const id = generateId();
sessionsData = {
activeId: id,
sessions: [{
id,
title: '新会话',
isDefaultTitle: true,
createdAt: Date.now(),
updatedAt: Date.now(),
messages: []
}]
};
saveSessions();
}
sessionsData.sessions.forEach((session) => {
if (session && typeof session.isDefaultTitle === 'undefined') {
session.isDefaultTitle = isDefaultTitleValue(session.title);
}
if (!Array.isArray(session.messages)) {
session.messages = [];
}
});
if (!sessionsData.activeId || !sessionsData.sessions.find((s) => s.id === sessionsData.activeId)) {
sessionsData.activeId = sessionsData.sessions[0].id;
}
restoreActiveSession();
restoreSessionModel();
renderSessionList();
}
function toast(message, type) {
if (typeof showToast === 'function') {
showToast(message, type);
@@ -382,7 +759,7 @@
current.lines.push(line);
continue;
}
const agentMatch = trimmed.match(/^(Grok\\s+Leader|Agent\\s*\\d+|Grok\\s+Agent\\s*\\d+)$/i);
const agentMatch = trimmed.match(/^(Grok\s+Leader|(?:Grok\s+)?Agent\s*\d+)$/i);
if (agentMatch) {
hasAgentHeading = true;
if (current.lines.length) {
@@ -438,7 +815,7 @@
const blocks = parseRolloutBlocks(section.lines.join('\n'));
const inner = blocks.length
? renderGroups(blocks, openAll)
: `<div class="think-rollout-body">${renderBasicMarkdown(section.lines.join('\\n').trim())}</div>`;
: `<div class="think-rollout-body">${renderBasicMarkdown(section.lines.join('\n').trim())}</div>`;
if (!section.title) {
return `<div class="think-agent-items">${inner}</div>`;
}
@@ -836,6 +1213,11 @@
node.classList.toggle('active', node.dataset.value === modelId);
});
}
if (sessionsData) {
syncSessionModel();
saveSessions();
renderSessionList();
}
}
function renderModelOptions(models) {
@@ -904,6 +1286,7 @@
} else {
setModelValue(list[list.length - 1] || preferred);
}
restoreSessionModel();
}
function showAttachmentBadge() {
@@ -1171,6 +1554,7 @@
return;
}
const historySlice = messageHistory.slice(0, lastUserIndex + 1);
const retrySessionId = sessionsData ? sessionsData.activeId : null;
const assistantEntry = createMessage('assistant', '');
setSendingState(true);
setStatus('connecting', '发送中');
@@ -1198,14 +1582,18 @@
throw new Error(`请求失败: ${res.status}`);
}
await handleStream(res, assistantEntry);
await handleStream(res, assistantEntry, retrySessionId);
setStatus('connected', '完成');
} catch (e) {
if (e && e.name === 'AbortError') {
updateMessage(assistantEntry, assistantEntry.raw || '已中止', true);
if (!assistantEntry.committed) {
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '' });
assistantEntry.committed = true;
if (retrySessionId) {
commitToSession(retrySessionId, assistantEntry.raw || '');
} else {
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '' });
}
}
setStatus('error', '已中止');
} else {
@@ -1245,9 +1633,16 @@
}
messageHistory.push({ role: 'user', content });
trimMessageHistory();
if (promptInput) promptInput.value = '';
clearAttachment();
syncCurrentSession();
syncSessionModel();
updateSessionTitle(getActiveSession());
saveSessions();
renderSessionList();
const sendSessionId = sessionsData ? sessionsData.activeId : null;
const assistantEntry = createMessage('assistant', '');
setSendingState(true);
setStatus('connecting', '发送中');
@@ -1275,7 +1670,7 @@
throw new Error(`请求失败: ${res.status}`);
}
await handleStream(res, assistantEntry);
await handleStream(res, assistantEntry, sendSessionId);
setStatus('connected', '完成');
} catch (e) {
if (e && e.name === 'AbortError') {
@@ -1286,8 +1681,12 @@
}
setStatus('error', '已中止');
if (!assistantEntry.committed) {
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '' });
assistantEntry.committed = true;
if (sendSessionId) {
commitToSession(sendSessionId, assistantEntry.raw || '');
} else {
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '' });
}
}
} else {
updateMessage(assistantEntry, `请求失败: ${e.message || e}`, true);
@@ -1301,12 +1700,33 @@
}
}
async function handleStream(res, assistantEntry) {
function commitToSession(sessionId, assistantText) {
if (!sessionId || !sessionsData) return;
const session = sessionsData.sessions.find((s) => s.id === sessionId);
if (!session) return;
session.messages.push({ role: 'assistant', content: assistantText });
if (session.messages.length > MAX_CONTEXT_MESSAGES) {
session.messages = session.messages.slice(-MAX_CONTEXT_MESSAGES);
}
session.updatedAt = Date.now();
updateSessionTitle(session);
if (sessionsData.activeId === sessionId) {
messageHistory = session.messages.slice();
trimMessageHistory();
} else {
session.unread = true;
}
saveSessions();
renderSessionList();
}
async function handleStream(res, assistantEntry, targetSessionId = null) {
activeStreamInfo = { sessionId: targetSessionId, entry: assistantEntry };
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let assistantText = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
@@ -1326,8 +1746,12 @@
const elapsed = assistantEntry.thinkElapsed || Math.max(1, Math.round((Date.now() - assistantEntry.startedAt) / 1000));
updateThinkSummary(assistantEntry, elapsed);
}
messageHistory.push({ role: 'assistant', content: assistantText });
assistantEntry.committed = true;
if (targetSessionId) {
commitToSession(targetSessionId, assistantText);
} else {
messageHistory.push({ role: 'assistant', content: assistantText });
}
return;
}
try {
@@ -1345,7 +1769,9 @@
assistantEntry.thinkElapsed = null;
updateThinkSummary(assistantEntry, null);
}
updateMessage(assistantEntry, assistantText, false);
if (!targetSessionId || (sessionsData && sessionsData.activeId === targetSessionId)) {
updateMessage(assistantEntry, assistantText, false);
}
}
} catch (e) {
// ignore parse errors
@@ -1358,8 +1784,15 @@
const elapsed = assistantEntry.thinkElapsed || Math.max(1, Math.round((Date.now() - assistantEntry.startedAt) / 1000));
updateThinkSummary(assistantEntry, elapsed);
}
messageHistory.push({ role: 'assistant', content: assistantText });
assistantEntry.committed = true;
if (targetSessionId) {
commitToSession(targetSessionId, assistantText);
} else {
messageHistory.push({ role: 'assistant', content: assistantText });
}
} finally {
activeStreamInfo = null;
}
}
function toggleSettings(show) {
@@ -1486,6 +1919,21 @@
openAttachmentPreview(item.data, item.name);
});
}
if (newChatBtn) {
newChatBtn.addEventListener('click', createSession);
}
if (collapseSidebarBtn) {
collapseSidebarBtn.addEventListener('click', toggleSidebar);
}
if (sidebarExpandBtn) {
sidebarExpandBtn.addEventListener('click', openSidebar);
}
if (sidebarToggle) {
sidebarToggle.addEventListener('click', toggleSidebar);
}
if (sidebarOverlay) {
sidebarOverlay.addEventListener('click', closeSidebar);
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
@@ -1558,8 +2006,10 @@
updateRangeValues();
setSendingState(false);
loadModels();
bindEvents();
restoreSidebarState();
loadSessions();
loadModels();
})();
+117 -78
View File
@@ -27,97 +27,136 @@
<div id="app-header"></div>
<main class="flex-1 container mx-auto max-w-5xl px-6 py-8 fade-in chat-main">
<div class="chat-shell">
<div class="chat-title">
<div>
<h2 class="chat-title-text">Chat 聊天</h2>
<p class="chat-subtitle">通过 chat 接口与 Grok 进行会话聊天</p>
</div>
<span id="statusText" class="status-text">就绪</span>
</div>
<div id="chatLog" class="chat-thread">
<div id="emptyState" class="chat-empty">输入一条消息开始对话。</div>
</div>
<div class="composer-shell">
<input id="fileInput" class="file-input" type="file" multiple />
<div class="composer-meta">
<div id="fileBadge" class="file-badge hidden"></div>
<div id="modelPicker" class="model-chip model-picker">
<button id="modelPickerBtn" class="model-picker-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
<span id="modelPickerLabel" class="model-picker-label">加载中...</span>
<svg class="model-picker-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
<div class="chat-layout">
<aside class="chat-sidebar" id="chatSidebar">
<div class="sidebar-header">
<span class="sidebar-title">会话</span>
<div class="sidebar-actions">
<button id="newChatBtn" class="sidebar-action-btn icon-btn" type="button" title="新增">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button id="collapseSidebarBtn" class="sidebar-action-btn icon-btn" type="button" title="折叠">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 6 9 12 15 18"></polyline>
</svg>
</button>
<div id="modelPickerMenu" class="model-picker-menu hidden" role="listbox"></div>
<select id="modelSelect" class="model-select-native" aria-hidden="true" tabindex="-1"></select>
</div>
</div>
<div class="composer-bottom">
<div class="composer-input">
<button id="attachBtn" class="icon-btn" type="button" title="上传文件">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.9-9.9a3.5 3.5 0 0 1 4.95 4.95l-9.9 9.9a2 2 0 0 1-2.83-2.83l8.49-8.49"></path>
<div class="sidebar-list" id="sessionList"></div>
</aside>
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<button id="sidebarExpandBtn" class="sidebar-expand-btn icon-btn" type="button" title="展开">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 6 15 12 9 18"></polyline>
</svg>
</button>
<div class="chat-shell">
<div class="chat-title">
<div style="display:flex;align-items:center;gap:10px;">
<button id="sidebarToggle" class="sidebar-toggle" type="button" title="会话列表">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<textarea id="promptInput" class="composer-textarea" placeholder="询问任何内容"></textarea>
<div class="composer-actions">
<div class="popover hidden">
<button id="settingsToggle" class="icon-btn" type="button" title="设置">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01A1.65 1.65 0 0 0 9 4.6V4a2 2 0 0 1 4 0v.09c0 .69.4 1.31 1.02 1.58.52.23 1.12.2 1.6-.07l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06c-.45.45-.58 1.12-.33 1.72.25.6.82 1 1.51 1H21a2 2 0 0 1 0 4h-.09c-.69 0-1.31.4-1.51 1Z">
</path>
</svg>
</button>
<div id="settingsPanel" class="settings-panel hidden">
<div class="settings-grid">
<div class="settings-block">
<label class="field-label" for="reasoningSelect">推理强度</label>
<select id="reasoningSelect" class="geist-input">
<option value="">跟随配置</option>
<option value="none">关闭</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">XHigh</option>
</select>
</div>
<div class="settings-block">
<label class="field-label" for="tempRange">温度</label>
<div class="range-row">
<input id="tempRange" type="range" min="0" max="2" step="0.05" value="0.8">
<span id="tempValue" class="range-value">0.8</span>
<div>
<h2 class="chat-title-text">Chat 聊天</h2>
<p class="chat-subtitle">通过 chat 接口与 Grok 进行会话聊天</p>
</div>
</div>
<span id="statusText" class="status-text">就绪</span>
</div>
<div id="chatLog" class="chat-thread">
<div id="emptyState" class="chat-empty">输入一条消息开始对话。</div>
</div>
<div class="composer-shell">
<input id="fileInput" class="file-input" type="file" multiple />
<div class="composer-meta">
<div id="fileBadge" class="file-badge hidden"></div>
<div id="modelPicker" class="model-chip model-picker">
<button id="modelPickerBtn" class="model-picker-btn" type="button" aria-haspopup="listbox" aria-expanded="false">
<span id="modelPickerLabel" class="model-picker-label">加载中...</span>
<svg class="model-picker-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div id="modelPickerMenu" class="model-picker-menu hidden" role="listbox"></div>
<select id="modelSelect" class="model-select-native" aria-hidden="true" tabindex="-1"></select>
</div>
</div>
<div class="composer-bottom">
<div class="composer-input">
<button id="attachBtn" class="icon-btn" type="button" title="上传文件">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.9-9.9a3.5 3.5 0 0 1 4.95 4.95l-9.9 9.9a2 2 0 0 1-2.83-2.83l8.49-8.49"></path>
</svg>
</button>
<textarea id="promptInput" class="composer-textarea" placeholder="询问任何内容"></textarea>
<div class="composer-actions">
<div class="popover hidden">
<button id="settingsToggle" class="icon-btn" type="button" title="设置">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01A1.65 1.65 0 0 0 9 4.6V4a2 2 0 0 1 4 0v.09c0 .69.4 1.31 1.02 1.58.52.23 1.12.2 1.6-.07l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06c-.45.45-.58 1.12-.33 1.72.25.6.82 1 1.51 1H21a2 2 0 0 1 0 4h-.09c-.69 0-1.31.4-1.51 1Z">
</path>
</svg>
</button>
<div id="settingsPanel" class="settings-panel hidden">
<div class="settings-grid">
<div class="settings-block">
<label class="field-label" for="reasoningSelect">推理强度</label>
<select id="reasoningSelect" class="geist-input">
<option value="">跟随配置</option>
<option value="none">关闭</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">XHigh</option>
</select>
</div>
</div>
<div class="settings-block">
<label class="field-label" for="topPRange">Top P</label>
<div class="range-row">
<input id="topPRange" type="range" min="0" max="1" step="0.05" value="0.95">
<span id="topPValue" class="range-value">0.95</span>
<div class="settings-block">
<label class="field-label" for="tempRange">温度</label>
<div class="range-row">
<input id="tempRange" type="range" min="0" max="2" step="0.05" value="0.8">
<span id="tempValue" class="range-value">0.8</span>
</div>
</div>
<div class="settings-block">
<label class="field-label" for="topPRange">Top P</label>
<div class="range-row">
<input id="topPRange" type="range" min="0" max="1" step="0.05" value="0.95">
<span id="topPValue" class="range-value">0.95</span>
</div>
</div>
<div class="settings-block settings-block-full">
<label class="field-label" for="systemInput">System Prompt</label>
<textarea id="systemInput" class="geist-input chat-input" placeholder="可选,设置系统提示词"></textarea>
</div>
</div>
<div class="settings-block settings-block-full">
<label class="field-label" for="systemInput">System Prompt</label>
<textarea id="systemInput" class="geist-input chat-input" placeholder="可选,设置系统提示词"></textarea>
</div>
</div>
</div>
<button id="sendBtn" class="send-btn" type="button" aria-label="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 2L11 13"></path>
<path d="M22 2L15 22L11 13L2 9L22 2Z"></path>
</svg>
</button>
</div>
<button id="sendBtn" class="send-btn" type="button" aria-label="Send">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 2L11 13"></path>
<path d="M22 2L15 22L11 13L2 9L22 2Z"></path>
</svg>
</button>
</div>
</div>
</div>