feat: add qualified research exports and accessible voice handoff
This commit is contained in:
1 parent
135e0c0da2
commit
08b7e20be8
4 files changed
+74
-16
No files matched your search
@@ -18,6 +18,9 @@ def fixture(page, replay=False):
|
|||||||
calls.append((method, path, body))
|
calls.append((method, path, body))
|
||||||
if path == '/api/auth/me': data = {'user': dict(id='alice', username='alice', role='user', status='ready')}
|
if path == '/api/auth/me': data = {'user': dict(id='alice', username='alice', role='user', status='ready')}
|
||||||
elif path == '/voices': data = {'voices': []}
|
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/capabilities': data = {'version':1,'features':{}}
|
||||||
elif path == '/api/preferences':
|
elif path == '/api/preferences':
|
||||||
if method == 'PUT': preferences.update(body); preferences['revision'] += 1
|
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('#chatTitle')).to_have_text('Newest revision')
|
||||||
expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed')
|
expect(page.locator('#chatNotice')).to_contain_text('Conversation refreshed')
|
||||||
browser.close()
|
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()
|
||||||
+17
-15
@@ -2,17 +2,17 @@
|
|||||||
(() => {
|
(() => {
|
||||||
'use strict';
|
'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 $ = 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;
|
let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false;
|
||||||
const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status);
|
const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status);
|
||||||
const notice = text => { $('chatNotice').textContent = text; };
|
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; };
|
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() {
|
function controls() {
|
||||||
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || state.voiceActive;
|
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();
|
$('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim();
|
||||||
$('cancelRun').hidden = terminal(state.run);
|
$('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy;
|
||||||
for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || 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;
|
$('conversationActions').hidden = !state.conversation; $('conversationMenu').hidden = !state.conversation;
|
||||||
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
|
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
|
||||||
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
|
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
|
||||||
@@ -23,21 +23,21 @@
|
|||||||
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
|
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
|
||||||
controls();
|
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.addEventListener('hope:attachments-changed', controls);
|
||||||
window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}};
|
window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}};
|
||||||
function stopStream() { state.stream?.abort(); state.stream = null; }
|
function stopStream() { state.stream?.abort(); state.stream = null; }
|
||||||
function drawer(open) {
|
function drawer(open) {
|
||||||
$('chatSidebar').classList.toggle('drawer-open', open); $('drawerBackdrop').hidden = !open;
|
$('chatSidebar').classList.toggle('drawer-open', open); $('drawerBackdrop').hidden = !open;
|
||||||
$('drawerOpen').setAttribute('aria-expanded', String(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();
|
(open ? $('drawerClose') : $('drawerOpen')).focus();
|
||||||
}
|
}
|
||||||
$('drawerOpen').onclick = () => drawer(true); $('drawerClose').onclick = $('drawerBackdrop').onclick = () => drawer(false);
|
$('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();} });
|
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)');
|
const mobile = matchMedia('(max-width: 760px)');
|
||||||
function adapt() { if (mobile.matches) $('conversationActions').setAttribute('popover', 'auto'); else {$('conversationActions').removeAttribute('popover');}
|
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();
|
mobile.addEventListener('change', adapt); adapt();
|
||||||
$('openVoice').onclick = () => { $('primaryChat').hidden = true; $('legacyVoice').hidden = false; if (mobile.matches) drawer(false); $('backToChat').focus(); };
|
$('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(); };
|
$('backToChat').onclick = () => { $('primaryChat').hidden = false; $('legacyVoice').hidden = true; $('primaryInput').focus(); };
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
for (const message of state.messages) {
|
for (const message of state.messages) {
|
||||||
const row = document.createElement('article'); row.className = `chat-message ${message.role}`; row.dataset.messageId = message.id;
|
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 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)));
|
actions.append(button('Copy', () => navigator.clipboard.writeText(message.content)));
|
||||||
if (terminal(state.run)) {
|
if (terminal(state.run)) {
|
||||||
if (message.role === 'user') actions.append(button('Edit in new branch', () => edit(message)));
|
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;} };
|
$('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}));
|
$('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);
|
$('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;};
|
$('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);
|
$('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();} }
|
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 {
|
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);}
|
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.
|
// Retry an uncertain POST with the same key rather than creating another turn.
|
||||||
const attachmentIDs = window.HopeFeatures?.attachmentIDs() || [];
|
const attachmentIDs = window.HopeFeatures?.attachmentIDs() || [], mode = state.research ? $('researchMode').value : 'normal';
|
||||||
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};
|
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;
|
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();
|
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();}
|
} 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 === 'run' || type === 'done') {state.run = data.run || data; controls();}
|
||||||
if (type === 'progress') $('runProgress').textContent = data.message || '';
|
if (type === 'progress') $('runProgress').textContent = data.message || '';
|
||||||
if (type === 'approval') approval(data, run.id);
|
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;
|
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);} };
|
$('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() {
|
function workspaceRows() {
|
||||||
$('workspaceList').replaceChildren(button('All conversations', () => {state.workspace = null; list();}, 'conversation-link'));
|
$('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();}
|
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.');}
|
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();
|
$('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;} };
|
$('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 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();});
|
document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();});
|
||||||
HopeAuth.onChange((user, changed) => {
|
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;
|
controls(); if (!user || !changed) return;
|
||||||
const epoch = state.epoch; syncTimer = setInterval(sync, 5000);
|
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);});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
+1
-1
@@ -29,7 +29,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<main class="app-shell" id="authenticatedApp" hidden>
|
<main class="app-shell" id="authenticatedApp" hidden>
|
||||||
<aside id="chatSidebar" class="chat-sidebar" aria-label="Conversations"><div class="sidebar-brand"><span class="hope-mark" aria-hidden="true">✳</span><strong>Hope</strong><button id="drawerClose" class="btn btn-ghost" aria-label="Close navigation">×</button></div><button id="newConversation" class="btn new-chat">+ New chat</button><label class="sr-only" for="conversationSearch">Search conversations</label><input id="conversationSearch" type="search" placeholder="Search conversations"><nav aria-label="Chat history"><h2>Pinned</h2><div id="pinnedConversations"></div><div class="nav-heading"><h2>Workspaces</h2><button id="newWorkspace" class="btn btn-ghost" aria-label="Create workspace">+</button></div><div id="workspaceList"></div><div class="nav-heading"><h2 id="recentHeading">Recent</h2><button id="showArchived" class="btn btn-ghost" aria-pressed="false">Archived</button></div><div id="recentConversations"></div><button id="loadMoreConversations" class="btn btn-ghost" hidden>Load more</button></nav><div class="sidebar-footer"><button id="openVoice" class="btn btn-ghost">Voice & account</button><button id="openPreferences" class="btn btn-ghost">Appearance</button></div></aside><button id="drawerBackdrop" class="drawer-backdrop" aria-label="Close navigation" hidden></button>
|
<aside id="chatSidebar" class="chat-sidebar" aria-label="Conversations"><div class="sidebar-brand"><span class="hope-mark" aria-hidden="true">✳</span><strong>Hope</strong><button id="drawerClose" class="btn btn-ghost" aria-label="Close navigation">×</button></div><button id="newConversation" class="btn new-chat">+ New chat</button><label class="sr-only" for="conversationSearch">Search conversations</label><input id="conversationSearch" type="search" placeholder="Search conversations"><nav aria-label="Chat history"><h2>Pinned</h2><div id="pinnedConversations"></div><div class="nav-heading"><h2>Workspaces</h2><button id="newWorkspace" class="btn btn-ghost" aria-label="Create workspace">+</button></div><div id="workspaceList"></div><div class="nav-heading"><h2 id="recentHeading">Recent</h2><button id="showArchived" class="btn btn-ghost" aria-pressed="false">Archived</button></div><div id="recentConversations"></div><button id="loadMoreConversations" class="btn btn-ghost" hidden>Load more</button></nav><div class="sidebar-footer"><button id="openVoice" class="btn btn-ghost">Voice & account</button><button id="openPreferences" class="btn btn-ghost">Appearance</button></div></aside><button id="drawerBackdrop" class="drawer-backdrop" aria-label="Close navigation" hidden></button>
|
||||||
<section id="primaryChat" class="primary-chat" aria-labelledby="chatTitle"><header class="chat-topbar"><button id="drawerOpen" class="btn btn-ghost" aria-label="Open navigation" aria-expanded="false" aria-controls="chatSidebar">☰</button><h1 id="chatTitle">New chat</h1><div id="featureActions" class="feature-actions"></div><button id="conversationMenu" class="btn btn-ghost" popovertarget="conversationActions" aria-label="Conversation actions" hidden>•••</button><div id="conversationActions" class="conversation-actions"><button id="pinConversation" class="btn btn-ghost" disabled>Pin</button><button id="renameConversation" class="btn btn-ghost" disabled>Rename</button><button id="moveConversation" class="btn btn-ghost" disabled>Move</button><button id="archiveConversation" class="btn btn-ghost" disabled>Archive</button><button id="deleteConversation" class="btn btn-ghost" disabled>Delete</button></div></header><div id="chatNotice" class="chat-notice" role="status" aria-live="polite"></div><div id="chatTimeline" class="chat-timeline" tabindex="-1" aria-label="Messages"><div class="chat-welcome"><span class="hope-mark" aria-hidden="true">✳</span><h2>A little space to think.</h2><p>Ask a question, work through an idea, or start something new.</p></div></div><div id="runProgress" class="run-progress" role="status" aria-live="polite"></div><form id="primaryComposer" class="primary-composer" aria-label="Message Hope"><label class="sr-only" for="primaryInput">Message Hope</label><textarea id="primaryInput" rows="2" placeholder="What's on your mind?" required disabled></textarea><div id="attachmentPreviews"></div><div class="composer-footer"><span>Enter to send · Shift + Enter for a new line</span><button id="cancelRun" class="btn" type="button" hidden>Stop response</button><button id="primarySend" class="btn btn-primary" disabled>Send ↑</button></div></form><p class="chat-footnote">Your chats are private to your account. Hope can make mistakes.</p></section><section id="legacyVoice" class="legacy-voice" hidden aria-label="Voice and account settings"><button id="backToChat" class="btn btn-secondary">← Back to chat</button><p>Live voice controls and account settings.</p> <header class="topbar">
|
<section id="primaryChat" class="primary-chat" aria-labelledby="chatTitle"><header class="chat-topbar"><button id="drawerOpen" class="btn btn-ghost" aria-label="Open navigation" aria-expanded="false" aria-controls="chatSidebar">☰</button><h1 id="chatTitle">New chat</h1><div id="featureActions" class="feature-actions"></div><button id="conversationMenu" class="btn btn-ghost" popovertarget="conversationActions" aria-label="Conversation actions" hidden>•••</button><div id="conversationActions" class="conversation-actions"><button id="pinConversation" class="btn btn-ghost" disabled>Pin</button><button id="renameConversation" class="btn btn-ghost" disabled>Rename</button><button id="exportMarkdown" class="btn btn-ghost">Download Markdown</button><button id="exportJSON" class="btn btn-ghost">Download JSON</button><button id="moveConversation" class="btn btn-ghost" disabled>Move</button><button id="archiveConversation" class="btn btn-ghost" disabled>Archive</button><button id="deleteConversation" class="btn btn-ghost" disabled>Delete</button></div></header><div id="chatNotice" class="chat-notice" role="status" aria-live="polite"></div><div id="chatTimeline" class="chat-timeline" tabindex="-1" aria-label="Messages"><div class="chat-welcome"><span class="hope-mark" aria-hidden="true">✳</span><h2>A little space to think.</h2><p>Ask a question, work through an idea, or start something new.</p></div></div><div id="runProgress" class="run-progress" role="status" aria-live="polite"></div><form id="primaryComposer" class="primary-composer" aria-label="Message Hope"><label class="sr-only" for="primaryInput">Message Hope</label><textarea id="primaryInput" rows="2" placeholder="What's on your mind?" required disabled></textarea><div id="attachmentPreviews"></div><div class="composer-footer"><label id="researchModeLabel" for="researchMode" hidden>Mode</label><select id="researchMode" hidden><option value="normal">Chat</option><option value="research">Research</option></select><span>Enter to send · Shift + Enter for a new line</span><button id="cancelRun" class="btn" type="button" hidden>Stop response</button><button id="primarySend" class="btn btn-primary" disabled>Send ↑</button></div></form><p class="chat-footnote">Your chats are private to your account. Hope can make mistakes.</p></section><section id="legacyVoice" class="legacy-voice" hidden aria-label="Voice and account settings"><button id="backToChat" class="btn btn-secondary">← Back to chat</button><p>Live voice controls and account settings.</p> <header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-glyph" aria-hidden="true"><i></i></span>
|
<span class="brand-glyph" aria-hidden="true"><i></i></span>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1543,3 +1543,6 @@ dialog h2 {font-size:22px;margin-bottom:16px;}
|
|||||||
.feature-actions {max-width:120px;}
|
.feature-actions {max-width:120px;}
|
||||||
#drawerOpen {min-height:44px;min-width:44px;}
|
#drawerOpen {min-height:44px;min-width:44px;}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#researchMode {max-width:130px;padding:7px 8px;border:1px solid var(--line);border-radius:6px;background:var(--canvas);color:var(--ink);font:inherit;font-size:13px;}
|
||||||
|
#researchModeLabel {font-size:12px;color:var(--ink-muted);}
|
||||||
Reference in new issue
Block a user