From 614f174c4a5bd34b82c331cb1015893f2bd1348e Mon Sep 17 00:00:00 2001 From: XianYuDaXian Date: Thu, 5 Mar 2026 23:01:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B9=B6=E5=85=A5=20chat=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E4=BF=9D=E5=AD=98=E4=B8=8E=E5=A4=9A=20agent=20?= =?UTF-8?q?=E6=80=9D=E8=80=83=20UI=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/public/css/chat.css | 257 ++++++++++++++++ app/static/public/js/chat.js | 474 +++++++++++++++++++++++++++++- app/static/public/pages/chat.html | 195 +++++++----- 3 files changed, 836 insertions(+), 90 deletions(-) diff --git a/app/static/public/css/chat.css b/app/static/public/css/chat.css index be0ac96..a699a53 100644 --- a/app/static/public/css/chat.css +++ b/app/static/public/css/chat.css @@ -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; + } +} diff --git a/app/static/public/js/chat.js b/app/static/public/js/chat.js index 739b6ef..e5e2b2b 100644 --- a/app/static/public/js/chat.js +++ b/app/static/public/js/chat.js @@ -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 = ''; const STOP_ICON = ''; + 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) - : `
${renderBasicMarkdown(section.lines.join('\\n').trim())}
`; + : `
${renderBasicMarkdown(section.lines.join('\n').trim())}
`; if (!section.title) { return `
${inner}
`; } @@ -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(); })(); diff --git a/app/static/public/pages/chat.html b/app/static/public/pages/chat.html index 017fd8e..71a9ef5 100644 --- a/app/static/public/pages/chat.html +++ b/app/static/public/pages/chat.html @@ -27,97 +27,136 @@
-
-
-
-

Chat 聊天

-

通过 chat 接口与 Grok 进行会话聊天

-
- 就绪 -
- -
-
输入一条消息开始对话。
-
- -
- -
- -
- + - -
-
-
- +
+
+
+ - -
-