diff --git a/tests/unit/test_chat_web.py b/tests/unit/test_chat_web.py index 4945213..dc25523 100644 --- a/tests/unit/test_chat_web.py +++ b/tests/unit/test_chat_web.py @@ -18,6 +18,9 @@ def fixture(page, replay=False): 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/research': data = {'available':True,'mode':'hermes_tools','reason':''} + elif path.endswith('/export'): + r.fulfill(body='private export',content_type='text/plain'); return elif path == '/api/capabilities': data = {'version':1,'features':{}} elif path == '/api/preferences': if method == 'PUT': preferences.update(body); preferences['revision'] += 1 @@ -150,3 +153,53 @@ def test_archived_move_sync_conflict_and_mobile_actions(): expect(page.locator('#chatTitle')).to_have_text('Newest revision') expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed') browser.close() + + +def test_voice_history_refresh_keyboard_focus_and_large_text(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={'width':1280,'height':900}, reduced_motion='reduce') + calls, streams, conversations, messages = fixture(page) + 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('#chatTitle')).to_have_text('A saved thought') + expect(page.locator('#chatTimeline')).to_contain_text('Original question') + page.locator('#primaryInput').fill('Keep my draft') + page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:true,conversation_id:'c1'}}))") + messages['c1'].append(dict(id='voice1',role='assistant',content='Saved voice reply with [source](https://example.com/report)',status='completed')) + page.evaluate("window.dispatchEvent(new CustomEvent('hope:voice-state',{detail:{active:false,conversation_id:'c1'}}))") + expect(page.locator('#chatTimeline')).to_contain_text('Saved voice reply') + expect(page.locator('#primaryInput')).to_have_value('Keep my draft') + expect(page.locator('#chatTimeline a')).to_have_attribute('href','https://example.com/report') + expect(page.locator('#chatNotice')).to_contain_text('Voice conversation saved') + page.screenshot(path='/tmp/hope-chat-opal-desktop.png') + page.keyboard.press('Control+k'); expect(page.locator('#conversationSearch')).to_be_focused() + page.keyboard.press('Escape') + page.locator('#openPreferences').click(); expect(page.locator('#preferencesDialog')).to_be_visible() + page.keyboard.press('Escape'); expect(page.locator('#openPreferences')).to_be_focused() + # Native controls remain usable at large text and narrow effective viewport. + page.set_viewport_size({'width':640,'height':900}) + page.add_style_tag(content='html {font-size: 200% !important;}') + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + page.locator('#drawerOpen').click(); page.keyboard.press('Escape') + expect(page.locator('#drawerOpen')).to_be_focused() + assert page.evaluate("getComputedStyle(document.getElementById('drawerOpen')).outlineStyle") != 'none' + browser.close() + + +def test_qualified_research_and_private_export(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(); calls, streams, conversations, messages = fixture(page) + page.goto('https://hope.test/'); page.wait_for_load_state('networkidle') + expect(page.locator('#researchMode')).to_be_visible() + page.locator('#researchMode').select_option('research') + page.locator('#primaryInput').fill('Research this topic'); page.locator('#primarySend').click() + expect(page.locator('#chatTimeline')).to_contain_text('Answer') + assert next(body for method,path,body in calls if method == 'POST' and path.endswith('/messages'))['mode'] == 'research' + with page.expect_download() as info: page.locator('#exportMarkdown').click() + assert info.value.suggested_filename.endswith('.md') + with page.expect_download() as info: page.locator('#exportJSON').click() + assert info.value.suggested_filename.endswith('.json') + assert all('/api/conversations/' in path for method,path,body in calls if path.endswith('/export')) + browser.close() diff --git a/web/chat.js b/web/chat.js index 3086537..50a5392 100644 --- a/web/chat.js +++ b/web/chat.js @@ -2,17 +2,17 @@ (() => { '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, archived: false, syncCursor: 0, pending: null}; + const state = {conversation: null, messages: [], run: null, workspaces: [], preferences: null, epoch: 0, stream: null, cursor: null, messageCursor: null, workspace: null, busy: false, voiceActive: false, archived: false, syncCursor: 0, research: false, pending: null}; let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false; 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; + $('primaryInput').disabled = !ready || state.busy || running; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy)); $('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim(); - $('cancelRun').hidden = terminal(state.run); - for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy; + $('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy; + for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy; $('conversationActions').hidden = !state.conversation; $('conversationMenu').hidden = !state.conversation; $('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive'; $('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin'; @@ -23,21 +23,21 @@ 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:voice-state', event => {const wasActive = state.voiceActive; state.voiceActive = !!event.detail?.active; controls(); if (state.voiceActive) notice('Voice is active. End the voice conversation before sending text.'); else {if (wasActive) notice(''); if (wasActive && HopeAuth.user && state.conversation) refreshSelected().then(() => {notice('Voice conversation saved.'); list();}).catch(error => notice(error.message));}}); 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; } + if (matchMedia('(max-width: 760px)').matches) { $('primaryChat').inert = open; $('legacyVoice').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() { if (mobile.matches) $('conversationActions').setAttribute('popover', 'auto'); else {$('conversationActions').removeAttribute('popover');} - $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('drawerBackdrop').hidden = true; } } + $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').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(); }; @@ -77,7 +77,7 @@ 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'; + const actions = document.createElement('div'); actions.className = 'message-actions'; actions.setAttribute('aria-label', (message.role === 'user' ? 'Your message' : 'Hope response') + ' 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))); @@ -117,6 +117,8 @@ $('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(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', async () => {await update({archived: !state.conversation.archived}); reset(); await list();}, 'The conversation is retained, but leaves Recent.', false); + async function exportChat(format) {const epoch = state.epoch, cid = state.conversation.id; const response = await fetch('/api/conversations/' + enc(cid) + '/export?format=' + format, {credentials:'same-origin'}); if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Download failed (' + response.status + ').'); const blob = await response.blob(); if (epoch !== state.epoch) return; const url = URL.createObjectURL(blob), anchor = document.createElement('a'); anchor.href = url; anchor.download = 'hope-conversation-' + cid.replace(/[^a-zA-Z0-9_-]/g,'') + (format === 'json' ? '.json' : '.md'); anchor.click(); setTimeout(() => URL.revokeObjectURL(url),1000); notice('Conversation downloaded.');} + $('exportMarkdown').onclick = () => exportChat('markdown').catch(e => notice(e.message)); $('exportJSON').onclick = () => exportChat('json').catch(e => notice(e.message)); $('moveConversation').onclick = () => {dialog('Move conversation', '', () => update({workspace_id: $('chatWorkspaceSelect').value || null}), 'Keep the same history in a different workspace.', false); const select = $('chatWorkspaceSelect'); select.replaceChildren(); for (const workspace of [{id:'',name:'No workspace'}, ...state.workspaces]) {const option = document.createElement('option'); option.value = workspace.id; option.textContent = workspace.name; select.append(option);} select.value = state.conversation.workspace_id || ''; select.hidden = false; $('chatWorkspaceLabel').hidden = 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();} } @@ -128,8 +130,8 @@ 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 attachmentIDs = window.HopeFeatures?.attachmentIDs() || [], mode = state.research ? $('researchMode').value : 'normal'; + if (!state.pending || state.pending.content !== content || state.pending.mode !== mode || JSON.stringify(state.pending.attachment_ids) !== JSON.stringify(attachmentIDs)) state.pending = {content, mode, 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();} @@ -152,7 +154,7 @@ 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 (type === 'done') {notice(state.run?.status === 'cancelled' ? 'Response stopped.' : state.run?.status === 'failed' ? 'Response failed.' : 'Response ready.'); done = true; await reader.cancel(); break;} } if (part.done) done = true; } @@ -167,7 +169,7 @@ $('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); } + for (const workspace of state.workspaces) { const row = document.createElement('div'); row.className = 'workspace-row'; row.append(button(workspace.name, () => {state.workspace = workspace.id; notice('Workspace: ' + workspace.name + '. New chats will be created here.'); list();}, 'conversation-link'), button('Instructions', () => workspaceInstructions(workspace)), 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.');} @@ -177,12 +179,12 @@ $('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;} }; async function refreshSelected(messages = true) {if (!state.conversation) return; const epoch = state.epoch, cid = state.conversation.id; const data = await api('/api/conversations/' + enc(cid)); if (epoch !== state.epoch || cid !== state.conversation?.id) return; selected(data.conversation); if (messages && terminal(state.run)) {const timeline = $('chatTimeline'), scroll = timeline.scrollTop; const incoming = new Map((data.messages || []).map(m => [m.id,m])); state.messages = state.messages.map(m => incoming.get(m.id) || m); for (const m of incoming.values()) if (!state.messages.some(old => old.id === m.id)) state.messages.push(m); render(); timeline.scrollTop = scroll;} } - async function sync() {if (syncBusy || !HopeAuth.user || document.hidden) return; syncBusy = true; const epoch = state.epoch; try {const data = await api('/api/sync?after=' + state.syncCursor); if (epoch !== state.epoch) return; state.syncCursor = data.cursor; if (!data.changes?.length) return; for (const change of data.changes) {if (change.kind === 'preferences' && change.data && !$('preferencesDialog').open) {state.preferences = change.data; theme(change.data);} if (change.kind === 'conversation' && change.id === state.conversation?.id && change.deleted) {reset(); notice('This conversation was deleted on another device.');}} await Promise.all([list(), workspaces(), refreshSelected()]);} catch (error) {/* A subsequent foreground poll retries; never discard the draft. */} finally {syncBusy = false;}} + async function sync() {if (syncBusy || !HopeAuth.user || document.hidden) return; syncBusy = true; const epoch = state.epoch; try {const data = await api('/api/sync?after=' + state.syncCursor); if (epoch !== state.epoch) return; state.syncCursor = data.cursor; if (!data.changes?.length) return; for (const change of data.changes) {if (change.kind === 'preferences' && change.data && !$('preferencesDialog').open) {state.preferences = change.data; theme(change.data);} if (change.kind === 'conversation' && change.id === state.conversation?.id && change.deleted) {reset(); notice('This conversation was deleted on another device.');}} await Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), refreshSelected()]);} catch (error) {/* A subsequent foreground poll retries; never discard the draft. */} finally {syncBusy = false;}} document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();}); HopeAuth.onChange((user, changed) => { - if (changed || !user) {clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; 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'});} + if (changed || !user) {clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; 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; syncTimer = setInterval(sync, 5000); - 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);}); + Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), 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 56617ac..b1ae74c 100644 --- a/web/index.html +++ b/web/index.html @@ -29,7 +29,7 @@
-

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.