personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-t6yovpmo-chat-bar-ui'

+287 -591
+1 -12
apps/__init__.py
··· 10 10 workspace.html # Required: Main template 11 11 routes.py # Optional: Flask blueprint (for custom routes beyond index) 12 12 background.html # Optional: Background service 13 - app_bar.html # Optional: Bottom bar 14 13 app.json # Optional: Metadata overrides 15 14 agents/ # Optional: Custom agents 16 15 tests/ # Optional: App-specific tests ··· 64 63 65 64 # Template paths (relative to Flask template root) 66 65 workspace_template: str = "" 67 - app_bar_template: Optional[str] = None 68 66 background_template: Optional[str] = None 69 67 70 68 # Facet configuration (optional, default {}) ··· 99 97 """Return path to workspace template.""" 100 98 return self.workspace_template 101 99 102 - def get_app_bar_template(self) -> Optional[str]: 103 - """Return path to custom app-bar template, or None.""" 104 - return self.app_bar_template 105 - 106 100 def get_background_template(self) -> Optional[str]: 107 101 """Return path to background service template, or None.""" 108 102 return self.background_template ··· 121 115 1. Check for workspace.html (required) 122 116 2. Load app.json if present (for icon, label overrides) 123 117 3. Import routes.py and get blueprint (optional - for custom routes) 124 - 4. Check for background.html, app_bar.html (optional) 118 + 4. Check for background.html (optional) 125 119 """ 126 120 apps_dir = Path(__file__).parent 127 121 ··· 236 230 if (app_path / "background.html").exists(): 237 231 background_template = f"{app_name}/background.html" 238 232 239 - app_bar_template = None 240 - if (app_path / "app_bar.html").exists(): 241 - app_bar_template = f"{app_name}/app_bar.html" 242 - 243 233 return App( 244 234 name=app_name, 245 235 icon=icon, 246 236 label=label, 247 237 blueprint=blueprint, 248 238 workspace_template=workspace_template, 249 - app_bar_template=app_bar_template, 250 239 background_template=background_template, 251 240 facets_config=facets_config, 252 241 date_nav=date_nav,
-87
apps/chat/app_bar.html
··· 1 - <style> 2 - .chat-message-input { 3 - flex: 1; 4 - resize: none; 5 - border: 1px solid #ddd; 6 - border-radius: 20px; 7 - padding: 0.75em 1em; 8 - font-family: inherit; 9 - font-size: 14px; 10 - line-height: 1.5; 11 - max-height: 120px; 12 - min-height: 42px; 13 - } 14 - 15 - .chat-message-input:focus { 16 - outline: none; 17 - border-color: #667eea; 18 - } 19 - 20 - .chat-provider-toggle { 21 - margin-left: auto; 22 - padding: 0.75em 1em; 23 - background: transparent; 24 - border: 1px solid #ddd; 25 - border-radius: 20px; 26 - cursor: pointer; 27 - font-weight: 500; 28 - font-size: 14px; 29 - transition: all 0.2s; 30 - white-space: nowrap; 31 - color: #666; 32 - } 33 - 34 - .chat-provider-toggle:hover { 35 - background: #f5f5f5; 36 - border-color: #999; 37 - color: #333; 38 - } 39 - 40 - .chat-provider-toggle[data-provider="anthropic"] { 41 - color: #764ba2; 42 - border-color: #764ba2; 43 - } 44 - 45 - .chat-provider-toggle[data-provider="google"] { 46 - color: #4285f4; 47 - border-color: #4285f4; 48 - } 49 - 50 - .chat-provider-toggle[data-provider="openai"] { 51 - color: #10a37f; 52 - border-color: #10a37f; 53 - } 54 - 55 - </style> 56 - 57 - <!-- Use display:contents to make form invisible to flexbox --> 58 - <form id="chatInputForm" style="display: contents;"> 59 - <textarea id="chatMessageInput" class="chat-message-input" rows="1" placeholder="Send a message..."></textarea> 60 - <button type="button" id="chatProviderToggle" class="chat-provider-toggle" data-provider="google" title="Click to change provider"> 61 - Gemini 62 - </button> 63 - </form> 64 - 65 - <script> 66 - (function() { 67 - const input = document.getElementById('chatMessageInput'); 68 - 69 - if (!input) { 70 - console.error('Chat input not found'); 71 - return; 72 - } 73 - 74 - // Auto-resize textarea 75 - input.addEventListener('input', () => { 76 - input.style.height = 'auto'; 77 - input.style.height = Math.min(input.scrollHeight, 120) + 'px'; 78 - }); 79 - 80 - // Focus input if nothing else is focused 81 - setTimeout(() => { 82 - if (document.activeElement === document.body) { 83 - input.focus(); 84 - } 85 - }, 100); 86 - })(); 87 - </script>
+15 -10
apps/chat/workspace.html
··· 1020 1020 * @param {string} provider - Provider key (anthropic, google, openai) 1021 1021 */ 1022 1022 function setProvider(provider) { 1023 - if (!provider || !providers[provider] || !providerToggle) return; 1023 + if (!provider || !providers[provider]) return; 1024 1024 currentProvider = provider; 1025 - providerToggle.dataset.provider = provider; 1026 - providerToggle.textContent = providers[provider].label; 1025 + if (providerToggle) { 1026 + providerToggle.dataset.provider = provider; 1027 + providerToggle.textContent = providers[provider].label; 1028 + } 1027 1029 } 1028 1030 1029 1031 function initAppBarControls() { ··· 1031 1033 input = document.getElementById('chatMessageInput'); 1032 1034 providerToggle = document.getElementById('chatProviderToggle'); 1033 1035 1034 - if (!form || !input || !providerToggle) { 1036 + if (!form || !input) { 1035 1037 console.error('Chat controls not found'); 1036 1038 return; 1037 1039 } 1038 1040 1039 - currentProvider = providerToggle.dataset.provider; 1041 + // Provider toggle is optional (removed from universal bar) 1042 + currentProvider = providerToggle ? providerToggle.dataset.provider : 'google'; 1040 1043 1041 - providerToggle.addEventListener('click', () => { 1042 - currentProvider = providers[currentProvider].next; 1043 - providerToggle.dataset.provider = currentProvider; 1044 - providerToggle.textContent = providers[currentProvider].label; 1045 - }); 1044 + if (providerToggle) { 1045 + providerToggle.addEventListener('click', () => { 1046 + currentProvider = providers[currentProvider].next; 1047 + providerToggle.dataset.provider = currentProvider; 1048 + providerToggle.textContent = providers[currentProvider].label; 1049 + }); 1050 + } 1046 1051 1047 1052 setupFormHandlers(); 1048 1053 input.focus();
-99
apps/dev/app_bar.html
··· 1 - <style> 2 - .dev-facet-display { 3 - display: inline-flex; 4 - align-items: center; 5 - gap: 8px; 6 - padding: 8px 16px; 7 - background: #f9fafb; 8 - border: 1px solid #e5e7eb; 9 - border-radius: 8px; 10 - font-size: 14px; 11 - } 12 - 13 - .dev-facet-display.has-facet { 14 - background: var(--facet-bg, #f9fafb); 15 - border-color: var(--facet-color, #e5e7eb); 16 - } 17 - 18 - .dev-facet-display strong { 19 - font-weight: 500; 20 - color: #6b7280; 21 - } 22 - 23 - .dev-facet-display small { 24 - font-size: 11px; 25 - text-transform: uppercase; 26 - letter-spacing: 0.05em; 27 - color: #9ca3af; 28 - margin-left: 4px; 29 - } 30 - 31 - .dev-facet-all { 32 - color: #667eea; 33 - font-weight: 600; 34 - } 35 - </style> 36 - 37 - <div class="dev-facet-display{% if selected_facet %} has-facet{% endif %}" id="dev-facet-display"> 38 - <strong>Facet:</strong> 39 - <span id="dev-facet-value"> 40 - {% if selected_facet %} 41 - {% set selected_facet_data = facets|selectattr("name", "equalto", selected_facet)|first %} 42 - {% if selected_facet_data %} 43 - {{ selected_facet_data.emoji }} {{ selected_facet_data.title }} 44 - <small>specific</small> 45 - {% else %} 46 - {{ selected_facet }} 47 - <small>specific</small> 48 - {% endif %} 49 - {% else %} 50 - <strong class="dev-facet-all">All Facets</strong> 51 - <small>all-facet</small> 52 - {% endif %} 53 - </span> 54 - </div> 55 - 56 - <script> 57 - (function() { 58 - // Listen for facet.switch events 59 - window.addEventListener('facet.switch', (e) => { 60 - updateFacetDisplay(e.detail.facet, e.detail.facetData); 61 - }); 62 - 63 - function updateFacetDisplay(facetName, facetData) { 64 - const display = document.getElementById('dev-facet-display'); 65 - const valueEl = document.getElementById('dev-facet-value'); 66 - 67 - if (!display || !valueEl) return; 68 - 69 - if (!facetName) { 70 - // All-facet mode 71 - display.classList.remove('has-facet'); 72 - valueEl.innerHTML = ` 73 - <strong class="dev-facet-all">All Facets</strong> 74 - <small>all-facet</small> 75 - `; 76 - return; 77 - } 78 - 79 - // Use provided facetData or fallback to window.facetsData 80 - if (!facetData) { 81 - facetData = window.facetsData?.find(f => f.name === facetName); 82 - } 83 - 84 - display.classList.add('has-facet'); 85 - 86 - if (facetData) { 87 - valueEl.innerHTML = ` 88 - ${facetData.emoji} ${facetData.title} 89 - <small>specific</small> 90 - `; 91 - } else { 92 - valueEl.innerHTML = ` 93 - ${facetName} 94 - <small>specific</small> 95 - `; 96 - } 97 - } 98 - })(); 99 - </script>
-80
apps/entities/app_bar.html
··· 1 - <style> 2 - .entity-add-input { 3 - flex: 1; 4 - min-width: 0; 5 - padding: 0.75em 1em; 6 - border: 1px solid #d1d5db; 7 - border-radius: 20px; 8 - font-family: inherit; 9 - font-size: 14px; 10 - line-height: 1.5; 11 - transition: border-color 0.15s, box-shadow 0.15s; 12 - } 13 - 14 - .entity-add-input:focus { 15 - outline: none; 16 - border-color: var(--facet-color, #667eea); 17 - box-shadow: 0 0 0 3px var(--facet-bg, rgba(102, 126, 234, 0.1)); 18 - } 19 - 20 - .entity-add-input::placeholder { 21 - color: #9ca3af; 22 - } 23 - 24 - .entity-add-input:disabled { 25 - background: #f3f4f6; 26 - color: #9ca3af; 27 - cursor: not-allowed; 28 - opacity: 0.7; 29 - } 30 - 31 - .entity-add-input:disabled::placeholder { 32 - color: #d1d5db; 33 - } 34 - </style> 35 - 36 - <form id="entity-add-form" style="display: contents;"> 37 - <input 38 - type="text" 39 - id="entity-assist-input" 40 - class="entity-add-input" 41 - placeholder="Add entity with AI (e.g., 'OpenAI', 'Claude')..." 42 - autocomplete="off" 43 - > 44 - </form> 45 - 46 - <script> 47 - (function() { 48 - const input = document.getElementById('entity-assist-input'); 49 - if (!input) return; 50 - 51 - // Check if facet is selected and update input state 52 - function updateInputState() { 53 - const facet = window.selectedFacet; 54 - if (!facet) { 55 - input.disabled = true; 56 - input.placeholder = 'Select a facet to add entities'; 57 - } else { 58 - input.disabled = false; 59 - input.placeholder = "Add entity with AI (e.g., 'OpenAI', 'Claude')..."; 60 - } 61 - } 62 - 63 - // Initial state - wait for DOM to ensure window.selectedFacet is set 64 - if (document.readyState === 'loading') { 65 - document.addEventListener('DOMContentLoaded', updateInputState); 66 - } else { 67 - updateInputState(); 68 - } 69 - 70 - // Listen for facet changes 71 - window.addEventListener('facet.switch', updateInputState); 72 - 73 - // Focus input if nothing else is focused 74 - setTimeout(() => { 75 - if (document.activeElement === document.body && !input.disabled) { 76 - input.focus(); 77 - } 78 - }, 100); 79 - })(); 80 - </script>
-62
apps/search/app_bar.html
··· 1 - <style> 2 - .search-bar-input { 3 - flex: 1; 4 - min-width: 0; 5 - padding: 0.75em 1em; 6 - border: 1px solid #d1d5db; 7 - border-radius: 20px; 8 - font-family: inherit; 9 - font-size: 14px; 10 - line-height: 1.5; 11 - transition: border-color 0.15s, box-shadow 0.15s; 12 - } 13 - 14 - .search-bar-input:focus { 15 - outline: none; 16 - border-color: var(--facet-color, #2563eb); 17 - box-shadow: 0 0 0 3px var(--facet-bg, rgba(37, 99, 235, 0.1)); 18 - } 19 - 20 - .search-bar-input::placeholder { 21 - color: #9ca3af; 22 - } 23 - </style> 24 - 25 - <!-- Use display:contents to make form invisible to flexbox --> 26 - <form id="search-bar-form" style="display: contents;"> 27 - <input 28 - type="text" 29 - id="search-bar-input" 30 - class="search-bar-input" 31 - placeholder="Search your journal..." 32 - autocomplete="off" 33 - > 34 - </form> 35 - 36 - <script> 37 - (function() { 38 - const form = document.getElementById('search-bar-form'); 39 - const input = document.getElementById('search-bar-input'); 40 - if (!form || !input) return; 41 - 42 - // Focus input on page load if nothing else is focused 43 - setTimeout(() => { 44 - if (document.activeElement === document.body) { 45 - input.focus(); 46 - } 47 - }, 100); 48 - 49 - // Handle form submission 50 - form.addEventListener('submit', (e) => { 51 - e.preventDefault(); 52 - window.dispatchEvent(new CustomEvent('search.submit', { 53 - detail: { query: input.value.trim() } 54 - })); 55 - }); 56 - 57 - // Listen for external query updates (hash changes) 58 - window.addEventListener('search.queryUpdate', (e) => { 59 - input.value = e.detail.query || ''; 60 - }); 61 - })(); 62 - </script>
-231
apps/todos/app_bar.html
··· 1 - <style> 2 - .todo-add-input { 3 - flex: 1; 4 - min-width: 0; 5 - padding: 0.75em 1em; 6 - border: 1px solid #d1d5db; 7 - border-radius: 20px; 8 - font-family: inherit; 9 - font-size: 14px; 10 - line-height: 1.5; 11 - transition: border-color 0.15s, box-shadow 0.15s; 12 - } 13 - 14 - .todo-add-input:focus { 15 - outline: none; 16 - border-color: var(--facet-color, #2563eb); 17 - box-shadow: 0 0 0 3px var(--facet-bg, rgba(37, 99, 235, 0.1)); 18 - } 19 - 20 - .todo-add-input::placeholder { 21 - color: #9ca3af; 22 - } 23 - 24 - .todo-add-input:disabled { 25 - background: #f3f4f6; 26 - color: #9ca3af; 27 - cursor: not-allowed; 28 - opacity: 0.7; 29 - } 30 - 31 - .todo-add-input:disabled::placeholder { 32 - color: #d1d5db; 33 - } 34 - 35 - .appbar-generate-btn { 36 - display: none; 37 - align-items: center; 38 - justify-content: center; 39 - width: 2.5rem; 40 - height: 2.5rem; 41 - padding: 0; 42 - border: none; 43 - border-radius: 50%; 44 - background: transparent; 45 - font-size: 1.25rem; 46 - cursor: pointer; 47 - transition: transform 0.2s, background 0.15s; 48 - } 49 - 50 - .appbar-generate-btn:hover { 51 - background: rgba(0, 0, 0, 0.05); 52 - transform: scale(1.1); 53 - } 54 - 55 - .appbar-generate-btn.generating { 56 - animation: appbar-spin 2s linear infinite; 57 - } 58 - 59 - @keyframes appbar-spin { 60 - from { transform: rotate(0deg); } 61 - to { transform: rotate(360deg); } 62 - } 63 - 64 - .appbar-generate-btn.visible { 65 - display: inline-flex; 66 - } 67 - </style> 68 - 69 - <!-- Use display:contents to make form invisible to flexbox --> 70 - <form method="post" style="display: contents;"> 71 - <input type="hidden" name="action" value="add"> 72 - <input 73 - type="text" 74 - name="text" 75 - id="todo-add-input" 76 - class="todo-add-input" 77 - placeholder="{% if selected_facet %}Add a todo to {{ facets|selectattr('name', 'equalto', selected_facet)|map(attribute='title')|first|default(selected_facet) }}...{% else %}Add a todo... (use #facet){% endif %}" 78 - autocomplete="off" 79 - > 80 - </form> 81 - 82 - <!-- Generate button for single-facet mode --> 83 - <button 84 - type="button" 85 - class="appbar-generate-btn{% if selected_facet %} visible{% endif %}" 86 - id="appbar-generate-btn" 87 - data-day="{{ day }}" 88 - title="Generate weekly todos with AI" 89 - >✨</button> 90 - 91 - <script> 92 - (function() { 93 - const input = document.getElementById('todo-add-input'); 94 - const form = input?.closest('form'); 95 - if (!input || !form || input.disabled) return; 96 - 97 - function updatePlaceholder(facetName) { 98 - if (facetName) { 99 - const facetData = window.facetsData?.find(f => f.name === facetName); 100 - const facetTitle = facetData?.title || facetName; 101 - input.placeholder = `Add a todo to ${facetTitle}...`; 102 - } else { 103 - input.placeholder = 'Add a todo... (use #facet)'; 104 - } 105 - } 106 - 107 - // Listen for facet selection changes 108 - window.addEventListener('facet.switch', (e) => { 109 - updatePlaceholder(e.detail.facet); 110 - }); 111 - 112 - // Handle form submission via AJAX 113 - form.addEventListener('submit', async (e) => { 114 - e.preventDefault(); 115 - 116 - const text = input.value.trim(); 117 - if (!text) { 118 - input.focus(); 119 - return; 120 - } 121 - 122 - // Capture form data BEFORE disabling (disabled inputs are excluded from FormData) 123 - const formData = new FormData(form); 124 - input.disabled = true; 125 - 126 - try { 127 - const response = await fetch(window.location.href, { 128 - method: 'POST', 129 - headers: { 'X-Requested-With': 'XMLHttpRequest' }, 130 - body: formData 131 - }); 132 - 133 - const data = await response.json(); 134 - 135 - if (response.ok && data.status === 'ok') { 136 - input.value = ''; 137 - window.dispatchEvent(new CustomEvent('todo.added', { detail: data.todo })); 138 - } else { 139 - alert(data.message || 'Failed to add todo'); 140 - } 141 - } catch (error) { 142 - alert('Network error - please try again'); 143 - } finally { 144 - input.disabled = false; 145 - input.focus(); 146 - } 147 - }); 148 - })(); 149 - </script> 150 - 151 - <script> 152 - (function() { 153 - const generateBtn = document.getElementById('appbar-generate-btn'); 154 - if (!generateBtn) return; 155 - 156 - const day = generateBtn.dataset.day; 157 - let currentFacet = window.selectedFacet; 158 - let agentId = null; 159 - 160 - // Show/hide button based on facet selection 161 - function updateVisibility(facetName) { 162 - currentFacet = facetName; 163 - if (facetName) { 164 - generateBtn.classList.add('visible'); 165 - // Reset state when switching facets 166 - resetButton(); 167 - } else { 168 - generateBtn.classList.remove('visible'); 169 - } 170 - } 171 - 172 - function resetButton() { 173 - generateBtn.textContent = '✨'; 174 - generateBtn.classList.remove('generating', 'completed'); 175 - agentId = null; 176 - } 177 - 178 - // Listen for facet selection changes 179 - window.addEventListener('facet.switch', (e) => { 180 - updateVisibility(e.detail.facet); 181 - }); 182 - 183 - // Handle click 184 - generateBtn.addEventListener('click', async () => { 185 - if (!currentFacet) return; 186 - 187 - // If completed, clicking does nothing 188 - if (generateBtn.classList.contains('completed')) return; 189 - 190 - // If generating, could navigate to agent (future enhancement) 191 - if (generateBtn.classList.contains('generating')) return; 192 - 193 - // Start generation 194 - generateBtn.textContent = '⏳'; 195 - generateBtn.classList.add('generating'); 196 - 197 - try { 198 - const response = await fetch(`/app/todos/${day}/generate-weekly/${currentFacet}`, { 199 - method: 'POST' 200 - }); 201 - 202 - if (!response.ok) { 203 - throw new Error('Failed to start generation'); 204 - } 205 - 206 - const data = await response.json(); 207 - agentId = data.agent_id; 208 - 209 - } catch (error) { 210 - alert('Failed to start todo generation'); 211 - resetButton(); 212 - } 213 - }); 214 - 215 - // Listen for cortex events (agent completion) 216 - if (window.appEvents) { 217 - window.appEvents.listen('cortex', msg => { 218 - if (!agentId || msg.agent_id !== agentId) return; 219 - 220 - if (msg.event === 'finish') { 221 - generateBtn.textContent = '✅'; 222 - generateBtn.classList.remove('generating'); 223 - generateBtn.classList.add('completed'); 224 - } else if (msg.event === 'error') { 225 - alert('Todo generation failed: ' + (msg.error || 'Unknown error')); 226 - resetButton(); 227 - } 228 - }); 229 - } 230 - })(); 231 - </script>
+4
convey/__init__.py
··· 19 19 from .bridge import emit, register_websocket 20 20 from .config import bp as config_bp 21 21 from .root import bp as root_bp 22 + from .triage import bp as triage_bp 22 23 23 24 __all__ = [ 24 25 "create_app", ··· 53 54 54 55 # Register config API blueprint 55 56 app.register_blueprint(config_bp) 57 + 58 + # Register triage API blueprint (universal chat bar) 59 + app.register_blueprint(triage_bp) 56 60 57 61 # Initialize and register app system 58 62 registry = AppRegistry()
+81 -4
convey/static/app.css
··· 106 106 .workspace { 107 107 margin-top: var(--facet-bar-height); 108 108 margin-left: var(--menu-bar-width-minimal); 109 - } 110 - 111 - /* Add bottom margin when app-bar is present */ 112 - body.has-app-bar .workspace { 113 109 margin-bottom: var(--app-bar-height); 114 110 } 115 111 ··· 1026 1022 pointer-events: none; 1027 1023 z-index: -1; 1028 1024 border-radius: 12px; 1025 + } 1026 + 1027 + /* Chat Bar Elements */ 1028 + .chat-bar-input { 1029 + flex: 1; 1030 + resize: none; 1031 + border: 1px solid #ddd; 1032 + border-radius: 20px; 1033 + padding: 0.75em 1em; 1034 + font-family: inherit; 1035 + font-size: 14px; 1036 + line-height: 1.5; 1037 + max-height: 120px; 1038 + min-height: 42px; 1039 + background: transparent; 1040 + position: relative; 1041 + z-index: 1; 1042 + } 1043 + 1044 + .chat-bar-input:focus { 1045 + outline: none; 1046 + border-color: var(--facet-color, #667eea); 1047 + } 1048 + 1049 + .chat-bar-input::placeholder { 1050 + color: #999; 1051 + } 1052 + 1053 + .chat-bar-send { 1054 + width: 36px; 1055 + height: 36px; 1056 + border-radius: 50%; 1057 + border: none; 1058 + background: var(--facet-color, #667eea); 1059 + color: white; 1060 + font-size: 18px; 1061 + cursor: pointer; 1062 + display: flex; 1063 + align-items: center; 1064 + justify-content: center; 1065 + position: relative; 1066 + z-index: 1; 1067 + flex-shrink: 0; 1068 + } 1069 + 1070 + .chat-bar-send:hover { 1071 + opacity: 0.85; 1072 + } 1073 + 1074 + .chat-bar-thinking { 1075 + display: flex; 1076 + align-items: center; 1077 + gap: 0.5em; 1078 + color: #999; 1079 + font-size: 13px; 1080 + position: relative; 1081 + z-index: 1; 1082 + } 1083 + 1084 + .chat-bar-thinking-dot { 1085 + width: 8px; 1086 + height: 8px; 1087 + background: var(--facet-color, #667eea); 1088 + border-radius: 50%; 1089 + animation: chat-bar-pulse 1s ease-in-out infinite; 1090 + } 1091 + 1092 + @keyframes chat-bar-pulse { 1093 + 0%, 100% { opacity: 0.3; } 1094 + 50% { opacity: 1; } 1095 + } 1096 + 1097 + .chat-bar-response { 1098 + flex: 1; 1099 + font-size: 13px; 1100 + color: #666; 1101 + white-space: nowrap; 1102 + overflow: hidden; 1103 + text-overflow: ellipsis; 1104 + position: relative; 1105 + z-index: 1; 1029 1106 } 1030 1107 1031 1108 /* Error log display */
+128 -6
convey/templates/app.html
··· 34 34 {% endif %} 35 35 </head> 36 36 {% set body_classes = [] %} 37 - {% if app_registry.apps[app].get_app_bar_template() %}{% set _ = body_classes.append('has-app-bar') %}{% endif %} 37 + {% set _ = body_classes.append('has-app-bar') %} 38 38 {% if app_registry.apps[app].date_nav_enabled() and day %}{% set _ = body_classes.append('has-date-nav') %}{% endif %} 39 39 <body{% if body_classes %} class="{{ body_classes|join(' ') }}"{% endif %}> 40 40 <!-- Corner Tags (screen region detection) --> ··· 66 66 <!-- Notification Center --> 67 67 <div class="notification-center" id="notification-center"></div> 68 68 69 - <!-- App Bar (bottom) - only render if app provides template --> 70 - {% set app_bar_template = app_registry.apps[app].get_app_bar_template() %} 71 - {% if app_bar_template %} 69 + <!-- Chat Bar (universal) --> 72 70 <div class="app-bar"> 73 - {% include app_bar_template %} 71 + <form id="chatInputForm" style="display: contents;"> 72 + <textarea id="chatMessageInput" class="chat-bar-input" rows="1" placeholder="Send a message..."></textarea> 73 + <button type="submit" class="chat-bar-send" title="Send" style="display: none;">↑</button> 74 + </form> 75 + <div class="chat-bar-thinking" style="display: none;"> 76 + <span class="chat-bar-thinking-dot"></span> 77 + Thinking… 78 + </div> 79 + <div class="chat-bar-response" style="display: none;"></div> 74 80 </div> 75 - {% endif %} 76 81 77 82 <!-- Main Content --> 78 83 <div class="workspace"> ··· 81 86 82 87 <!-- App JavaScript (includes AppServices framework) --> 83 88 <script src="{{ url_for('root.static', filename='app.js') }}"></script> 89 + 90 + <script> 91 + (function() { 92 + const chatBarInput = document.getElementById('chatMessageInput'); 93 + const chatBarForm = document.getElementById('chatInputForm'); 94 + const chatBarThinking = document.querySelector('.chat-bar-thinking'); 95 + const chatBarResponse = document.querySelector('.chat-bar-response'); 96 + const chatBarSend = document.querySelector('.chat-bar-send'); 97 + const isChatApp = window.location.pathname.startsWith('/app/chat'); 98 + const STORAGE_KEY = 'solstone:chatBarState'; 99 + 100 + if (!chatBarInput || !chatBarForm) return; 101 + 102 + // --- Auto-resize textarea --- 103 + chatBarInput.addEventListener('input', () => { 104 + chatBarInput.style.height = 'auto'; 105 + chatBarInput.style.height = Math.min(chatBarInput.scrollHeight, 120) + 'px'; 106 + // Show/hide send button 107 + if (chatBarSend) { 108 + chatBarSend.style.display = chatBarInput.value.trim() ? '' : 'none'; 109 + } 110 + }); 111 + 112 + // --- Focus on load (only if nothing else focused) --- 113 + setTimeout(() => { 114 + if (document.activeElement === document.body) { 115 + chatBarInput.focus(); 116 + } 117 + }, 100); 118 + 119 + // --- Restore state from localStorage --- 120 + try { 121 + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); 122 + if (saved && saved.response && !isChatApp && chatBarResponse) { 123 + chatBarResponse.textContent = saved.response; 124 + chatBarResponse.style.display = ''; 125 + } 126 + } catch {} 127 + 128 + // --- Non-chat app: triage submit --- 129 + if (!isChatApp) { 130 + // Enter to submit, shift+enter for newline 131 + chatBarInput.addEventListener('keydown', e => { 132 + if (e.key === 'Enter' && !e.shiftKey) { 133 + e.preventDefault(); 134 + chatBarForm.requestSubmit(); 135 + } 136 + }); 137 + 138 + chatBarForm.onsubmit = async (e) => { 139 + e.preventDefault(); 140 + const text = chatBarInput.value.trim(); 141 + if (!text) return; 142 + 143 + // Show thinking state 144 + chatBarInput.value = ''; 145 + chatBarInput.style.height = ''; 146 + if (chatBarSend) { 147 + chatBarSend.style.display = 'none'; 148 + } 149 + chatBarInput.disabled = true; 150 + if (chatBarThinking) { 151 + chatBarThinking.style.display = ''; 152 + } 153 + if (chatBarResponse) { 154 + chatBarResponse.style.display = 'none'; 155 + } 156 + 157 + try { 158 + const r = await fetch('/api/triage', { 159 + method: 'POST', 160 + headers: { 'Content-Type': 'application/json' }, 161 + body: JSON.stringify({ 162 + message: text, 163 + app: '{{ app }}', 164 + path: window.location.pathname, 165 + facet: window.selectedFacet || null 166 + }) 167 + }); 168 + 169 + if (r.ok) { 170 + const data = await r.json(); 171 + if (chatBarResponse) { 172 + chatBarResponse.textContent = data.response || ''; 173 + chatBarResponse.style.display = data.response ? '' : 'none'; 174 + } 175 + 176 + // Save to localStorage 177 + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 178 + message: text, 179 + response: data.response || '', 180 + timestamp: Date.now(), 181 + app: '{{ app }}', 182 + path: window.location.pathname 183 + })); 184 + } else if (chatBarResponse) { 185 + chatBarResponse.textContent = 'Something went wrong. Try again.'; 186 + chatBarResponse.style.display = ''; 187 + } 188 + } catch (err) { 189 + if (chatBarResponse) { 190 + chatBarResponse.textContent = 'Connection error. Try again.'; 191 + chatBarResponse.style.display = ''; 192 + } 193 + } finally { 194 + if (chatBarThinking) { 195 + chatBarThinking.style.display = 'none'; 196 + } 197 + chatBarInput.disabled = false; 198 + chatBarInput.focus(); 199 + } 200 + }; 201 + } 202 + // In chat app: workspace's setupFormHandlers() takes over form submission. 203 + // The bar just provides the DOM elements (chatInputForm, chatMessageInput). 204 + })(); 205 + </script> 84 206 85 207 <!-- Load all app background scripts --> 86 208 {% for app_name, app_instance in app_registry.apps.items() %}
+58
convey/triage.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Triage endpoint for universal chat bar queries from non-chat apps.""" 5 + 6 + from __future__ import annotations 7 + 8 + import logging 9 + from typing import Any 10 + 11 + from flask import Blueprint, request 12 + 13 + from convey.utils import error_response 14 + 15 + logger = logging.getLogger(__name__) 16 + 17 + bp = Blueprint("triage", __name__, url_prefix="/api/triage") 18 + 19 + 20 + @bp.route("", methods=["POST"]) 21 + def triage() -> Any: 22 + """Accept a message from the universal chat bar and return a response. 23 + 24 + Expects JSON: {message, app, path, facet} 25 + Returns JSON: {response} 26 + """ 27 + payload = request.get_json(force=True) 28 + message = payload.get("message", "").strip() 29 + 30 + if not message: 31 + return error_response("message is required", 400) 32 + 33 + app_name = payload.get("app", "") 34 + path = payload.get("path", "") 35 + facet = payload.get("facet") 36 + 37 + try: 38 + from think.models import generate 39 + 40 + system_prompt = ( 41 + "You are a helpful assistant in solstone, a journaling toolkit. " 42 + "The user is asking from the app bar. " 43 + "Give a brief, one-sentence answer." 44 + ) 45 + 46 + ctx = f"App: {app_name}, Path: {path}" 47 + if facet: 48 + ctx += f", Facet: {facet}" 49 + 50 + full_prompt = f"[Context: {ctx}]\n\n{message}" 51 + 52 + response_text = generate( 53 + full_prompt, context="convey.triage", system_instruction=system_prompt 54 + ) 55 + return {"response": response_text} 56 + except Exception: 57 + logger.exception("Triage generation failed") 58 + return error_response("Failed to generate response", 500)