diff --git a/tests/unit/test_chat_web.py b/tests/unit/test_chat_web.py new file mode 100644 index 0000000..2ae16e6 --- /dev/null +++ b/tests/unit/test_chat_web.py @@ -0,0 +1,110 @@ +"""Offline Chromium tests of real Hope assets; no NAS or live provider.""" +from pathlib import Path +from urllib.parse import urlparse +from playwright.sync_api import sync_playwright, expect + +ROOT = Path(__file__).resolve().parents[2] + + +def fixture(page, replay=False): + calls, streams = [], [] + conversation = dict(id='c1', title='A saved thought', workspace_id=None, pinned=False, revision=1, archived=False) + original = dict(id='m1', role='user', content='Original question', status='completed') + conversations, messages = {'c1': conversation}, {'c1': [original]} + preferences = dict(palette='opal', appearance='light', revision=1) + def route(r): + path, method = urlparse(r.request.url).path, r.request.method + body = r.request.post_data_json if r.request.post_data else {} + calls.append((method, path, body)) + if path == '/api/auth/me': data = {'user': dict(id='alice', username='alice', role='user', status='ready')} + elif path == '/voices': data = {'voices': []} + elif path == '/api/capabilities': data = {'version':1,'features':{}} + elif path == '/api/preferences': + if method == 'PUT': preferences.update(body); preferences['revision'] += 1 + data = {'preferences': preferences} + elif path == '/api/workspaces': data = {'workspaces': []} + elif path == '/api/conversations': + if method == 'POST': + conversations['c2'] = dict(conversation, id='c2', title=body['title']); messages['c2'] = [] + data = {'conversation': conversations['c2']} + else: data = {'conversations': list(conversations.values()), 'next_cursor': None} + elif path.endswith('/events'): + streams.append(r.request.url) + message = 'id: 1\nevent: message\ndata: {"message":{"id":"a1","role":"assistant","content":"## Answer\\n**Safe** and `clear`.\\n\\n[bad](javascript:alert(1))","status":"completed"}}\n\n' + done = 'id: 2\nevent: done\ndata: {"run":{"id":"r1","status":"completed"}}\n\n' + r.fulfill(body=message if replay and len(streams) == 1 else message + done, content_type='text/event-stream'); return + elif path.endswith('/messages'): + cid = path.split('/')[3]; message = dict(id='u2', role='user', content=body['content'], status='completed'); messages[cid].append(message) + data = {'message':message,'run':{'id':'r1','status':'running'}} + elif path.endswith('/branch') and method == 'POST': + conversations['branch'] = dict(conversation, id='branch', title='New branch'); messages['branch'] = [] + data = {'conversation':conversations['branch']} + elif path.startswith('/api/conversations/'): + cid = path.split('/')[3] + if method == 'DELETE': del conversations[cid]; data = {'ok':True} + elif method == 'PATCH': conversations[cid].update(body); conversations[cid]['revision'] += 1; data = {'conversation':conversations[cid]} + else: data = {'conversation':conversations[cid],'messages':messages[cid],'runs':[],'next_cursor':None} + elif path == '/api/auth/logout': data = {'ok':True} + else: + asset = ROOT / 'web' / (path.lstrip('/') or 'index.html') + if asset.is_file(): + r.fulfill(body=asset.read_bytes(), content_type={'.html':'text/html','.css':'text/css','.js':'application/javascript','.svg':'image/svg+xml'}.get(asset.suffix,'application/octet-stream')); return + r.fulfill(status=404,json={'error':'Unavailable'}); return + r.fulfill(json=data) + page.route('https://hope.test/**', route) + return calls, streams, conversations, messages + + +def test_chat_crud_replay_safe_markdown_and_preserved_branches(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={'width':1280,'height':900}) + errors=[]; page.on('pageerror',lambda error: errors.append(str(error))) + calls, streams, conversations, messages = fixture(page, replay=True) + page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') + page.get_by_role('button',name='A saved thought',exact=True).click() + expect(page.locator('#chatTimeline')).to_contain_text('Original question') + page.locator('#pinConversation').click(); expect(page.locator('#pinConversation')).to_have_text('Unpin') + page.locator('#primaryInput').fill('A new question'); page.locator('#primarySend').click() + expect(page.locator('#chatTimeline')).to_contain_text('Answer') + expect(page.locator('#primaryInput')).to_be_enabled(timeout=10000) + assert len(streams) == 2 and streams[-1].endswith('after=1') + assert page.locator('[data-message-id="a1"]').count() == 1 + assert page.locator('#chatTimeline script').count() == 0 + assert page.locator('#chatTimeline a[href^="javascript:"]').count() == 0 + assert page.evaluate('window.injected') is None + assert not any(path == '/token' for _,path,_ in calls) + page.get_by_role('button',name='Edit in new branch').first.click() + page.locator('#chatActionValue').fill('Revised question'); page.locator('#chatActionSave').click() + expect(page.locator('#chatTitle')).to_have_text('New branch') + expect(page.locator('#chatTimeline')).to_contain_text('Revised question') + assert messages['c1'][0]['content'] == 'Original question' + page.locator('#openPreferences').click(); page.locator('#paletteSelect').select_option('ocean') + page.get_by_role('button',name='Save appearance').click(); expect(page.locator('html')).to_have_attribute('data-palette','ocean') + page.locator('#closePreferences').click(); page.screenshot(path='/tmp/hope-chat-desktop.png') + page.locator('#deleteConversation').click(); page.locator('#chatActionSave').click() + expect(page.locator('#chatTitle')).to_have_text('New chat'); assert 'branch' not in conversations + assert errors == [] + browser.close() + + +def test_mobile_drawer_keyboard_voice_and_logout(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={'width':390,'height':844}); fixture(page) + page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') + page.locator('#drawerOpen').click(); expect(page.locator('#drawerOpen')).to_have_attribute('aria-expanded','true') + page.keyboard.press('Escape'); expect(page.locator('#drawerOpen')).to_be_focused() + page.locator('#drawerOpen').click(); page.locator('#openVoice').click() + expect(page.locator('#startBtn')).to_be_visible(); expect(page.locator('#settingsBtn')).to_be_visible() + page.locator('#backToChat').click(); expect(page.locator('#primaryChat')).to_be_visible() + page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true}}))") + expect(page.locator('#primaryInput')).to_be_disabled() + page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:false}}))") + expect(page.locator('#primaryInput')).to_be_enabled() + page.screenshot(path='/tmp/hope-chat-mobile.png') + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.locator('#drawerOpen').click(); page.locator('#openVoice').click(); page.locator('#logoutBtn').click() + expect(page.locator('#loginGate')).to_be_visible() + assert 'Original question' not in page.locator('#chatTimeline').inner_text() + browser.close() diff --git a/web/chat.js b/web/chat.js new file mode 100644 index 0000000..7313be3 --- /dev/null +++ b/web/chat.js @@ -0,0 +1,178 @@ +// Durable HTTP chat. Deliberately independent of the LiveKit composer. +(() => { + 'use strict'; + const $ = id => document.getElementById(id), api = (path, method = 'GET', body, signal) => HopeAuth.request(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal}); + const state = {conversation: null, messages: [], run: null, workspaces: [], preferences: null, epoch: 0, stream: null, cursor: null, messageCursor: null, workspace: null, busy: false, voiceActive: false, pending: null}; + let searchTimer, listVersion = 0, action; + const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status); + const notice = text => { $('chatNotice').textContent = text; }; + const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => Promise.resolve().then(fn).catch(error => notice(error.message))); return b; }; + function controls() { + const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || state.voiceActive; + $('primaryInput').disabled = !ready || state.busy || running; + $('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim(); + $('cancelRun').hidden = terminal(state.run); + for (const id of ['pinConversation', 'renameConversation', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy; + $('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin'; + $('chatTitle').textContent = state.conversation?.title || 'New chat'; + } + function selected(conversation) { + state.conversation = conversation; + window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}})); + controls(); + } + window.addEventListener('hope:voice-state', event => {state.voiceActive = !!event.detail?.active; controls(); if (state.voiceActive) notice('Voice is active. End the voice conversation before sending text.'); else notice('');}); + window.addEventListener('hope:attachments-changed', controls); + window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}}; + function stopStream() { state.stream?.abort(); state.stream = null; } + function drawer(open) { + $('chatSidebar').classList.toggle('drawer-open', open); $('drawerBackdrop').hidden = !open; + $('drawerOpen').setAttribute('aria-expanded', String(open)); + if (matchMedia('(max-width: 760px)').matches) { $('primaryChat').inert = open; $('chatSidebar').inert = !open; } + (open ? $('drawerClose') : $('drawerOpen')).focus(); + } + $('drawerOpen').onclick = () => drawer(true); $('drawerClose').onclick = $('drawerBackdrop').onclick = () => drawer(false); + document.addEventListener('keydown', event => { if (event.key === 'Escape' && $('chatSidebar').classList.contains('drawer-open')) drawer(false); if ((event.metaKey || event.ctrlKey) && event.key === 'k') {event.preventDefault(); drawer(true); $('conversationSearch').focus();} }); + const mobile = matchMedia('(max-width: 760px)'); + function adapt() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('drawerBackdrop').hidden = true; } } + mobile.addEventListener('change', adapt); adapt(); + $('openVoice').onclick = () => { $('primaryChat').hidden = true; $('legacyVoice').hidden = false; if (mobile.matches) drawer(false); $('backToChat').focus(); }; + $('backToChat').onclick = () => { $('primaryChat').hidden = false; $('legacyVoice').hidden = true; $('primaryInput').focus(); }; + // Markdown uses DOM text nodes only: HTML is always literal; unsafe URLs never become links. + function inline(parent, text) { + const expression = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^\s)]+\))/g; + let last = 0; + for (const match of text.matchAll(expression)) { + parent.append(document.createTextNode(text.slice(last, match.index))); const token = match[0]; let node; + if (token.startsWith('`')) {node = document.createElement('code'); node.textContent = token.slice(1, -1);} + else if (token.startsWith('**')) {node = document.createElement('strong'); node.textContent = token.slice(2, -2);} + else if (token.startsWith('*')) {node = document.createElement('em'); node.textContent = token.slice(1, -1);} + else {const parts = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); let url; try {url = new URL(parts[2]);} catch {} if (url && ['https:', 'http:', 'mailto:'].includes(url.protocol)) {node = document.createElement('a'); node.href = url.href; node.rel = 'noopener noreferrer'; node.textContent = parts[1];} else node = document.createTextNode(token);} + parent.append(node); last = match.index + token.length; + } + parent.append(document.createTextNode(text.slice(last))); + } + function markdown(text) { + const root = document.createElement('div'); root.className = 'markdown'; let code = null, list = null; + for (const line of String(text || '').split('\n')) { + if (line.startsWith('```')) { if (code) {code = null;} else {const pre = document.createElement('pre'); code = document.createElement('code'); pre.append(code); root.append(pre);} list = null; continue; } + if (code) {code.append(document.createTextNode(line + '\n')); continue;} + const item = /^(?:[-*] |\d+\. )(.*)$/.exec(line); + if (item) {const type = /^\d/.test(line) ? 'ol' : 'ul'; if (!list || list.tagName.toLowerCase() !== type) {list = document.createElement(type); root.append(list);} const li = document.createElement('li'); inline(li, item[1]); list.append(li); continue;} + list = null; if (!line.trim()) continue; + const heading = /^(#{1,6}) (.*)$/.exec(line); const node = document.createElement(heading ? 'h' + Math.min(heading[1].length + 1, 6) : line.startsWith('> ') ? 'blockquote' : 'p'); inline(node, heading ? heading[2] : line.replace(/^> /, '')); root.append(node); + } + return root; + } + function render() { + const timeline = $('chatTimeline'), atBottom = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 100, scroll = timeline.scrollTop; + timeline.replaceChildren(); + if (state.messageCursor) timeline.append(button('Load earlier messages', olderMessages)); + if (!state.messages.length) { const welcome = document.createElement('div'); welcome.className = 'chat-welcome'; const h = document.createElement('h2'); h.textContent = 'A little space to think.'; const p = document.createElement('p'); p.textContent = 'Ask a question, work through an idea, or start something new.'; welcome.append(h, p); timeline.append(welcome); } + for (const message of state.messages) { + const row = document.createElement('article'); row.className = `chat-message ${message.role}`; row.dataset.messageId = message.id; + const name = document.createElement('h2'); name.textContent = message.role === 'user' ? 'You' : 'Hope'; row.append(name, markdown(message.content)); + const actions = document.createElement('div'); actions.className = 'message-actions'; + actions.append(button('Copy', () => navigator.clipboard.writeText(message.content))); + if (terminal(state.run)) { + if (message.role === 'user') actions.append(button('Edit in new branch', () => edit(message))); + else {actions.append(button('Branch from here', () => branch(message))); const index = state.messages.indexOf(message); const prior = state.messages.slice(0, index).reverse().find(m => m.role === 'user'); if (prior) actions.append(button('Regenerate', () => branch(prior, prior.content, true)));} + } + if (['failed', 'cancelled'].includes(message.status)) {const status = document.createElement('span'); status.textContent = message.status === 'failed' ? 'Response failed' : 'Response stopped'; actions.append(status);} + row.append(actions); timeline.append(row); + } + timeline.scrollTop = atBottom ? timeline.scrollHeight : scroll; controls(); + } + async function list(append = false) { + const version = ++listVersion, epoch = state.epoch; + const params = new URLSearchParams({q: $('conversationSearch').value, archived: 'false', limit: '50'}); + if (state.workspace) params.set('workspace_id', state.workspace); if (append && state.cursor) params.set('cursor', state.cursor); + const data = await api('/api/conversations?' + params); if (version !== listVersion || epoch !== state.epoch) return; + if (!append) { $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); } + for (const conversation of data.conversations) {const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false'); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(b);} + state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor; + } + async function open(id) { + stopStream(); const epoch = ++state.epoch; state.run = null; state.messages = []; state.pending = null; notice('Loading conversation…'); controls(); + const data = await api('/api/conversations/' + enc(id)); if (epoch !== state.epoch) return; + selected(data.conversation); state.messages = data.messages || []; state.messageCursor = data.next_cursor; state.run = (data.runs || []).find(r => !terminal(r)) || null; render(); notice(''); if (mobile.matches) drawer(false); if (state.run) stream(state.run); await list(); + } + async function olderMessages() {const epoch = state.epoch, timeline = $('chatTimeline'), height = timeline.scrollHeight; const data = await api('/api/conversations/' + enc(state.conversation.id) + '?' + new URLSearchParams({cursor: state.messageCursor, limit: '50'})); if (epoch !== state.epoch) return; const ids = new Set(state.messages.map(m => m.id)); state.messages = (data.messages || []).filter(m => !ids.has(m.id)).concat(state.messages); state.messageCursor = data.next_cursor; render(); timeline.scrollTop += timeline.scrollHeight - height;} + function reset() { stopStream(); ++state.epoch; state.messages = []; state.messageCursor = null; state.run = null; state.pending = null; state.busy = false; selected(null); render(); notice(''); $('primaryInput').value = ''; controls(); } + $('newConversation').onclick = () => {reset(); if (mobile.matches) drawer(false); $('primaryInput').focus();}; + $('conversationSearch').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => list().catch(e => notice(e.message)), 200); }; + $('loadMoreConversations').onclick = () => list(true).catch(e => notice(e.message)); + async function update(patch) {const epoch = state.epoch; const data = await api('/api/conversations/' + enc(state.conversation.id), 'PATCH', {revision: state.conversation.revision, ...patch}); if (epoch !== state.epoch) return; selected(data.conversation); await list();} + $('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message)); + function dialog(title, value, fn, description = '', needsValue = true) { + action = fn; $('chatActionTitle').textContent = title; $('chatActionDescription').textContent = description; $('chatActionValue').value = value; $('chatActionValue').hidden = !needsValue; $('chatActionLabel').hidden = !needsValue; $('chatActionValue').required = needsValue; $('chatActionError').textContent = ''; $('chatActionDialog').showModal(); + } + $('chatActionCancel').onclick = () => $('chatActionDialog').close(); + $('chatActionForm').onsubmit = async event => {event.preventDefault(); $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); $('chatActionDialog').close();} catch (e) {$('chatActionError').textContent = e.message;} finally {$('chatActionSave').disabled = false;} }; + $('renameConversation').onclick = () => dialog('Rename conversation', state.conversation.title, title => update({title})); + $('archiveConversation').onclick = () => dialog('Archive conversation?', '', async () => {await update({archived: true}); reset(); await list();}, 'The conversation is retained, but leaves Recent.', false); + $('deleteConversation').onclick = () => dialog('Delete conversation?', '', async () => {await api('/api/conversations/' + enc(state.conversation.id), 'DELETE'); reset(); await list();}, 'This deletes the conversation, not your account memory.', false); + async function branch(message, content, send = false) {const data = await api('/api/conversations/' + enc(state.conversation.id) + '/branch', 'POST', {message_id: message.id, ...(content === undefined ? {} : {content})}); await open(data.conversation.id); if (send) {$('primaryInput').value = content; await submit();} } + function edit(message) {dialog('Edit in a new branch', message.content, content => branch(message, content, true), 'Your original conversation stays unchanged.');} + function upsert(message) {const index = state.messages.findIndex(m => m.id === message.id); if (index < 0) state.messages.push(message); else state.messages[index] = message;} + async function submit() { + const content = $('primaryInput').value.trim(); if (!content || window.HopeFeatures?.hasPendingUploads() || state.voiceActive || state.busy || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return; + state.busy = true; controls(); const epoch = state.epoch; + try { + if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: content.slice(0, 80), ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation);} + // Retry an uncertain POST with the same key rather than creating another turn. + const attachmentIDs = window.HopeFeatures?.attachmentIDs() || []; + if (!state.pending || state.pending.content !== content || JSON.stringify(state.pending.attachment_ids) !== JSON.stringify(attachmentIDs)) state.pending = {content, idempotency_key: crypto.randomUUID(), attachment_ids: attachmentIDs}; + const data = await api('/api/conversations/' + enc(state.conversation.id) + '/messages', 'POST', state.pending); if (epoch !== state.epoch) return; + window.HopeFeatures?.clearAfterSend(state.pending.attachment_ids); state.pending = null; $('primaryInput').value = ''; upsert(data.message); state.run = data.run; render(); notice(''); stream(data.run); await list(); + } catch (e) {if (epoch === state.epoch) notice(e.message + ' You can retry your message.');} finally {state.busy = false; controls();} + } + $('primaryComposer').onsubmit = event => {event.preventDefault(); submit();}; $('primaryInput').oninput = controls; + $('primaryInput').onkeydown = event => {if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {event.preventDefault(); submit();}}; + async function stream(run) { + stopStream(); const controller = new AbortController(); state.stream = controller; const epoch = state.epoch; let after = 0, retries = 0; + try { + while (!controller.signal.aborted && epoch === state.epoch) { + try { + const response = await fetch('/api/runs/' + enc(run.id) + '/events?after=' + after, {credentials: 'same-origin', signal: controller.signal, headers: {Accept: 'text/event-stream'}}); + if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Response stream unavailable (' + response.status + ').'); + const reader = response.body.getReader(), decoder = new TextDecoder(); let buffer = '', done = false; + while (!done) {const part = await reader.read(); buffer += decoder.decode(part.value || new Uint8Array(), {stream: !part.done}).replace(/\r\n/g, '\n'); let boundary; + while ((boundary = buffer.indexOf('\n\n')) >= 0) {const block = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); let type = 'message', id = 0, raw = []; + for (const line of block.split('\n')) {if (line.startsWith('event:')) type = line.slice(6).trim(); else if (line.startsWith('id:')) id = Number(line.slice(3)); else if (line.startsWith('data:')) raw.push(line.slice(5).trimStart());} + if (!raw.length || (id && id <= after)) continue; const data = JSON.parse(raw.join('\n')); if (controller.signal.aborted || epoch !== state.epoch) return; if (id) after = id; + if (type === 'message') {upsert(data.message || data); render();} + if (type === 'run' || type === 'done') {state.run = data.run || data; controls();} + if (type === 'progress') $('runProgress').textContent = data.message || ''; + if (type === 'approval') approval(data, run.id); + if (type === 'done') {done = true; await reader.cancel(); break;} + } + if (part.done) done = true; + } + if (terminal(state.run)) break; + retries = 0; + } catch (error) {if (controller.signal.aborted) return; if (++retries >= 3) {notice('Connection interrupted. Reopen this conversation to resume the response.'); break;}} + await new Promise(resolve => {const timer = setTimeout(resolve, 1000); controller.signal.addEventListener('abort', () => {clearTimeout(timer); resolve();}, {once: true});}); + } + } finally {if (state.stream === controller) {state.stream = null; $('runProgress').replaceChildren(); if (state.run?.error) notice(state.run.error); controls(); render();}} + } + function approval(data, runId) {const area = $('runProgress'); area.replaceChildren(); const text = document.createElement('p'); text.textContent = data.prompt || 'Hope needs your permission to continue.'; area.append(text); for (const decision of ['approve', 'deny']) area.append(button(decision === 'approve' ? 'Approve' : 'Deny', async () => {const result = await api('/api/runs/' + enc(runId) + '/approval', 'POST', {approval_id: data.approval_id, decision}); state.run = result.run; area.replaceChildren(); controls();}));} + $('cancelRun').onclick = async () => {try {const data = await api('/api/runs/' + enc(state.run.id) + '/cancel', 'POST'); state.run = data.run; controls();} catch (e) {notice(e.message);} }; + function workspaceRows() { + $('workspaceList').replaceChildren(button('All conversations', () => {state.workspace = null; list();}, 'conversation-link')); + for (const workspace of state.workspaces) { const row = document.createElement('div'); row.className = 'workspace-row'; row.append(button('Instructions', () => workspaceInstructions(workspace))); row.append(button(workspace.name, () => {state.workspace = workspace.id; notice('Workspace: ' + workspace.name + '. New chats will be created here.'); list();}, 'conversation-link'), button('Edit', () => dialog('Rename workspace', workspace.name, async name => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, name}); await workspaces();})), button('Remove', () => dialog('Remove workspace?', '', async () => {await api('/api/workspaces/' + enc(workspace.id), 'DELETE'); if (state.workspace === workspace.id) state.workspace = null; await workspaces(); await list();}, 'Its conversations are kept outside the workspace.', false))); $('workspaceList').append(row); } + } + async function workspaces() {const epoch = state.epoch, data = await api('/api/workspaces'); if (epoch !== state.epoch) return; state.workspaces = data.workspaces; workspaceRows();} + function workspaceInstructions(workspace) {dialog('Workspace instructions', workspace.instructions || '', async instructions => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, instructions}); await workspaces();}, 'These instructions guide chats in this workspace. They do not isolate tool access.');} + $('newWorkspace').onclick = () => dialog('Create workspace', '', async name => {await api('/api/workspaces', 'POST', {name}); await workspaces();}, 'Group related chats. A workspace is not a separate tool sandbox.'); + function theme(preferences) {document.documentElement.dataset.palette = preferences.palette; document.documentElement.dataset.appearance = preferences.appearance; $('paletteSelect').value = preferences.palette; $('appearanceSelect').value = preferences.appearance;} + $('openPreferences').onclick = async () => {try {const data = await api('/api/preferences'); state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = ''; if (!HopeAuth.user) return; $('preferencesDialog').showModal();} catch(e) {notice(e.message);} }; + $('closePreferences').onclick = () => $('preferencesDialog').close(); + $('preferencesForm').onsubmit = async event => {event.preventDefault(); try {const data = await api('/api/preferences', 'PUT', {revision: state.preferences.revision, palette: $('paletteSelect').value, appearance: $('appearanceSelect').value}); state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = 'Appearance saved.';} catch(e) {$('preferencesMessage').textContent = e.message;} }; + HopeAuth.onChange((user, changed) => { + if (changed || !user) {reset(); state.workspace = null; state.workspaces = []; state.preferences = null; $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); $('workspaceList').replaceChildren(); $('legacyVoice').hidden = true; $('primaryChat').hidden = false; $('preferencesDialog').close(); $('chatActionDialog').close(); theme({palette: 'opal', appearance: 'system'});} + controls(); if (!user || !changed) return; + const epoch = state.epoch; + Promise.all([list(), workspaces(), api('/api/preferences').then(data => {if (epoch === state.epoch) {state.preferences = data.preferences; theme(data.preferences);}})]).catch(e => {if (epoch === state.epoch) notice(e.message);}); + }); +})(); diff --git a/web/index.html b/web/index.html index ffc3943..930169a 100644 --- a/web/index.html +++ b/web/index.html @@ -4,16 +4,17 @@ - Hope · Voice companion + Hope · Your private companion + - +

Welcome to Hope

@@ -27,7 +28,8 @@
-
+ +

New chat

A little space to think.

Ask a question, work through an idea, or start something new.

Your chats are private to your account. Hope can make mistakes.

-
+