feat: 并入 chat 会话保存与多 agent 思考 UI 优化
This commit is contained in:
1 parent
944896e7eb
commit
614f174c4a
3 files changed
+836
-90
No files matched your search
@@ -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
@@ -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();
|
||||
})();
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user