diff --git a/backend/routers/chat.py b/backend/routers/chat.py index 1db343e9409..73b4572303a 100644 --- a/backend/routers/chat.py +++ b/backend/routers/chat.py @@ -274,9 +274,14 @@ def process_message(response: str, callback_data: dict): # cited extraction cited_conversation_idxs = {int(i) for i in re.findall(r'\[(\d+)\]', response)} + logger.info( + f"[chat] citation extraction: {len(memories)} memories_found, " + f"cited_idxs={sorted(cited_conversation_idxs)}" + ) if len(cited_conversation_idxs) > 0: response = re.sub(r'\[\d+\]', '', response) memories = [memories[i - 1] for i in cited_conversation_idxs if 0 < i and i <= len(memories)] + logger.info(f"[chat] resolved {len(memories)} citation(s) for done: payload") memories_id = extract_memory_ids(memories) if memories else [] @@ -324,7 +329,25 @@ async def generate_stream(): else: response = callback_data.get('answer') if response: - ai_message, ask_for_nps = process_message(response, callback_data) + try: + ai_message, ask_for_nps = process_message(response, callback_data) + except Exception as pm_err: + logger.error( + f"[chat] process_message failed; emitting done: without citations. Error: {pm_err}" + ) + # Build a minimal message so the done: line is always emitted. + # Strip [N] markers best-effort so the frontend shows clean text. + ai_message = Message( + id=str(uuid.uuid4()), + text=re.sub(r'\[\d+\]', '', response), + created_at=datetime.now(timezone.utc), + sender='ai', + app_id=app_id_from_app, + type='text', + ) + if chat_session: + ai_message.chat_session_id = chat_session.id + ask_for_nps = False ai_message_dict = ai_message.dict() response_message = ResponseMessage(**ai_message_dict) response_message.ask_for_nps = ask_for_nps diff --git a/backend/utils/retrieval/agentic.py b/backend/utils/retrieval/agentic.py index 00a31df2432..990ae3883b2 100644 --- a/backend/utils/retrieval/agentic.py +++ b/backend/utils/retrieval/agentic.py @@ -657,5 +657,11 @@ async def execute_agentic_chat_stream( traceback.print_exc() if callback_data is not None: callback_data['error'] = str(e) + # Ensure answer is always set so generate_stream can emit the done: line + # and the frontend receives clean text even when an exception cut the + # normal success path before line 644. + if 'answer' not in callback_data: + callback_data['answer'] = ''.join(full_response) + callback_data['memories_found'] = conversations_collected if conversations_collected else [] yield None # Signal completion diff --git a/backend/utils/retrieval/tools/conversation_tools.py b/backend/utils/retrieval/tools/conversation_tools.py index b92b7265684..664e723ede8 100644 --- a/backend/utils/retrieval/tools/conversation_tools.py +++ b/backend/utils/retrieval/tools/conversation_tools.py @@ -242,8 +242,15 @@ def get_conversations_tool( logger.info(f"πŸ” get_conversations_tool - Converted {len(conversations)} conversation objects") - # Store conversations in config for citation tracking (as lightweight dicts) - conversations_collected = config['configurable'].get('conversations_collected', []) + # Store conversations in config for citation tracking (as lightweight dicts). + # Prefer agent_config_context (guaranteed to be the original shared list from + # execute_agentic_chat_stream) over config['configurable'] (LangChain may pass + # a copy of the configurable dict, silently breaking the shared-list reference). + ctx = agent_config_context.get(None) + if ctx: + conversations_collected = ctx['configurable'].get('conversations_collected', []) + else: + conversations_collected = config['configurable'].get('conversations_collected', []) for conv in conversations: conv_dict = conv.dict() # Remove heavy fields to reduce memory usage @@ -477,8 +484,15 @@ def search_conversations_tool( logger.info(f"πŸ” search_conversations_tool - Converted {len(conversations)} conversation objects") - # Store conversations in config for citation tracking (as lightweight dicts) - conversations_collected = config['configurable'].get('conversations_collected', []) + # Store conversations in config for citation tracking (as lightweight dicts). + # Prefer agent_config_context (guaranteed to be the original shared list from + # execute_agentic_chat_stream) over config['configurable'] (LangChain may pass + # a copy of the configurable dict, silently breaking the shared-list reference). + ctx = agent_config_context.get(None) + if ctx: + conversations_collected = ctx['configurable'].get('conversations_collected', []) + else: + conversations_collected = config['configurable'].get('conversations_collected', []) for conv in conversations: conv_dict = conv.dict() # Remove heavy fields to reduce memory usage diff --git a/desktop/windows/.gitignore b/desktop/windows/.gitignore index 926d0b802c5..8705daf7803 100644 --- a/desktop/windows/.gitignore +++ b/desktop/windows/.gitignore @@ -50,3 +50,5 @@ resources/win-ocr-helper/*.pdb src/main/automation/helper/bin/ src/main/automation/helper/obj/ resources/win-automation-helper/*.pdb +*.tsbuildinfo +electron.vite.config.*.mjs diff --git a/desktop/windows/electron-builder.yml b/desktop/windows/electron-builder.yml index 1594c459b8a..521d334b3a7 100644 --- a/desktop/windows/electron-builder.yml +++ b/desktop/windows/electron-builder.yml @@ -19,6 +19,9 @@ asarUnpack: # koffi loads its native .node at runtime, resolved relative to its own package # dir β€” it must live outside the asar archive or the foreground monitor fails. - node_modules/koffi/** + # kgWorker.js is loaded via new Worker(path) which bypasses Electron's asar + # virtual-fs patch β€” it must be a real file on disk. + - out/main/kgWorker.js win: executableName: omi-windows target: diff --git a/desktop/windows/electron.vite.config.ts b/desktop/windows/electron.vite.config.ts index 93b74cf74d3..a0b304d68f8 100644 --- a/desktop/windows/electron.vite.config.ts +++ b/desktop/windows/electron.vite.config.ts @@ -1,9 +1,24 @@ +import { readFileSync } from 'node:fs' import { resolve } from 'path' import { defineConfig } from 'electron-vite' import react from '@vitejs/plugin-react' +const appVersion = (JSON.parse(readFileSync('./package.json', 'utf-8')) as { version: string }).version + export default defineConfig({ - main: {}, + main: { + build: { + rollupOptions: { + input: { + index: resolve('src/main/index.ts'), + // Second entry so vite emits out/main/kgWorker.js alongside index.js. + // The worker file must be a separate bundle (not inlined) because + // new Worker(path) needs a real file β€” it can't load from the main bundle. + kgWorker: resolve('src/main/ipc/kgWorker.ts') + } + } + } + }, preload: {}, renderer: { // Pin the dev server to a fixed port so the renderer's origin @@ -21,6 +36,9 @@ export default defineConfig({ '@renderer': resolve('src/renderer/src') } }, - plugins: [react()] + plugins: [react()], + define: { + __APP_VERSION__: JSON.stringify(appVersion) + } } }) diff --git a/desktop/windows/package.json b/desktop/windows/package.json index 77967d2062e..a522ec7ef3b 100644 --- a/desktop/windows/package.json +++ b/desktop/windows/package.json @@ -18,7 +18,7 @@ "build:ocr-helper": "powershell -ExecutionPolicy Bypass -File scripts/build-ocr-helper.ps1", "postinstall": "electron-builder install-app-deps && electron-rebuild -f -w better-sqlite3 && node scripts/ensure-ocr-helper.mjs", "build:unpack": "npm run build && electron-builder --dir", - "build:win": "npm run build && electron-builder --win --x64", + "build:win": "node scripts/copy-koffi-native.mjs && npm run build && electron-builder --win --x64", "build:mac": "electron-vite build && electron-builder --mac", "build:linux": "electron-vite build && electron-builder --linux", "test": "vitest run", @@ -34,8 +34,6 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", - "@react-three/drei": "^10.7.7", - "@react-three/fiber": "^9.6.1", "axios": "^1.16.1", "better-sqlite3": "^12.10.0", "class-variance-authority": "^0.7.1", @@ -47,13 +45,14 @@ "lucide-react": "^1.16.0", "react-router-dom": "^7.15.1", "tailwind-merge": "^3.6.0", - "three": "^0.184.0", "ws": "^8.21.0" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", "@electron/rebuild": "^4.0.4", "@types/better-sqlite3": "^7.6.13", "@types/d3-force": "^3.0.10", @@ -61,6 +60,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/three": "^0.184.1", + "three": "^0.184.0", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.5.0", diff --git a/desktop/windows/pnpm-lock.yaml b/desktop/windows/pnpm-lock.yaml index 59439f9b4d4..7f8f97a6a62 100644 --- a/desktop/windows/pnpm-lock.yaml +++ b/desktop/windows/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@39.8.10) - '@huggingface/transformers': - specifier: ^4.2.0 - version: 4.2.0 '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -32,12 +29,6 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@react-three/drei': - specifier: ^10.7.7 - version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0))(@types/react@19.2.16)(@types/three@0.184.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) - '@react-three/fiber': - specifier: ^9.6.1 - version: 9.6.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) axios: specifier: ^1.16.1 version: 1.17.0 @@ -71,9 +62,6 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 - three: - specifier: ^0.184.0 - version: 0.184.0 ws: specifier: ^8.21.0 version: 8.21.0 @@ -90,6 +78,12 @@ importers: '@electron/rebuild': specifier: ^4.0.4 version: 4.0.4 + '@react-three/drei': + specifier: ^10.7.7 + version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0))(@types/react@19.2.16)(@types/three@0.184.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) + '@react-three/fiber': + specifier: ^9.6.1 + version: 9.6.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.184.0) '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 @@ -153,6 +147,9 @@ importers: tailwindcss: specifier: ^3.4.19 version: 3.4.19 + three: + specifier: ^0.184.0 + version: 0.184.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -342,9 +339,6 @@ packages: engines: {node: '>=14.14'} hasBin: true - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -929,16 +923,6 @@ packages: engines: {node: '>=6'} hasBin: true - '@huggingface/jinja@0.5.9': - resolution: {integrity: sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==} - engines: {node: '>=18'} - - '@huggingface/tokenizers@0.1.3': - resolution: {integrity: sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==} - - '@huggingface/transformers@4.2.0': - resolution: {integrity: sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==} - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -959,159 +943,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -2000,10 +1831,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adm-zip@0.5.17: - resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} - engines: {node: '>=12.0'} - agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -2765,9 +2592,6 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatbuffers@25.9.23: - resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} - flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} @@ -2909,9 +2733,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - guid-typescript@1.0.9: - resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3460,19 +3281,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onnxruntime-common@1.24.0-dev.20251116-b39e144322: - resolution: {integrity: sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==} - - onnxruntime-common@1.24.3: - resolution: {integrity: sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==} - - onnxruntime-node@1.24.3: - resolution: {integrity: sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==} - os: [win32, darwin, linux] - - onnxruntime-web@1.26.0-dev.20260416-b7804b056c: - resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3545,9 +3353,6 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - platform@1.3.6: - resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} - plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -3901,10 +3706,6 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4719,7 +4520,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -4742,7 +4543,7 @@ snapshots: dir-compare: 4.2.0 fs-extra: 11.3.5 minimatch: 9.0.9 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -4757,11 +4558,6 @@ snapshots: - supports-color optional: true - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.25.12': optional: true @@ -5313,18 +5109,6 @@ snapshots: protobufjs: 7.6.2 yargs: 17.7.2 - '@huggingface/jinja@0.5.9': {} - - '@huggingface/tokenizers@0.1.3': {} - - '@huggingface/transformers@4.2.0': - dependencies: - '@huggingface/jinja': 0.5.9 - '@huggingface/tokenizers': 0.1.3 - onnxruntime-node: 1.24.3 - onnxruntime-web: 1.26.0-dev.20260416-b7804b056c - sharp: 0.34.5 - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5341,102 +5125,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.10.0 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -6283,8 +5971,7 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xmldom/xmldom@0.9.10': - optional: true + '@xmldom/xmldom@0.9.10': {} abbrev@4.0.0: {} @@ -6294,8 +5981,6 @@ snapshots: acorn@8.16.0: {} - adm-zip@0.5.17: {} - agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -6508,7 +6193,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - boolean@3.2.0: {} + boolean@3.2.0: + optional: true brace-expansion@1.1.15: dependencies: @@ -6807,7 +6493,8 @@ snapshots: detect-node-es@1.1.0: {} - detect-node@2.1.0: {} + detect-node@2.1.0: + optional: true didyoumean@1.2.2: {} @@ -7050,7 +6737,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - es6-error@4.1.1: {} + es6-error@4.1.1: + optional: true esbuild@0.25.12: optionalDependencies: @@ -7350,8 +7038,6 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 - flatbuffers@25.9.23: {} - flatted@3.4.2: {} follow-redirects@1.16.0: {} @@ -7484,6 +7170,7 @@ snapshots: roarr: 2.15.4 semver: 7.8.1 serialize-error: 7.0.1 + optional: true globals@14.0.0: {} @@ -7514,8 +7201,6 @@ snapshots: graceful-fs@4.2.11: {} - guid-typescript@1.0.9: {} - has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -7792,7 +7477,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - json-stringify-safe@5.0.1: {} + json-stringify-safe@5.0.1: + optional: true json5@2.2.3: {} @@ -7893,6 +7579,7 @@ snapshots: matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 + optional: true math-intrinsics@1.1.0: {} @@ -8052,25 +7739,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onnxruntime-common@1.24.0-dev.20251116-b39e144322: {} - - onnxruntime-common@1.24.3: {} - - onnxruntime-node@1.24.3: - dependencies: - adm-zip: 0.5.17 - global-agent: 3.0.0 - onnxruntime-common: 1.24.3 - - onnxruntime-web@1.26.0-dev.20260416-b7804b056c: - dependencies: - flatbuffers: 25.9.23 - guid-typescript: 1.0.9 - long: 5.3.2 - onnxruntime-common: 1.24.0-dev.20251116-b39e144322 - platform: 1.3.6 - protobufjs: 7.6.2 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8126,8 +7794,6 @@ snapshots: pirates@4.0.7: {} - platform@1.3.6: {} - plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13 @@ -8139,7 +7805,6 @@ snapshots: '@xmldom/xmldom': 0.9.10 base64-js: 1.5.1 xmlbuilder: 15.1.1 - optional: true possible-typed-array-names@1.1.0: {} @@ -8417,6 +8082,7 @@ snapshots: json-stringify-safe: 5.0.1 semver-compare: 1.0.0 sprintf-js: 1.1.3 + optional: true rollup@4.61.0: dependencies: @@ -8484,7 +8150,8 @@ snapshots: scheduler@0.27.0: {} - semver-compare@1.0.0: {} + semver-compare@1.0.0: + optional: true semver@5.7.2: {} @@ -8497,6 +8164,7 @@ snapshots: serialize-error@7.0.1: dependencies: type-fest: 0.13.1 + optional: true set-cookie-parser@2.7.2: {} @@ -8522,37 +8190,6 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.1 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8622,7 +8259,8 @@ snapshots: source-map@0.6.1: {} - sprintf-js@1.1.3: {} + sprintf-js@1.1.3: + optional: true stackback@0.0.2: {} @@ -8896,7 +8534,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@0.13.1: {} + type-fest@0.13.1: + optional: true typed-array-buffer@1.0.3: dependencies: diff --git a/desktop/windows/scripts/copy-koffi-native.mjs b/desktop/windows/scripts/copy-koffi-native.mjs new file mode 100644 index 00000000000..025dd26065b --- /dev/null +++ b/desktop/windows/scripts/copy-koffi-native.mjs @@ -0,0 +1,61 @@ +/** + * Copies the platform-specific koffi.node prebuilt binary from pnpm's virtual + * store into node_modules/koffi/build/koffi//, which is one of koffi's + * own runtime search paths and is already covered by asarUnpack: node_modules/koffi/**. + * + * Why this is needed: pnpm with node-linker=hoisted does not hoist optional + * scoped deps like @koromix/koffi-win32-x64 to top-level node_modules/, so + * koffi's static require('@koromix/...') fails. The binary only lives in the + * .pnpm/ virtual store. This script bridges the gap before electron-builder + * packages the app. + */ + +import { existsSync, cpSync, mkdirSync, readdirSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = join(__dirname, '..') +const pnpmStore = join(root, 'node_modules', '.pnpm') + +const platform = process.platform // e.g. win32 +const arch = process.arch // e.g. x64 +const triplet = `${platform}_${arch}` // e.g. win32_x64 +const scopedPkg = `koffi-${platform}-${arch}` // e.g. koffi-win32-x64 +const storePrefix = `@koromix+${scopedPkg}` // e.g. @koromix+koffi-win32-x64 + +let srcNode = null + +if (existsSync(pnpmStore)) { + for (const entry of readdirSync(pnpmStore)) { + if (!entry.startsWith(storePrefix)) continue + const candidate = join( + pnpmStore, + entry, + 'node_modules', + '@koromix', + scopedPkg, + triplet, + 'koffi.node' + ) + if (existsSync(candidate)) { + srcNode = candidate + break + } + } +} + +if (!srcNode) { + console.error( + `[copy-koffi] ERROR: Cannot find koffi.node for ${triplet} in ${pnpmStore}\n` + + ` Expected a directory matching: ${storePrefix}@*/node_modules/@koromix/${scopedPkg}/${triplet}/koffi.node` + ) + process.exit(1) +} + +const dest = join(root, 'node_modules', 'koffi', 'build', 'koffi', triplet, 'koffi.node') +mkdirSync(dirname(dest), { recursive: true }) +cpSync(srcNode, dest) +console.log( + `[copy-koffi] OK: copied koffi.node (${triplet}) β†’ node_modules/koffi/build/koffi/${triplet}/` +) diff --git a/desktop/windows/src/main/index.ts b/desktop/windows/src/main/index.ts index ab558764cdb..8cc37e1df90 100644 --- a/desktop/windows/src/main/index.ts +++ b/desktop/windows/src/main/index.ts @@ -1,5 +1,6 @@ -import { app, shell, BrowserWindow, ipcMain, session, nativeImage, desktopCapturer } from 'electron' +import { app, shell, BrowserWindow, ipcMain, session, nativeImage, desktopCapturer, Tray, Menu, dialog } from 'electron' import { join } from 'path' +import { readFileSync, writeFileSync } from 'fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import iconPath from '../../resources/icon.png?asset' import { listCaptureSources } from './ipc/capture' @@ -37,6 +38,7 @@ import { startRewindCapture } from './rewind/captureService' import { startRewindOcr } from './rewind/ocrService' import { startRewindRetention } from './rewind/retentionRunner' import { prewarmPrimarySourceId } from './rewind/sourceId' +import { getPersistedRewindSettings, persistRewindSettings } from './rewind/rewindSettings' import { perfMark, flushPerfMarks } from '../shared/perf' // Default the perf log to the user data dir so marks double as lightweight prod @@ -84,6 +86,93 @@ if (sandbox && process.env.OMI_BENCH !== '1') { } const icon = nativeImage.createFromPath(iconPath) + +// Keep a module-level reference so the tray isn't GC'd. +let tray: Tray | null = null +// Set to true when we're actually quitting so the close-to-tray intercept +// doesn't prevent shutdown. +let isQuitting = false +// Pending BLE device-selection callback from the `select-bluetooth-device` event. +// Stored at module level so the ipcMain handler (registered once in whenReady) +// can call it after the renderer returns the chosen device id. +let bluetoothCallback: ((deviceId: string) => void) | null = null +let bluetoothDeviceList: Array<{ deviceId: string; deviceName: string }> = [] +let bluetoothDialogOpen = false + +// Build (or rebuild) the tray context menu, reading live settings each time +// so the checked state reflects the current toggle value. +function navigateMain(mainWindow: BrowserWindow, route: string): void { + mainWindow.show() + mainWindow.focus() + void mainWindow.webContents.executeJavaScript(`window.location.hash = "#${route}"`) +} + +function buildTrayMenu(mainWindow: BrowserWindow): Electron.Menu { + const settings = getPersistedRewindSettings() + return Menu.buildFromTemplate([ + { + label: 'Open Omi', + click: () => { + mainWindow.show() + mainWindow.focus() + } + }, + { type: 'separator' }, + { + label: 'Screen Capture', + type: 'checkbox', + checked: settings.captureEnabled, + click: () => { + const current = getPersistedRewindSettings() + const updated = persistRewindSettings({ ...current, captureEnabled: !current.captureEnabled }) + for (const w of BrowserWindow.getAllWindows()) { + if (!w.isDestroyed()) w.webContents.send('rewind:settings', updated) + } + tray?.setContextMenu(buildTrayMenu(mainWindow)) + } + }, + { type: 'separator' }, + { + label: 'Open Chat', + click: () => navigateMain(mainWindow, '/chat') + }, + { + label: 'Open Rewind', + click: () => navigateMain(mainWindow, '/rewind') + }, + { + label: 'Open Focus', + click: () => navigateMain(mainWindow, '/focus') + }, + { type: 'separator' }, + { + label: 'Settings', + click: () => navigateMain(mainWindow, '/settings') + }, + { type: 'separator' }, + { + label: 'Quit Omi', + click: () => { + isQuitting = true + app.quit() + } + } + ]) +} + +function setupTray(mainWindow: BrowserWindow): void { + tray = new Tray(icon) + tray.setToolTip('Omi') + tray.setContextMenu(buildTrayMenu(mainWindow)) + + // Left-click on the tray icon shows / focuses the main window (matches macOS + // behavior where clicking the menu bar icon opens the app). + tray.on('click', () => { + mainWindow.show() + mainWindow.focus() + }) +} + import { remapConversationId, insertLocalConversation, @@ -93,19 +182,54 @@ import { updateLocalConversationTitle } from './ipc/db' +// ── Window bounds persistence ──────────────────────────────────────────────── +// Save/restore window position and size across restarts so the app reopens +// where the user left it β€” matches macOS NSWindow frame autosave behavior. +type WindowBounds = { x?: number; y?: number; width: number; height: number } + +function loadWindowBounds(): WindowBounds | null { + try { + return JSON.parse(readFileSync(join(app.getPath('userData'), 'main-window-bounds.json'), 'utf8')) as WindowBounds + } catch { + return null + } +} + +function saveWindowBounds(win: BrowserWindow): void { + if (win.isMaximized() || win.isMinimized() || win.isFullScreen()) return + try { + writeFileSync( + join(app.getPath('userData'), 'main-window-bounds.json'), + JSON.stringify(win.getBounds()) + ) + } catch { /* ignore write errors */ } +} + function createWindow(): BrowserWindow { // Create the browser window. 1280x820 gives the two-column Record layout // (transcript + screen sidebar) room without overflow; min-size prevents the // sidebar from clipping below a usable threshold. + const savedBounds = loadWindowBounds() const mainWindow = new BrowserWindow({ title: 'omi', - width: 1280, - height: 820, + width: savedBounds?.width ?? 1280, + height: savedBounds?.height ?? 820, + x: savedBounds?.x, + y: savedBounds?.y, minWidth: 1024, minHeight: 640, show: false, autoHideMenuBar: true, - frame: true, + titleBarStyle: 'hidden', + // Win11 Snap Layouts: native caption buttons rendered by the OS so hovering + // the maximize button shows the snap-grid. Transparent background lets the + // DWM Acrylic material show behind the buttons; symbolColor matches our + // text-white/45 design token. + titleBarOverlay: { + color: 'rgba(0,0,0,0)', + symbolColor: 'rgba(255,255,255,0.45)', + height: 32 + }, transparent: false, backgroundColor: '#121212', icon, @@ -116,7 +240,8 @@ function createWindow(): BrowserWindow { // Keep renderer timers running at full rate when the window is minimized/ // hidden, so Rewind's background screen capture keeps sampling instead of // being throttled to ~once/minute by Chromium's background policy. - backgroundThrottling: false + backgroundThrottling: false, + spellcheck: true } }) @@ -129,6 +254,9 @@ function createWindow(): BrowserWindow { mainWindow.on('ready-to-show', () => { mainWindow.show() }) + // Persist size+position on user resize/move so next launch restores them. + mainWindow.on('resize', () => saveWindowBounds(mainWindow)) + mainWindow.on('move', () => saveWindowBounds(mainWindow)) perfMark('window:created') // Allow Firebase + Google OAuth popups to open as real Electron windows so @@ -152,13 +280,13 @@ function createWindow(): BrowserWindow { } } } - // Hand only web/mail links to the OS. A prompt-injected chat reply could emit - // a file://, UNC, or custom-protocol URL; passing those to shell.openExternal - // enables NTLM-hash leak / protocol-handler abuse. Defense-in-depth alongside - // the renderer's Markdown scheme allow-list. + // Hand safe links to the OS. Block file://, UNC, and unknown protocols to + // prevent prompt-injection NTLM-hash-leak / protocol-handler abuse. + // obsidian: is allowed for the memory-export vault deep-link. try { const scheme = new URL(url).protocol - if (scheme === 'http:' || scheme === 'https:' || scheme === 'mailto:') { + const allowed = ['http:', 'https:', 'mailto:', 'obsidian:'] + if (allowed.includes(scheme)) { shell.openExternal(url) } else { console.warn('[main] blocked external open of non-web URL scheme:', scheme) @@ -169,6 +297,73 @@ function createWindow(): BrowserWindow { return { action: 'deny' } }) + // Bluetooth: handle device picker for Web Bluetooth requestDevice() calls. + // Collects nearby devices for 2 s (debounce), shows one filtered picker. + // Guard stays true through the dialog so a second event can never open a + // second dialog while the user is already looking at the first one. + mainWindow.webContents.on( + 'select-bluetooth-device', + async (event, deviceList, callback) => { + event.preventDefault() + // Accumulate devices as scanning finds them + for (const d of deviceList) { + if (!bluetoothDeviceList.some((x) => x.deviceId === d.deviceId)) { + bluetoothDeviceList.push({ deviceId: d.deviceId, deviceName: d.deviceName ?? '' }) + } + } + bluetoothCallback = callback + + // One dialog at a time β€” guard stays true until cb() is called below. + if (bluetoothDialogOpen) return + bluetoothDialogOpen = true + + // Debounce: wait 2 s so scanning can collect a richer device list. + await new Promise((r) => setTimeout(r, 2000)) + + const devs = bluetoothDeviceList + const cb = bluetoothCallback + bluetoothDeviceList = [] + bluetoothCallback = null + + const done = (id: string): void => { + bluetoothDialogOpen = false + if (cb) cb(id) + } + + if (!cb) { bluetoothDialogOpen = false; return } + + if (devs.length === 0) { + // Notify renderer so it can distinguish "no devices nearby" from "user cancelled". + mainWindow.webContents.send('bluetooth:noDevicesFound') + done('') + return + } + + // Named first; hide unnamed rows when at least one named device exists. + const named = devs.filter((d) => d.deviceName?.trim()) + const displayDevs = named.length > 0 ? named : devs + const hiddenCount = devs.length - displayDevs.length + + const buttons = [ + ...displayDevs.map((d) => d.deviceName || `Device (${d.deviceId.slice(0, 8)})`), + 'Cancel' + ] + const detail = hiddenCount > 0 + ? `${hiddenCount} unnamed device${hiddenCount !== 1 ? 's' : ''} not shown.` + : undefined + const { response } = await dialog.showMessageBox(mainWindow, { + title: 'Select a Bluetooth device', + message: `${displayDevs.length} device${displayDevs.length !== 1 ? 's' : ''} found. Choose your Omi or compatible device, or Cancel.`, + detail, + type: 'question', + buttons, + cancelId: displayDevs.length, + defaultId: 0 + }) + done(response < displayDevs.length ? displayDevs[response].deviceId : '') + } + ) + // HMR for renderer base on electron-vite cli. // Load the remote URL for development, or the loopback renderer server in // production β€” a file:// origin would break Firebase sign-in (see @@ -213,6 +408,19 @@ app.whenReady().then(async () => { optimizer.watchWindowShortcuts(window) }) + // Gate API access to only the permissions this app actively uses. + // `setPermissionCheckHandler` controls whether the browser API is exposed at all; + // `setDevicePermissionHandler` below auto-grants previously-seen BLE devices. + // Cast to unknown: Electron's TS defs don't include 'bluetooth' yet (added + // in Electron 22 / Chromium 100). + session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { + const p = permission as string + return p === 'bluetooth' || p === 'media' || p === 'display-capture' || p === 'notifications' || p === 'clipboard-sanitized-write' + }) + session.defaultSession.setDevicePermissionHandler((details) => { + return (details.deviceType as unknown as string) === 'bluetooth' + }) + // Omi's API doesn't advertise http://localhost:5173 as a CORS-allowed origin. // In Electron we control the network stack, so strip the Origin header on // outgoing requests and inject permissive CORS response headers. Scoped to @@ -315,8 +523,82 @@ app.whenReady().then(async () => { // cadence). Rewind handlers/services are already registered/deferred above + below. registerScreenSynthHandlers() + // Shell / app-info / dialog convenience handlers for the renderer. + ipcMain.on('shell:openExternal', (_e, url: string) => { + try { + const scheme = new URL(url).protocol + // Allow safe schemes: web links, mail, and the Obsidian vault deep-link + // used by the memory export panel. file://, UNC, and unknown protocols + // are blocked to prevent prompt-injection path abuse. + const allowed = ['http:', 'https:', 'mailto:', 'obsidian:'] + if (allowed.includes(scheme)) { + void shell.openExternal(url) + } + } catch { + console.warn('[main] blocked external open of unparseable URL') + } + }) + ipcMain.handle('app:getVersion', () => app.getVersion()) + ipcMain.handle('app:checkForUpdates', async () => { + // No auto-updater wired yet β€” resolve immediately. + }) + ipcMain.handle('dialog:pickDirectory', async () => { + const result = await dialog.showOpenDialog({ properties: ['openDirectory'] }) + return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0] + }) + ipcMain.handle('app:getLoginItem', () => app.getLoginItemSettings().openAtLogin) + ipcMain.handle('app:setLoginItem', (_e, enabled: boolean) => { + app.setLoginItemSettings({ openAtLogin: enabled }) + }) + const mainWindow = createWindow() + // Apply Win11 Mica/Acrylic DWM backdrop to the main window β€” same technique as + // the overlay (applyOverlayMaterial). The renderer body uses a semi-transparent + // background so the material peeks through, giving native-vibrancy depth. + // Wrapped in try/catch: setBackgroundMaterial throws on Win10 / old Electron. + if (process.platform === 'win32') { + const tryMaterial = (m: 'acrylic' | 'mica'): boolean => { + try { + const w = mainWindow as BrowserWindow & { setBackgroundMaterial?: (m: string) => void } + if (typeof w.setBackgroundMaterial !== 'function') return false + w.setBackgroundMaterial(m) + return true + } catch { return false } + } + if (!tryMaterial('acrylic')) tryMaterial('mica') + } + + // Window-specific IPC β€” needs mainWindow reference. + ipcMain.handle('window:getAlwaysOnTop', () => mainWindow.isAlwaysOnTop()) + ipcMain.handle('window:setAlwaysOnTop', (_e, enabled: boolean) => { + mainWindow.setAlwaysOnTop(enabled, 'floating') + }) + ipcMain.on('win:minimize', () => mainWindow.minimize()) + ipcMain.on('win:maximize', () => { + if (mainWindow.isMaximized()) mainWindow.unmaximize() + else mainWindow.maximize() + }) + ipcMain.on('win:close', () => mainWindow.close()) + + // System tray β€” mirrors macOS menu bar icon. Created immediately so the tray + // appears as soon as the app launches. Left-click and "Open Omi" show the window. + setupTray(mainWindow) + + // Close-to-tray: intercept the window's close event and hide it instead of + // destroying it, so Omi keeps running in the system tray just like the macOS + // menu-bar app. The tray "Quit Omi" item sets isQuitting=true so the actual + // quit path still works. + app.on('before-quit', () => { + isQuitting = true + }) + mainWindow.on('close', (e) => { + if (!isQuitting) { + e.preventDefault() + mainWindow.hide() + } + }) + // Defer non-essential background services until the window is ready to show, so // their synchronous setup (foreground-monitor koffi/user32 init ~60ms, rewind // capture/OCR/retention loops, screen-source prewarm) runs AFTER first paint diff --git a/desktop/windows/src/main/ipc/db.ts b/desktop/windows/src/main/ipc/db.ts index 6d6d989b112..a0a6591273d 100644 --- a/desktop/windows/src/main/ipc/db.ts +++ b/desktop/windows/src/main/ipc/db.ts @@ -68,12 +68,12 @@ function get(): Database.Database { // never reads or writes the user's real omi.db. const file = process.env.OMI_DB_PATH ?? join(app.getPath('userData'), 'omi.db') db = new Database(file) - // For the throwaway bench DB only, relax durability so seeding ~7k rows isn't - // dominated by a per-insert fsync (otherwise it swamps the startup measurement). - if (process.env.OMI_DB_PATH) { - db.pragma('journal_mode = WAL') - db.pragma('synchronous = NORMAL') - } + // WAL mode: allows reads on the main thread to proceed concurrently while the + // KG write worker holds the write lock. NORMAL sync is crash-safe in WAL mode + // (may lose the last committed transaction on OS power-loss; acceptable for + // this derived cache). Previously bench-only; now unconditional. + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') // Migrate away the incompatible local_kg_* schema from the parked KG experiment. dropIfMissingColumn(db, 'local_kg_nodes', 'summary') dropIfMissingColumn(db, 'local_kg_edges', 'id') @@ -531,7 +531,9 @@ export function queryKgNodes(q: string, limit = 12): LocalKnowledgeGraph { aliases: parseJsonArray(r.aliasesJson), sourceRefs: parseJsonArray(r.sourceRefs) })) - if (nodes.length === 0) return { nodes: [], edges: [] } + if (nodes.length === 0) { + return { nodes: [], edges: [] } + } const ids = nodes.map((n) => n.id) const placeholders = ids.map(() => '?').join(',') const edges = d diff --git a/desktop/windows/src/main/ipc/kg.ts b/desktop/windows/src/main/ipc/kg.ts index a2a9800d250..dbbd55c51b1 100644 --- a/desktop/windows/src/main/ipc/kg.ts +++ b/desktop/windows/src/main/ipc/kg.ts @@ -1,23 +1,132 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' +import { join } from 'path' +import { Worker } from 'worker_threads' import { execSafeSelect, getFileIndexDigest, getLocalKGStatus, queryKgNodes, - replaceLocalGraph, searchIndexedFiles } from './db' import { guardSelect } from '../../shared/sqlGuard' import type { LocalKnowledgeGraph } from '../../shared/types' -// All local-knowledge-graph IPC. Kept in this dedicated module so registration -// is a single append in index.ts (conflict discipline with the concurrent -// integrations/Settings work). +// --------------------------------------------------------------------------- +// KG write worker +// +// Writes run in a worker_thread so the Electron main thread stays free for +// IPC during the synchronous DELETE+INSERT transaction. +// +// Lifecycle: +// - Worker is created lazily on the first kg:saveGraph call. +// - At most one write runs at a time; subsequent kg:saveGraph calls are +// coalesced β€” only the latest pending graph is kept. +// - Reads (queryNodes / status) run on the main thread via WAL mode. +// - kgSnapshot caches the last successfully written graph so empty-query +// reads skip SQLite entirely. +// --------------------------------------------------------------------------- + +let worker: Worker | null = null +let workerBusy = false +let pendingGraph: LocalKnowledgeGraph | null = null +let lastDispatched: LocalKnowledgeGraph | null = null +let kgSnapshot: LocalKnowledgeGraph | null = null + +function dbPath(): string { + return process.env.OMI_DB_PATH ?? join(app.getPath('userData'), 'omi.db') +} + +function workerScriptPath(): string { + // Packaged builds: kgWorker.js is unpacked from the asar (see electron-builder.yml). + // Dev: vite emits kgWorker.js into out/main/ alongside index.js. + if (app.isPackaged) { + return join(process.resourcesPath, 'app.asar.unpacked', 'out', 'main', 'kgWorker.js') + } + return join(__dirname, 'kgWorker.js') +} + +function ensureWorker(): Worker { + if (worker) return worker + worker = new Worker(workerScriptPath(), { workerData: { dbPath: dbPath() } }) + worker.on('message', (msg: { type: string; ms?: number; message?: string }) => { + if (msg.type === 'done') { + kgSnapshot = lastDispatched + } else if (msg.type === 'error') { + console.error('[kg:worker] saveGraph error:', msg.message) + } + workerBusy = false + flushPending() + }) + worker.on('error', (err) => { + console.error('[kg:worker] crash:', err.message) + worker = null + workerBusy = false + flushPending() + }) + return worker +} + +function flushPending(): void { + if (pendingGraph !== null) { + const next = pendingGraph + pendingGraph = null + dispatch(next) + } +} + +function dispatch(graph: LocalKnowledgeGraph): void { + workerBusy = true + lastDispatched = graph + try { + ensureWorker().postMessage({ type: 'replace', nodes: graph.nodes, edges: graph.edges }) + } catch (err) { + // Worker construction failed (e.g. missing kgWorker.js in packaged build). + // Reset state so future saves can retry rather than being silently dropped. + console.error('[kg:worker] failed to dispatch:', (err as Error).message) + worker = null + workerBusy = false + flushPending() + } +} + +function enqueueGraph(graph: LocalKnowledgeGraph): void { + if (workerBusy) { + pendingGraph = graph + return + } + dispatch(graph) +} + +// --------------------------------------------------------------------------- +// IPC handlers +// --------------------------------------------------------------------------- + export function registerKgHandlers(): void { ipcMain.handle('kg:fileIndexDigest', async () => getFileIndexDigest()) - ipcMain.handle('kg:saveGraph', async (_e, graph: LocalKnowledgeGraph) => replaceLocalGraph(graph)) - ipcMain.handle('kg:status', async () => getLocalKGStatus()) - ipcMain.handle('kg:queryNodes', async (_e, q: string, limit?: number) => queryKgNodes(q, limit)) + + // Offloaded to worker β€” returns immediately, write completes asynchronously. + ipcMain.handle('kg:saveGraph', (_e, graph: LocalKnowledgeGraph) => { + enqueueGraph(graph) + }) + + ipcMain.handle('kg:status', () => getLocalKGStatus()) + + ipcMain.handle('kg:queryNodes', (_e, q: string, limit?: number) => { + // Resolve cap once so snapshot and DB paths always return the same count. + const cap = limit ?? 80 + if (q === '' && kgSnapshot !== null) { + // Hot path: serve from in-memory snapshot, no SQLite access required. + const nodes = kgSnapshot.nodes + .slice() + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, cap) + const idSet = new Set(nodes.map((n) => n.id)) + const edges = kgSnapshot.edges.filter((e) => idSet.has(e.sourceId) || idSet.has(e.targetId)) + return { nodes, edges } + } + return queryKgNodes(q, cap) + }) + ipcMain.handle('kg:searchFiles', async (_e, q: string, fileType?: string, limit?: number) => searchIndexedFiles(q, fileType, limit) ) diff --git a/desktop/windows/src/main/ipc/kgWorker.ts b/desktop/windows/src/main/ipc/kgWorker.ts new file mode 100644 index 00000000000..0eceba32d40 --- /dev/null +++ b/desktop/windows/src/main/ipc/kgWorker.ts @@ -0,0 +1,72 @@ +/** + * KG write worker β€” runs in a Node.js worker_thread so the Electron main + * thread stays free for IPC during the synchronous SQLite replace transaction. + * + * Protocol (parentPort messages): + * Receive: { type: 'replace'; nodes: KgNode[]; edges: KgEdge[] } + * Send: { type: 'done'; ms: number } + * { type: 'error'; message: string } + * + * workerData: { dbPath: string } + */ +import { parentPort, workerData } from 'worker_threads' +import Database from 'better-sqlite3' + +const d = new Database((workerData as { dbPath: string }).dbPath) +// WAL: readers on the main thread are not blocked while we hold the write lock. +d.pragma('journal_mode = WAL') +d.pragma('synchronous = NORMAL') + +// Prepare all statements once at startup. +const insertNode = d.prepare( + `INSERT OR REPLACE INTO local_kg_nodes + (id, label, node_type, summary, source, created_at, aliases_json, source_refs) + VALUES (@id, @label, @nodeType, @summary, @source, @createdAt, @aliasesJson, @sourceRefs)` +) +const insertEdge = d.prepare( + `INSERT OR REPLACE INTO local_kg_edges (id, source_id, target_id, label, created_at) + VALUES (@id, @sourceId, @targetId, @label, @createdAt)` +) +const deleteEdges = d.prepare('DELETE FROM local_kg_edges') +const deleteNodes = d.prepare('DELETE FROM local_kg_nodes') + +type KgNode = { + id: string + label: string + nodeType: string + summary: string + source: string + createdAt: number + aliases?: string[] + sourceRefs?: string[] +} +type KgEdge = { id: string; sourceId: string; targetId: string; label: string; createdAt: number } + +const doReplace = d.transaction((nodes: KgNode[], edges: KgEdge[]) => { + deleteEdges.run() + deleteNodes.run() + for (const n of nodes) { + insertNode.run({ + id: n.id, + label: n.label, + nodeType: n.nodeType, + summary: n.summary, + source: n.source, + createdAt: n.createdAt, + aliasesJson: n.aliases?.length ? JSON.stringify(n.aliases) : null, + sourceRefs: n.sourceRefs?.length ? JSON.stringify(n.sourceRefs) : null + }) + } + for (const e of edges) insertEdge.run(e) +}) + +parentPort!.on('message', (msg: { type: string; nodes: KgNode[]; edges: KgEdge[] }) => { + if (msg.type !== 'replace') return + const t0 = performance.now() + try { + doReplace(msg.nodes, msg.edges) + parentPort!.postMessage({ type: 'done', ms: Math.round(performance.now() - t0) }) + } catch (err) { + parentPort!.postMessage({ type: 'error', message: (err as Error).message }) + } +}) diff --git a/desktop/windows/src/main/ipc/usage.ts b/desktop/windows/src/main/ipc/usage.ts index 82446d93097..d3f1e023afb 100644 --- a/desktop/windows/src/main/ipc/usage.ts +++ b/desktop/windows/src/main/ipc/usage.ts @@ -7,11 +7,19 @@ import { startForegroundMonitor, stopForegroundMonitor } from '../usage/foregroundMonitor' +import { getForegroundWindowInfo } from '../usage/nativeForeground' import { seedUserAssistOnce } from '../usage/userAssistSeed' import type { UsageSettings } from '../../shared/types' export function registerUsageHandlers(): void { ipcMain.handle('usage:list', async () => listAppUsage()) + ipcMain.handle('usage:foregroundNow', () => { + try { + return getForegroundWindowInfo() + } catch { + return null + } + }) // Force an immediate flush of the in-memory tally, then return the fresh rows. ipcMain.handle('usage:flush', async () => { flushForegroundMonitor() diff --git a/desktop/windows/src/main/overlay/ipc.ts b/desktop/windows/src/main/overlay/ipc.ts index 972df65fd21..8ae8ccde34f 100644 --- a/desktop/windows/src/main/overlay/ipc.ts +++ b/desktop/windows/src/main/overlay/ipc.ts @@ -49,4 +49,19 @@ export function registerOverlayHandlers(focusMain: () => void): void { if (!w.isDestroyed()) w.webContents.send('overlay:asked') } }) + + // Overlay citation click: hide the overlay, focus the main window, and tell + // the main window's renderer to navigate to the given route. + ipcMain.on('overlay:openMainRoute', (e, route: string) => { + if (typeof route !== 'string' || !route.startsWith('/')) return + hideOverlay() + focusMain() + // Send the route to every window except the overlay (identified as the sender). + // The main window renderer listens for 'overlay:mainRoute' via window.omi.onOverlayRoute. + for (const w of BrowserWindow.getAllWindows()) { + if (!w.isDestroyed() && w.webContents !== e.sender) { + w.webContents.send('overlay:mainRoute', route) + } + } + }) } diff --git a/desktop/windows/src/main/overlay/window.ts b/desktop/windows/src/main/overlay/window.ts index b18aea06708..93cf4e5c4b9 100644 --- a/desktop/windows/src/main/overlay/window.ts +++ b/desktop/windows/src/main/overlay/window.ts @@ -41,7 +41,12 @@ export function createOverlayWindow(): BrowserWindow { // shortcut / Esc only. titleBarStyle 'hidden' keeps the window frame, so Win11 // still rounds the corners and the Mica/acrylic material renders. titleBarStyle: 'hidden', - resizable: false, + resizable: true, + // Lock width to the CSS layout width (zoom layer is 480px Γ— 0.7 = 336px). + // Height is content-driven by the tween, but can also be manually dragged. + minWidth: OVERLAY_WIDTH, + maxWidth: OVERLAY_WIDTH, + minHeight: 80, skipTaskbar: true, alwaysOnTop: true, hasShadow: true, diff --git a/desktop/windows/src/preload/index.ts b/desktop/windows/src/preload/index.ts index fd2263cef2c..44e82d9bc72 100644 --- a/desktop/windows/src/preload/index.ts +++ b/desktop/windows/src/preload/index.ts @@ -150,6 +150,16 @@ const omi: OmiBridgeApi = { const listener = (): void => cb() ipcRenderer.on('conversations:changed', listener) return () => ipcRenderer.removeListener('conversations:changed', listener) + }, + onBluetoothNoDevices: (cb: () => void) => { + const listener = (): void => cb() + ipcRenderer.on('bluetooth:noDevicesFound', listener) + return () => ipcRenderer.removeListener('bluetooth:noDevicesFound', listener) + }, + onOverlayRoute: (cb: (route: string) => void) => { + const listener = (_e: Electron.IpcRendererEvent, route: string): void => cb(route) + ipcRenderer.on('overlay:mainRoute', listener) + return () => ipcRenderer.removeListener('overlay:mainRoute', listener) } } @@ -201,9 +211,45 @@ const omiOverlay: OmiOverlayApi = { const listener = (): void => cb() ipcRenderer.on('overlay:asked', listener) return () => ipcRenderer.removeListener('overlay:asked', listener) + }, + openMainRoute: (route: string) => ipcRenderer.send('overlay:openMainRoute', route), + onNotification: (cb: (n: { title: string; body: string }) => void) => { + const listener = (_e: Electron.IpcRendererEvent, n: { title: string; body: string }): void => cb(n) + ipcRenderer.on('overlay:notification', listener) + return () => ipcRenderer.removeListener('overlay:notification', listener) } } +// Extend the omi bridge with optional convenience methods (shell, app info). +// Cast to `any` here so we can add properties not in the frozen OmiBridgeApi type. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).openExternal = (url: string): void => { + ipcRenderer.send('shell:openExternal', url) +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).getAppVersion = (): Promise => ipcRenderer.invoke('app:getVersion') +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).checkForUpdates = (): Promise => ipcRenderer.invoke('app:checkForUpdates') +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).pickDirectory = (): Promise => ipcRenderer.invoke('dialog:pickDirectory') +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).getLoginItem = (): Promise => ipcRenderer.invoke('app:getLoginItem') +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).setLoginItem = (enabled: boolean): Promise => ipcRenderer.invoke('app:setLoginItem', enabled) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).setAlwaysOnTop = (enabled: boolean): Promise => ipcRenderer.invoke('window:setAlwaysOnTop', enabled) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).getAlwaysOnTop = (): Promise => ipcRenderer.invoke('window:getAlwaysOnTop') +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).winMinimize = (): void => ipcRenderer.send('win:minimize') +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).winMaximize = (): void => ipcRenderer.send('win:maximize') +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).winClose = (): void => ipcRenderer.send('win:close') +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(omi as any).getForegroundNow = (): Promise<{ handle: string | null; exePath: string | null; className: string | null } | null> => + ipcRenderer.invoke('usage:foregroundNow') + if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI) diff --git a/desktop/windows/src/renderer/index.html b/desktop/windows/src/renderer/index.html index d4443027ccc..4347f29fc9f 100644 --- a/desktop/windows/src/renderer/index.html +++ b/desktop/windows/src/renderer/index.html @@ -9,9 +9,9 @@ content=" default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' blob: https://apis.google.com https://www.gstatic.com https://cdn.jsdelivr.net; - style-src 'self' 'unsafe-inline'; + style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https: https://*.googleusercontent.com; - font-src 'self' data:; + font-src 'self' data: https://fonts.gstatic.com; media-src 'self' blob:; connect-src 'self' https://identitytoolkit.googleapis.com @@ -31,6 +31,12 @@ worker-src 'self' blob:; " /> + + + diff --git a/desktop/windows/src/renderer/src/App.tsx b/desktop/windows/src/renderer/src/App.tsx index a4da0ece6c6..a7478d455d3 100644 --- a/desktop/windows/src/renderer/src/App.tsx +++ b/desktop/windows/src/renderer/src/App.tsx @@ -4,6 +4,7 @@ import { useAuth } from './hooks/useAuth' import { Login } from './pages/Login' import { Sidebar } from './components/layout/Sidebar' import { MainViews } from './components/layout/MainViews' +import { TitleBar } from './components/layout/TitleBar' import { Spinner } from './components/ui/Spinner' import { purgeAppMemoriesOnce } from './lib/appMemories' import { AppStateProvider, useAppState } from './state/AppStateProvider' @@ -13,9 +14,8 @@ import { SourcePicker } from './components/SourcePicker' // brain map β€” wrapping the page in Suspense breaks the BrainGraph render. The // direct import keeps the map reliable; the bundle-size win is not worth it. import { Onboarding } from './pages/Onboarding' -import { consumePendingRoute } from './lib/preferences' +import { consumePendingRoute, getPreferences, setPreferences, onPreferencesChange } from './lib/preferences' import { useOnboardingComplete } from './hooks/useOnboardingComplete' -import { getPreferences } from './lib/preferences' import { SandboxBadge } from './components/SandboxBadge' import { OverlayApp } from './components/overlay/OverlayApp' import { RewindCaptureHost } from './components/rewind/RewindCaptureHost' @@ -23,6 +23,10 @@ import { ContinuousRecordingHost } from './components/recording/ContinuousRecord import { invalidateConversationsCache } from './lib/pageCache' import { runAnimBench } from './lib/animBench' import { InsightToast } from './components/insight/InsightToast' +import { GoalCelebration } from './components/ui/GoalCelebration' +import { LiveTranscriptPanel } from './components/recording/LiveTranscriptPanel' +import { LiveNotesPanel } from './components/recording/LiveNotesPanel' +import { ToastHost } from './components/ui/ToastHost' function AppShellInner(): React.JSX.Element { const { recorder, pickerOpen, setPickerOpen } = useAppState() @@ -32,12 +36,26 @@ function AppShellInner(): React.JSX.Element { const navigate = useNavigate() const hideSidebar = pathname === '/settings' + // Persist the active route to localStorage so the app restores the last page + // on relaunch β€” matches macOS which remembers the selected sidebar item. + // Skip /settings (ephemeral full-screen view) and /home (the implicit default). + useEffect(() => { + if (pathname !== '/settings' && pathname !== '/home') { + localStorage.setItem('omi.lastRoute', pathname) + } + }, [pathname]) + // Honor a one-shot destination requested by onboarding (e.g. the final // "Take me to my tasks" button). The shell mounts at /home after the // onboarding gate redirects; we consume the pending route here and jump to it. + // Fall through to the persisted last-route if no pending route. useEffect(() => { const dest = consumePendingRoute() - if (dest) navigate(dest, { replace: true }) + if (dest) { navigate(dest, { replace: true }); return } + const saved = localStorage.getItem('omi.lastRoute') + if (saved && saved.startsWith('/') && saved !== '/home') { + navigate(saved, { replace: true }) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -60,8 +78,50 @@ function AppShellInner(): React.JSX.Element { // here so this window's Conversations tab refreshes without a relaunch. useEffect(() => window.omi.onConversationsChanged(() => invalidateConversationsCache()), []) + // Overlay citation cards call openMainRoute() β†’ main sends 'overlay:mainRoute' here. + // Navigate the main window to the target route (e.g. /conversations/:id). + useEffect(() => window.omi.onOverlayRoute((route) => navigate(route)), [navigate]) + + // Font scale β€” applies the persisted scale to the root element so all + // rem-based Tailwind text utilities scale uniformly. Matches macOS Cmd++/βˆ’. + // Only runs in the main app shell (not the overlay window, which bypasses + // AppShellInner and uses its own zoom transform). + useEffect(() => { + const apply = (scale: number): void => { + document.documentElement.style.fontSize = `${scale * 100}%` + } + apply(getPreferences().fontScale ?? 1.0) + return onPreferencesChange((p) => apply(p.fontScale ?? 1.0)) + }, []) + + // Ctrl+= / Ctrl++ β€” increase font scale (5% per step, max 125%) + // Ctrl+- β€” decrease font scale (5% per step, min 85%) + // Ctrl+0 β€” reset to 100% + useEffect(() => { + const handler = (e: KeyboardEvent): void => { + if (!e.ctrlKey || e.altKey || e.metaKey) return + const tag = (document.activeElement as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + const step = 0.05 + if (e.key === '=' || e.key === '+') { + e.preventDefault() + const next = Math.min(1.25, Math.round(((getPreferences().fontScale ?? 1.0) + step) * 100) / 100) + setPreferences({ fontScale: next }) + } else if (e.key === '-') { + e.preventDefault() + const next = Math.max(0.85, Math.round(((getPreferences().fontScale ?? 1.0) - step) * 100) / 100) + setPreferences({ fontScale: next }) + } else if (e.key === '0') { + e.preventDefault() + setPreferences({ fontScale: 1.0 }) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + return ( -
+
{!hideSidebar && }
@@ -83,6 +143,12 @@ function AppShellInner(): React.JSX.Element { {/* Always-on mic capture for continuous recording mode. */} + {/* Goal completion celebration β€” fullscreen confetti + text overlay. */} + + {/* Floating live transcript panel β€” mirrors macOS LiveTranscriptPanel. */} + + {/* Floating live notes panel β€” mirrors macOS LiveNotesView. */} +
) } @@ -102,6 +168,14 @@ function AppShell(): React.JSX.Element { ) } +// Renders the custom title bar on all routes except the overlay/insight windows, +// which run in their own BrowserWindow with titleBarStyle:'hidden' + no caption buttons. +function ConditionalTitleBar(): React.JSX.Element | null { + const { pathname } = useLocation() + if (pathname === '/overlay' || pathname === '/insight-toast') return null + return +} + function App(): React.JSX.Element { const { user, loading } = useAuth() // Under the perf bench, treat the user as already onboarded so the authed @@ -128,17 +202,24 @@ function App(): React.JSX.Element { }, []) if (loading) { + const isSpecialWindow = + window.location.hash.includes('overlay') || window.location.hash.includes('insight-toast') return ( -
- - +
+ {!isSpecialWindow && } +
+ + +
) } return ( + + } /> } /> diff --git a/desktop/windows/src/renderer/src/assets/base.css b/desktop/windows/src/renderer/src/assets/base.css index 5ed6406a34f..4ef6f314687 100644 --- a/desktop/windows/src/renderer/src/assets/base.css +++ b/desktop/windows/src/renderer/src/assets/base.css @@ -60,7 +60,10 @@ body { 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + sans-serif, + 'Segoe UI Emoji', + 'Apple Color Emoji', + 'Noto Color Emoji'; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/desktop/windows/src/renderer/src/components/Markdown.tsx b/desktop/windows/src/renderer/src/components/Markdown.tsx index 59f3f2436e8..a6a252e8383 100644 --- a/desktop/windows/src/renderer/src/components/Markdown.tsx +++ b/desktop/windows/src/renderer/src/components/Markdown.tsx @@ -10,6 +10,9 @@ // italic so `**x**` matches bold, not two italics. const INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\))/g +const MONO = + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace' + function renderInline(text: string): React.ReactNode[] { return text.split(INLINE).map((part, i) => { if (!part) return null @@ -17,7 +20,11 @@ function renderInline(text: string): React.ReactNode[] { return {part.slice(2, -2)} if (part.startsWith('`') && part.endsWith('`')) return ( - + {part.slice(1, -1)} ) @@ -37,7 +44,14 @@ function renderInline(text: string): React.ReactNode[] { const href = link[2].trim() if (/^(https?:|mailto:)/i.test(href)) return ( - + {link[1]} ) @@ -62,22 +76,46 @@ export function Markdown({ text }: { text: string }): React.JSX.Element { const line = lines[i] if (FENCE.test(line.trim())) { + // Extract optional language label from the opening fence (e.g. ```python) + const lang = line.trim().slice(3).trim() || null const buf: string[] = [] i++ while (i < lines.length && !FENCE.test(lines[i].trim())) buf.push(lines[i++]) i++ // consume closing fence blocks.push( -
-          {buf.join('\n')}
-        
+
+ {lang && ( +
+ + {lang} + +
+ )} +
+            {buf.join('\n')}
+          
+
) continue } const h = HEADING.exec(line) if (h) { + const level = h[1].length + const cls = + level === 1 + ? 'mb-1 mt-3 text-base font-semibold text-white' + : level === 2 + ? 'mb-0.5 mt-2.5 text-sm font-semibold text-white' + : 'mb-0.5 mt-2 text-sm font-medium text-white/90' blocks.push( -

+

{renderInline(h[2])}

) diff --git a/desktop/windows/src/renderer/src/components/chat/ChatMessages.tsx b/desktop/windows/src/renderer/src/components/chat/ChatMessages.tsx index 57f3f56e7de..459ed1eddae 100644 --- a/desktop/windows/src/renderer/src/components/chat/ChatMessages.tsx +++ b/desktop/windows/src/renderer/src/components/chat/ChatMessages.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react' -import type { ChatMsg } from '../../hooks/useChat' +import { Link } from 'react-router-dom' +import { Copy, Check } from 'lucide-react' +import type { ChatMsg, ChatCitation } from '../../hooks/useChat' import { Markdown } from '../Markdown' // Smooth text reveal, decoupled from SSE chunk sizes so a reply streams in evenly @@ -7,6 +9,15 @@ import { Markdown } from '../Markdown' const REVEAL_MS = 16 const REVEAL_MIN_CHARS = 2 +// Snap a code-unit index to a safe boundary so we never slice inside a +// surrogate pair (emoji above U+FFFF). If the character just before `n` is a +// high surrogate, advance by 1 to include its paired low surrogate. +function snapBoundary(text: string, n: number): number { + if (n <= 0 || n >= text.length) return n + const code = text.charCodeAt(n - 1) + return code >= 0xd800 && code <= 0xdbff ? n + 1 : n +} + function RevealMarkdown({ text, startRevealed @@ -28,60 +39,217 @@ function RevealMarkdown({ }, REVEAL_MS) return () => clearInterval(id) }, []) - return + return } +/** Animated 3-dot typing indicator β€” mirrors macOS TypingIndicator component */ +function TypingIndicator(): React.JSX.Element { + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ) +} + +// macOS-exact bubble spec: +// User: bg #43389F (OmiColors.userBubble), corner 20pt continuous, sharp bottom-right +// Assistant: bg #252525 @ 95% opacity (OmiColors.backgroundTertiary), sharp bottom-left +// Padding: 14px horiz / 10px vert (matches .padding(.horizontal, 14).padding(.vertical, 10)) +// Gap: 18pt between messages (LazyVStack spacing: 18) const BUBBLE: Record<'main' | 'overlay', { user: string; assistant: string }> = { main: { - user: 'glass ml-auto max-w-[85%] rounded-2xl rounded-br-md px-4 py-3 text-sm leading-relaxed text-white', + user: + 'bg-[#43389F] ml-auto max-w-[85%] rounded-[20px] rounded-br-[6px] px-[14px] py-[10px] text-sm leading-snug text-white select-text', assistant: - 'glass-subtle mr-auto max-w-[85%] rounded-2xl rounded-bl-md px-4 py-3 text-sm leading-relaxed text-white/75' + 'bg-[#252525]/95 mr-auto max-w-[85%] rounded-[20px] rounded-bl-[6px] px-[14px] py-[10px] text-sm leading-snug text-white/85 select-text' }, - // Same bubble design as the main window (Home) β€” shape, padding, asymmetric - // corner, and the bubble-in entrance animation β€” but keeping the overlay's - // neutral colors (the floating bar's dark acrylic, not Home's accent/white). overlay: { - user: 'bubble-in ml-auto w-fit max-w-[80%] rounded-2xl rounded-br-md bg-neutral-700/70 px-3.5 py-2 text-sm leading-snug text-neutral-100', + user: 'bubble-in ml-auto w-fit max-w-[80%] rounded-2xl rounded-br-md bg-neutral-700/70 px-3.5 py-2 text-sm leading-snug text-neutral-100 select-text', assistant: - 'bubble-in mr-auto w-fit max-w-[80%] rounded-2xl rounded-bl-md bg-neutral-800/60 px-3.5 py-2 text-sm leading-snug text-neutral-100' + 'bubble-in mr-auto w-fit max-w-[80%] rounded-2xl rounded-bl-md bg-neutral-800/60 px-3.5 py-2 text-sm leading-snug text-neutral-100 select-text' + } +} + +// Message truncation at 500 chars β€” matches macOS behavior +const TRUNCATE_AT = 500 + +function TruncatedContent({ text }: { text: string }): React.JSX.Element { + const [expanded, setExpanded] = useState(false) + if (text.length <= TRUNCATE_AT || expanded) { + return } + return ( + <> + + + + ) +} + +function CitationCards({ + citations, + variant +}: { + citations: ChatCitation[] + variant: 'main' | 'overlay' +}): React.JSX.Element { + const cardClass = + 'flex w-full items-center gap-2.5 rounded-lg border border-white/[0.12] bg-white/[0.06] px-3 py-2.5 text-left transition-colors hover:border-white/20 hover:bg-white/[0.10]' + return ( +
+

+ πŸ“Ž + Sources +

+ {citations.map((c) => { + const emoji = c.emoji ? ( + {c.emoji} + ) : ( + πŸ’¬ + ) + const body = ( + <> + {emoji} +
+

{c.title}

+ {c.preview && ( +

{c.preview}

+ )} + {c.created_at && ( +

+ {new Date(c.created_at).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })} +

+ )} +
+ + Open + + + ) + return variant === 'overlay' ? ( + + ) : ( + + {body} + + ) + })} +
+ ) +} + +/** Icon-only copy button β€” matches macOS (doc.on.doc icon, no text label) */ +function CopyMsgButton({ text }: { text: string }): React.JSX.Element { + const [copied, setCopied] = useState(false) + return ( + + ) } /** * Shared chat message list used by both the main window (Home) and the overlay. - * Owns bubble styling (per `variant`), markdown rendering, and the smooth reveal - * of the live assistant message. Callers provide their own scroll container. + * Main variant matches macOS exactly: purple user bubbles, dark-grey assistant bubbles, + * 20pt continuous corners, 14/10px padding, 18px gap, icon-only copy, typing dots. */ export function ChatMessages({ messages, sending, - variant + variant, + suggestions, + onSuggest }: { messages: ChatMsg[] sending: boolean variant: 'main' | 'overlay' + suggestions?: string[] + onSuggest?: (text: string) => void }): React.JSX.Element { const cls = BUBBLE[variant] + // 18px gap between messages matches macOS LazyVStack(spacing: 18) + const gapClass = variant === 'main' ? 'flex flex-col gap-[18px]' : 'flex flex-col gap-3' return ( - <> +
{messages.map((m, i) => { const isLast = i === messages.length - 1 + const isStreaming = isLast && sending && m.role === 'assistant' return ( -
- {m.role === 'assistant' ? ( - m.content ? ( - - ) : sending ? ( - '…' +
+
+ {m.role === 'assistant' ? ( + m.content ? ( + variant === 'main' ? ( + isStreaming ? ( + + ) : ( + + ) + ) : ( + + ) + ) : sending ? ( + + ) : ( + '' + ) ) : ( - '' - ) - ) : ( -
{m.content}
+
{m.content}
+ )} +
+ {m.role === 'assistant' && m.content && !isStreaming && ( + + )} + {m.role === 'assistant' && m.citations && m.citations.length > 0 && ( + + )} + {/* Suggestion chips β€” only on the last AI message when not streaming (main variant) */} + {isLast && m.role === 'assistant' && !sending && variant === 'main' && suggestions && suggestions.length > 0 && onSuggest && ( +
+ {suggestions.map((s) => ( + + ))} +
)}
) })} - +
) } diff --git a/desktop/windows/src/renderer/src/components/chat/ChatSessionsSidebar.tsx b/desktop/windows/src/renderer/src/components/chat/ChatSessionsSidebar.tsx new file mode 100644 index 00000000000..26848c8df33 --- /dev/null +++ b/desktop/windows/src/renderer/src/components/chat/ChatSessionsSidebar.tsx @@ -0,0 +1,311 @@ +import { useEffect, useRef, useState } from 'react' +import { Plus, Star, Search, X, Pencil, Trash2, Loader2 } from 'lucide-react' +import { cn } from '../../lib/utils' +import type { LocalConversation } from '../../../../shared/types' + +type Session = { + id: string + title: string + preview: string + starred: boolean + sortAt: number +} + +function dateGroup(ts: number): string { + const now = new Date() + const d = new Date(ts) + const diff = now.getTime() - d.getTime() + const days = diff / 86_400_000 + if (days < 1) return 'Today' + if (days < 2) return 'Yesterday' + if (days < 7) return 'This Week' + if (days < 30) return 'This Month' + return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) +} + +const STARRED_KEY = 'omi.chat.starred' +function loadStarred(): Set { + try { return new Set(JSON.parse(localStorage.getItem(STARRED_KEY) ?? '[]') as string[]) } + catch { return new Set() } +} +function saveStarred(ids: Set): void { + try { localStorage.setItem(STARRED_KEY, JSON.stringify([...ids])) } catch {} +} + +export function ChatSessionsSidebar({ + activeId, + onSelect, + onNew +}: { + activeId: string | null + onSelect: (id: string) => void + onNew: () => void +}): React.JSX.Element { + const [sessions, setSessions] = useState([]) + const [loading, setLoading] = useState(true) + const [starred, setStarred] = useState>(loadStarred) + const [showStarredOnly, setShowStarredOnly] = useState(false) + const [query, setQuery] = useState('') + const [deletingId, setDeletingId] = useState(null) + const [editingId, setEditingId] = useState(null) + const [editTitle, setEditTitle] = useState('') + const editRef = useRef(null) + + const load = async (): Promise => { + try { + const all = (await window.omi.listLocalConversations()) as LocalConversation[] + const chats = all.filter((c) => c.kind === 'chat') + const mapped: Session[] = chats.map((c) => { + const msgs = c.messages ?? [] + const last = msgs[msgs.length - 1] + const preview = last?.content?.trim()?.slice(0, 80) ?? '' + return { + id: c.id, + title: c.title ?? (msgs.length ? 'Chat with Omi' : 'New Chat'), + preview, + starred: starred.has(c.id), + sortAt: c.endedAt || c.startedAt || 0 + } + }) + mapped.sort((a, b) => b.sortAt - a.sortAt) + setSessions(mapped) + } finally { + setLoading(false) + } + } + + useEffect(() => { void load() }, []) + + // Re-load when active changes (a new session was created) + useEffect(() => { void load() }, [activeId]) + + const toggleStar = (id: string): void => { + setStarred((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + saveStarred(next) + return next + }) + } + + const deleteSession = async (id: string): Promise => { + setDeletingId(id) + try { + await window.omi.deleteLocalConversation(id) + setSessions((s) => s.filter((x) => x.id !== id)) + if (activeId === id) onNew() + } finally { + setDeletingId(null) + } + } + + const startEdit = (s: Session): void => { + setEditingId(s.id) + setEditTitle(s.title) + setTimeout(() => editRef.current?.focus(), 0) + } + + const saveEdit = async (): Promise => { + if (!editingId) return + const trimmed = editTitle.trim() + if (!trimmed) { + setEditingId(null) + return + } + try { + await window.omi.updateLocalConversationTitle(editingId, trimmed) + setSessions((s) => s.map((x) => x.id === editingId ? { ...x, title: trimmed } : x)) + setEditingId(null) + } catch (e) { + console.error('Failed to rename chat session:', e) + // Keep editor open so user can retry or cancel manually + } + } + + const filtered = sessions + .map((s) => ({ ...s, starred: starred.has(s.id) })) + .filter((s) => { + if (showStarredOnly && !s.starred) return false + if (query && !s.title.toLowerCase().includes(query.toLowerCase()) && + !s.preview.toLowerCase().includes(query.toLowerCase())) return false + return true + }) + + const groups: { label: string; items: Session[] }[] = [] + for (const s of filtered) { + const label = dateGroup(s.sortAt) + const g = groups.find((x) => x.label === label) + if (g) g.items.push(s) + else groups.push({ label, items: [s] }) + } + + return ( +
+ {/* Top controls */} +
+ +
+ +
+
+ + setQuery(e.target.value)} + placeholder="Search chats…" + className="flex-1 bg-transparent text-xs text-white/80 placeholder:text-white/30 focus:outline-none" + /> + {query && ( + + )} +
+
+ +
+ + {/* Sessions list */} +
+ {loading ? ( +
+ +
+ ) : groups.length === 0 ? ( +
+

{query ? 'No results' : showStarredOnly ? 'No starred chats' : 'No chats yet'}

+ {!query && !showStarredOnly && ( +

Start a conversation

+ )} +
+ ) : ( + groups.map(({ label, items }) => ( +
+

+ {label} +

+ {items.map((s) => ( + onSelect(s.id)} + onDelete={() => void deleteSession(s.id)} + onStar={() => toggleStar(s.id)} + onStartEdit={() => startEdit(s)} + onEditChange={setEditTitle} + onSaveEdit={() => void saveEdit()} + onCancelEdit={() => setEditingId(null)} + /> + ))} +
+ )) + )} +
+
+ ) +} + +function SessionRow({ + session, isActive, isDeleting, isEditing, editTitle, editRef, + onSelect, onDelete, onStar, onStartEdit, onEditChange, onSaveEdit, onCancelEdit +}: { + session: Session + isActive: boolean + isDeleting: boolean + isEditing: boolean + editTitle: string + editRef: React.RefObject + onSelect: () => void + onDelete: () => void + onStar: () => void + onStartEdit: () => void + onEditChange: (v: string) => void + onSaveEdit: () => void + onCancelEdit: () => void +}): React.JSX.Element { + const [hover, setHover] = useState(false) + + return ( +
{ if (!isEditing && (e.key === 'Enter' || e.key === ' ')) onSelect() }} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + className={cn( + 'group flex w-full cursor-pointer items-start gap-2 rounded-xl px-3 py-2 text-left transition-colors', + isActive ? 'bg-white/10' : 'hover:bg-white/[0.06]' + )} + > + {session.starred && ( + + )} +
+ {isEditing ? ( + } + value={editTitle} + onChange={(e) => onEditChange(e.target.value)} + onBlur={onSaveEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') onSaveEdit() + else if (e.key === 'Escape') onCancelEdit() + }} + className="w-full rounded border border-white/20 bg-black/30 px-1.5 py-0.5 text-xs text-white focus:border-white/50 focus:outline-none" + /> + ) : ( + <> +

+ {session.title} +

+ {session.preview && ( +

+ {session.preview} +

+ )} + + )} +
+ {/* Hover actions */} + {hover && !isEditing && !isDeleting && ( +
e.stopPropagation()}> + + + +
+ )} + {isDeleting && } +
+ ) +} diff --git a/desktop/windows/src/renderer/src/components/conversations/NameSpeakerSheet.tsx b/desktop/windows/src/renderer/src/components/conversations/NameSpeakerSheet.tsx new file mode 100644 index 00000000000..041672499fe --- /dev/null +++ b/desktop/windows/src/renderer/src/components/conversations/NameSpeakerSheet.tsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from 'react' +import { X, Plus, Loader2, User } from 'lucide-react' +import { cn } from '../../lib/utils' + +export type Person = { id: string; name: string } + +export type SpeakerTarget = { + rawLabel: string + previewText: string + segmentCount: number // total segments with this speaker label +} + +interface Props { + target: SpeakerTarget + people: Person[] + onClose: () => void + onSave: (personId: string | null, isUser: boolean, allSegments: boolean) => Promise + onCreatePerson: (name: string) => Promise +} + +export function NameSpeakerSheet({ target, people, onClose, onSave, onCreatePerson }: Props): React.JSX.Element { + const [selectedId, setSelectedId] = useState(null) + const [isUser, setIsUser] = useState(false) + const [allSegments, setAllSegments] = useState(true) + const [addingNew, setAddingNew] = useState(false) + const [newName, setNewName] = useState('') + const [creating, setCreating] = useState(false) + const [saving, setSaving] = useState(false) + const [duplicateError, setDuplicateError] = useState(false) + const newNameRef = useRef(null) + + useEffect(() => { + if (addingNew) newNameRef.current?.focus() + }, [addingNew]) + + const selectUser = (): void => { + setIsUser(true) + setSelectedId(null) + setAddingNew(false) + } + + const selectPerson = (id: string): void => { + setIsUser(false) + setSelectedId(id) + setAddingNew(false) + } + + const startAddNew = (): void => { + setIsUser(false) + setSelectedId(null) + setAddingNew(true) + setNewName('') + setDuplicateError(false) + } + + const commitNewPerson = async (): Promise => { + const name = newName.trim() + if (!name) return + if (people.some((p) => p.name.toLowerCase() === name.toLowerCase())) { + setDuplicateError(true) + return + } + setCreating(true) + try { + const p = await onCreatePerson(name) + if (p) { + setSelectedId(p.id) + setAddingNew(false) + setNewName('') + } + } finally { + setCreating(false) + } + } + + const canSave = isUser || selectedId != null + + const save = async (): Promise => { + if (!canSave || saving) return + setSaving(true) + try { + await onSave(selectedId, isUser, allSegments) + onClose() + } finally { + setSaving(false) + } + } + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+ +

Name Speaker

+ +
+ +
+ {/* Speaker preview card */} +
+

+ {target.rawLabel.replace(/^SPEAKER_0*(\d+)/, 'Speaker $1')} +

+

+ "{target.previewText.slice(0, 120)}{target.previewText.length > 120 ? '…' : ''}" +

+
+ + {/* Person selection */} +
+

Who is this?

+
+ {/* You chip */} + + + {/* Existing people */} + {people.map((p) => ( + + ))} + + {/* Add new */} + +
+ + {/* New person input */} + {addingNew && ( +
+ { setNewName(e.target.value); setDuplicateError(false) }} + onKeyDown={(e) => { + if (e.key === 'Enter') void commitNewPerson() + else if (e.key === 'Escape') setAddingNew(false) + }} + placeholder="Person name" + className="flex-1 rounded-xl border border-white/15 bg-white/[0.05] px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none" + /> + +
+ )} + {addingNew && duplicateError && ( +

A person with that name already exists.

+ )} +
+ + {/* All segments toggle */} + {target.segmentCount > 1 && ( + + )} +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/desktop/windows/src/renderer/src/components/graph/BrainGraph.tsx b/desktop/windows/src/renderer/src/components/graph/BrainGraph.tsx index b027c66044e..404093fd201 100644 --- a/desktop/windows/src/renderer/src/components/graph/BrainGraph.tsx +++ b/desktop/windows/src/renderer/src/components/graph/BrainGraph.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { Canvas, useFrame, useThree } from '@react-three/fiber' +import { Canvas, useFrame, useThree, type ThreeEvent } from '@react-three/fiber' import { OrbitControls, Billboard, Text, Line } from '@react-three/drei' import * as THREE from 'three' import type { KnowledgeGraph } from '../../../../shared/types' @@ -46,7 +46,9 @@ function GraphNodeMesh({ node, centerNodeId, reduced, - posMap + posMap, + isSelected, + onSelect }: { sim: GraphSimulation node: NodePosition @@ -55,7 +57,10 @@ function GraphNodeMesh({ // Shared map (owned by GraphScene, recreated on mount) where each node writes // its eased on-screen position so the edges can connect to it. posMap: Map + isSelected: boolean + onSelect?: (id: string) => void }): React.JSX.Element { + const { gl } = useThree() const groupRef = useRef(null) const coreMat = useRef(null) const glowMat = useRef(null) @@ -68,6 +73,14 @@ function GraphNodeMesh({ const labelSize = labelFontSize(node.sizeScale) * (isFixed ? 1.35 : 1) const phase = useMemo(() => hashPhase(node.id), [node.id]) + const handleClick = onSelect + ? (e: ThreeEvent) => { e.stopPropagation(); onSelect(node.id) } + : undefined + const handlePointerOver = onSelect + ? (e: ThreeEvent) => { e.stopPropagation(); gl.domElement.style.cursor = 'pointer' } + : undefined + const handlePointerOut = onSelect ? () => { gl.domElement.style.cursor = 'default' } : undefined + // Read the live simulation position each frame (no React state in the loop) // and ease toward it so motion stays smooth. New nodes fly out from the // center and grow 0 β†’ full size, then settle into a gentle continuous shine. @@ -98,19 +111,25 @@ function GraphNodeMesh({ // Shine: pulse the emissive core + halo so the modules glow and feel alive. // While a node is still growing in it flares brighter, giving the reveal a - // satisfying "pop" before it settles to its idle twinkle. + // satisfying "pop" before it settles to its idle twinkle. Selected nodes get + // an extra brightness boost so they stand out clearly. const entering = !reduced && g.scale.x < 1 const t = state.clock.elapsedTime const pulse = reduced ? 0.6 : 0.5 + 0.5 * Math.sin(t * 2 + phase) const flare = entering ? 1.8 : 1 - if (coreMat.current) coreMat.current.emissiveIntensity = (0.85 + 0.45 * pulse) * flare - if (glowMat.current) glowMat.current.opacity = (0.12 + 0.14 * pulse) * flare - if (glowMesh.current) glowMesh.current.scale.setScalar(1 + 0.18 * pulse) + const selBoost = isSelected ? 2.2 : 1 + if (coreMat.current) coreMat.current.emissiveIntensity = (0.85 + 0.45 * pulse) * flare * selBoost + if (glowMat.current) glowMat.current.opacity = (0.12 + 0.14 * pulse) * flare * (isSelected ? 2 : 1) + if (glowMesh.current) glowMesh.current.scale.setScalar((1 + 0.18 * pulse) * (isSelected ? 1.35 : 1)) }) return ( - + (null) // Eased on-screen position of each node, written by the meshes and read by the // edges so the lines stay glued to the spheres. Owned here (not on the sim) and @@ -306,6 +326,8 @@ function GraphScene({ centerNodeId={centerNodeId} reduced={reduced} posMap={posMap} + isSelected={n.id === selectedId} + onSelect={interactive ? setSelectedId : undefined} /> ))} {interactive ? ( diff --git a/desktop/windows/src/renderer/src/components/home/QuickConversationsWidget.tsx b/desktop/windows/src/renderer/src/components/home/QuickConversationsWidget.tsx new file mode 100644 index 00000000000..bc4fa142f71 --- /dev/null +++ b/desktop/windows/src/renderer/src/components/home/QuickConversationsWidget.tsx @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { MessageSquare, ChevronRight } from 'lucide-react' +import { omiApi } from '../../lib/apiClient' +import { auth, onAuthStateChanged } from '../../lib/firebase' +import { cn } from '../../lib/utils' + +type ConvSummary = { + id: string + title?: string | null + created_at?: string + structured?: { title?: string | null; emoji?: string | null } | null +} + +function relTime(isoStr?: string): string { + if (!isoStr) return '' + const ts = new Date(isoStr).getTime() + if (isNaN(ts)) return '' + const diff = Date.now() - ts + const mins = Math.floor(diff / 60000) + if (mins < 1) return 'Just now' + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days === 1) return 'Yesterday' + if (days < 7) return `${days}d ago` + return new Date(isoStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +const MAX_SHOWN = 3 + +export function QuickConversationsWidget({ + onReady, + className +}: { + onReady?: () => void + className?: string +}): React.JSX.Element | null { + const [convs, setConvs] = useState(null) + const { pathname } = useLocation() + const readyFired = useRef(false) + + useEffect(() => { + if (convs !== null && !readyFired.current) { + readyFired.current = true + onReady?.() + } + }, [convs, onReady]) + + const [userId, setUserId] = useState(auth.currentUser?.uid ?? null) + useEffect(() => onAuthStateChanged(auth, (u) => setUserId(u?.uid ?? null)), []) + + const fetchConvs = useCallback((): (() => void) => { + let cancelled = false + omiApi + .get('/v1/conversations', { params: { limit: 10, offset: 0 } }) + .then((res) => { + const data = res.data as ConvSummary[] | { conversations?: ConvSummary[] } + const list = Array.isArray(data) ? data : (data.conversations ?? []) + if (!cancelled) setConvs(list.slice(0, MAX_SHOWN)) + }) + .catch(() => { + if (!cancelled) setConvs((prev) => prev ?? []) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (!userId) return + return fetchConvs() + }, [userId, fetchConvs]) + + useEffect(() => { + if (pathname !== '/home' || !userId) return + return fetchConvs() + }, [pathname, userId, fetchConvs]) + + // Still loading β€” withhold rendering so the parent can reveal all widgets together. + if (!convs) return null + + const shown = convs.slice(0, MAX_SHOWN) + + return ( + +
+
+ +
+
+ Recent Conversations +
+ +
+ + {shown.length === 0 ? ( +

+ No conversations yet β€” start recording to create one. +

+ ) : ( +
+ {shown.map((c) => { + const title = c.structured?.title || c.title || 'Untitled' + const emoji = c.structured?.emoji + const time = relTime(c.created_at) + return ( +
+ + {emoji ? `${emoji} ` : ''} + {title} + + {time && {time}} +
+ ) + })} +
+ )} + + ) +} diff --git a/desktop/windows/src/renderer/src/components/layout/MainViews.tsx b/desktop/windows/src/renderer/src/components/layout/MainViews.tsx index ccc3fab1284..53779060e2e 100644 --- a/desktop/windows/src/renderer/src/components/layout/MainViews.tsx +++ b/desktop/windows/src/renderer/src/components/layout/MainViews.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useState } from 'react' -import { Navigate, useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { Home } from '../../pages/Home' import { Conversations } from '../../pages/Conversations' import { Memories } from '../../pages/Memories' @@ -9,7 +9,15 @@ import { Tasks } from '../../pages/Tasks' import { Goals } from '../../pages/Goals' import { Apps } from '../../pages/Apps' import { Rewind } from '../../pages/Rewind' +import { Insights } from '../../pages/Insights' +import { Focus } from '../../pages/Focus' import { LiveConversation } from '../../pages/LiveConversation' +import { Chat } from '../../pages/Chat' +import { Persona } from '../../pages/Persona' +import { Permissions } from '../../pages/Permissions' +import { Help } from '../../pages/Help' +import { ChatLab } from '../../pages/ChatLab' +import { People } from '../../pages/People' // Every page stays mounted (inactive ones are just hidden) so switching tabs is // instant. But the pages take no props, so without memo they ALL re-render on @@ -26,6 +34,14 @@ const TasksPanel = memo(Tasks) const GoalsPanel = memo(Goals) const AppsPanel = memo(Apps) const RewindPanel = memo(Rewind) +const InsightsPanel = memo(Insights) +const FocusPanel = memo(Focus) +const ChatPanel = memo(Chat) +const PersonaPanel = memo(Persona) +const PermissionsPanel = memo(Permissions) +const HelpPanel = memo(Help) +const ChatLabPanel = memo(ChatLab) +const PeoplePanel = memo(People) function panelClass(active: boolean): string { return active ? 'flex h-full min-h-0 flex-col' : 'hidden' @@ -33,6 +49,7 @@ function panelClass(active: boolean): string { export function MainViews(): React.JSX.Element { const { pathname } = useLocation() + const navigate = useNavigate() // Mounting every panel up front (incl. the heavy Memories R3F brain map) on // first render blocks the main thread during the startup entrance animations @@ -49,12 +66,14 @@ export function MainViews(): React.JSX.Element { return () => clearTimeout(timer) }, []) - // Home merges the old Chat and Record screens. - if (pathname === '/' || pathname === '/live' || pathname === '/chat') { - return - } + // Fix blank startup: redirect '/' to '/home' via effect so content renders + // immediately on the same frame instead of returning a Navigate (which leaves + // the main pane blank for one render cycle before the route updates). + useEffect(() => { + if (pathname === '/') navigate('/home', { replace: true }) + }, [pathname, navigate]) - if (pathname === '/conversations/live') { + if (pathname === '/conversations/live' || pathname === '/live') { return } @@ -63,7 +82,7 @@ export function MainViews(): React.JSX.Element { return } - const isHome = pathname === '/home' + const isHome = pathname === '/home' || pathname === '/' const isConversations = pathname === '/conversations' const isMemories = pathname === '/memories' const isSettings = pathname === '/settings' @@ -71,6 +90,14 @@ export function MainViews(): React.JSX.Element { const isGoals = pathname === '/goals' const isApps = pathname === '/apps' const isRewind = pathname === '/rewind' + const isInsights = pathname === '/insights' + const isFocus = pathname === '/focus' + const isChat = pathname === '/chat' + const isPersona = pathname === '/persona' + const isPermissions = pathname === '/permissions' + const isHelp = pathname === '/help' + const isChatLab = pathname === '/chatlab' + const isPeople = pathname === '/people' return (
@@ -88,6 +115,16 @@ export function MainViews(): React.JSX.Element {
{(isGoals || hydrateAll) && }
{(isApps || hydrateAll) && }
{(isRewind || hydrateAll) && }
+
{(isInsights || hydrateAll) && }
+
{(isFocus || hydrateAll) && }
+
{(isChat || hydrateAll) && }
+
{(isPersona || hydrateAll) && }
+
+ {(isPermissions || hydrateAll) && } +
+
{(isHelp || hydrateAll) && }
+
{(isChatLab || hydrateAll) && }
+
{(isPeople || hydrateAll) && }
) } diff --git a/desktop/windows/src/renderer/src/components/layout/Sidebar.tsx b/desktop/windows/src/renderer/src/components/layout/Sidebar.tsx index d96993afe2d..4bd1a57982f 100644 --- a/desktop/windows/src/renderer/src/components/layout/Sidebar.tsx +++ b/desktop/windows/src/renderer/src/components/layout/Sidebar.tsx @@ -1,36 +1,141 @@ -import { useEffect, useState } from 'react' -import { NavLink, useLocation } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { audioAnalyser } from '../../lib/audioAnalyser' +import { NavLink, Link, useLocation, useNavigate } from 'react-router-dom' import { House, GanttChartSquare, ListChecks, LayoutGrid, History, + Brain, + Lightbulb, + Target, Monitor, Mic, + MicOff, PanelLeftClose, - PanelLeftOpen + PanelLeftOpen, + Settings, + MessageCircle, + ShieldAlert, + Bluetooth, + HelpCircle, + User as UserIcon, + Users, + Loader2, + ChevronRight, + X, + Download, + Gift, + MoreHorizontal, + Lock, + FlaskConical, } from 'lucide-react' import { auth, onAuthStateChanged } from '../../lib/firebase' import { getPreferences, onPreferencesChange, setPreferences } from '../../lib/preferences' import { cn } from '../../lib/utils' +import { RecordingStatusBar } from '../recording/RecordingStatusBar' +import { liveConversation, type LiveStatus } from '../../lib/liveConversation' import type { User } from 'firebase/auth' import type { RewindSettings } from '../../../../shared/types' +import { loadObservations, type FocusStatus } from '../../lib/focusEngine' const navItems = [ - { label: 'Home', to: '/home', Icon: House }, - { label: 'Conversations', to: '/conversations', Icon: GanttChartSquare }, - { label: 'Tasks', to: '/tasks', Icon: ListChecks }, - { label: 'Rewind', to: '/rewind', Icon: History }, - { label: 'Apps', to: '/apps', Icon: LayoutGrid } + { label: 'Dashboard', to: '/home', Icon: House, tier: 5 }, + { label: 'Conversations', to: '/conversations', Icon: GanttChartSquare, tier: 1 }, + { label: 'Chat', to: '/chat', Icon: MessageCircle, tier: 4 }, + { label: 'Memories', to: '/memories', Icon: Brain, tier: 2 }, + { label: 'Tasks', to: '/tasks', Icon: ListChecks, tier: 3 }, + { label: 'People', to: '/people', Icon: Users, tier: 3 }, + { label: 'Focus', to: '/focus', Icon: Target, tier: 0 }, + { label: 'Rewind', to: '/rewind', Icon: History, tier: 1 }, + { label: 'Insights', to: '/insights', Icon: Lightbulb, tier: 0 }, + { label: 'Apps', to: '/apps', Icon: LayoutGrid, tier: 6 }, + { label: 'ChatLab', to: '/chatlab', Icon: FlaskConical, tier: 0 }, ] +const TIER_KEY = 'omi.tier.level' + const COLLAPSE_KEY = 'omi.sidebar.collapsed' +const LAST_DEVICE_KEY = 'omi.ble.lastDevice.v1' +const GET_OMI_DISMISSED_KEY = 'omi.sidebar.getOmiDismissed' -// Shared hover/selection background β€” matches .nav-active so an active tab and a -// hovered tab read as the same neutral grey. const HOVER = 'hover:bg-[var(--nav-sel)]' +const FOCUS_DOT: Record = { + focused: 'bg-green-500', + distracted: 'bg-orange-500', + neutral: 'bg-white/25', +} + +const BAR_MIN = 0.15 +const BAR_GAIN = 3.5 +const BAR_SMOOTH = 0.35 +const FLOOR_DECAY = 0.002 +const FLOOR_MARGIN = 0.04 + +/** 4 bars driven by real mic AnalyserNode amplitude β€” mirrors macOS AudioLevelNavItem. */ +function AudioBars(): React.JSX.Element { + const barsRef = useRef>([null, null, null, null]) + const scalesRef = useRef([BAR_MIN, BAR_MIN, BAR_MIN, BAR_MIN]) + const floorRef = useRef(0) + const dataRef = useRef(new Uint8Array(16)) + + useEffect(() => { + let raf = 0 + const tick = (): void => { + const analyserNode = audioAnalyser.get() + const bars = barsRef.current + if (analyserNode) { + if (dataRef.current.length !== analyserNode.frequencyBinCount) { + dataRef.current = new Uint8Array(analyserNode.frequencyBinCount) + } + analyserNode.getByteFrequencyData(dataRef.current as Uint8Array) + const d = dataRef.current + const avg = d.reduce((s, v) => s + v / 255, 0) / d.length + floorRef.current = + avg > floorRef.current ? avg : Math.max(0, floorRef.current - FLOOR_DECAY) + const floor = Math.max(0, floorRef.current - FLOOR_MARGIN) + const bucketSize = Math.max(1, Math.floor(d.length / 4)) + for (let i = 0; i < 4; i++) { + const s = i * bucketSize + const e = Math.min(s + bucketSize, d.length) + let sum = 0 + for (let j = s; j < e; j++) sum += d[j] / 255 + const raw = sum / (e - s) + const v = Math.min(1, Math.max(0, (raw - floor) * BAR_GAIN)) + const target = BAR_MIN + v * (1 - BAR_MIN) + const next = (scalesRef.current[i] ?? BAR_MIN) + (target - (scalesRef.current[i] ?? BAR_MIN)) * BAR_SMOOTH + scalesRef.current[i] = next + if (bars[i]) bars[i]!.style.transform = `scaleY(${next})` + } + } else { + for (let i = 0; i < 4; i++) { + const next = (scalesRef.current[i] ?? BAR_MIN) + (BAR_MIN - (scalesRef.current[i] ?? BAR_MIN)) * 0.1 + scalesRef.current[i] = next + if (bars[i]) bars[i]!.style.transform = `scaleY(${next})` + } + } + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, []) + + return ( +
+ {([0, 1, 2, 3] as const).map((i) => ( + { barsRef.current[i] = el }} + className="w-[3px] origin-bottom rounded-sm bg-[color:var(--accent)]" + style={{ height: '100%', transform: `scaleY(${BAR_MIN})` }} + /> + ))} +
+ ) +} + export function Sidebar(): React.JSX.Element { const [user, setUser] = useState(null) const [prefName, setPrefName] = useState(getPreferences().displayName) @@ -38,13 +143,159 @@ export function Sidebar(): React.JSX.Element { () => localStorage.getItem(COLLAPSE_KEY) === '1' ) const [rewind, setRewind] = useState(null) + const [focusStatus, setFocusStatus] = useState(null) + const [insightBadge, setInsightBadge] = useState(0) + const [liveStatus, setLiveStatus] = useState(() => liveConversation.getStatus()) + const [micPermission, setMicPermission] = useState(null) + const [loadingNav, setLoadingNav] = useState(null) + const [pairedDevice, setPairedDevice] = useState<{ + name: string + id: string + seenAt: number + batteryLevel?: number + } | null>(null) + const [showGetOmi, setShowGetOmi] = useState( + () => localStorage.getItem(GET_OMI_DISMISSED_KEY) !== '1' + ) + const [updateVersion, setUpdateVersion] = useState(null) + const [profileMenuOpen, setProfileMenuOpen] = useState(false) + const [tierLevel, setTierLevel] = useState(() => { + const v = parseInt(localStorage.getItem(TIER_KEY) ?? '', 10) + return isNaN(v) ? 99 : v + }) + const profileMenuRef = useRef(null) + const loadingNavTimerRef = useRef | null>(null) + const { pathname } = useLocation() + const navigate = useNavigate() useEffect(() => onAuthStateChanged(auth, (u) => setUser(u)), []) - - // Keep the displayed name in sync with the editable Settings/onboarding name. useEffect(() => onPreferencesChange((p) => setPrefName(p.displayName)), []) + // Load latest focus observation from local storage for sidebar dot + useEffect(() => { + const obs = loadObservations() + if (obs.length > 0) setFocusStatus(obs[0].status) + }, [pathname]) // refresh when navigating + + // Live conversation status β€” drives AudioBars on Conversations nav item + useEffect(() => liveConversation.subscribe(() => setLiveStatus(liveConversation.getStatus())), []) + + // Mic permission β€” shown in permission status section + useEffect(() => { + if (!navigator.permissions) return + navigator.permissions + .query({ name: 'microphone' as PermissionName }) + .then((status) => { + setMicPermission(status.state) + status.addEventListener('change', () => setMicPermission(status.state)) + }) + .catch(() => {}) + }, []) + + // Load last-paired BLE device from localStorage (saved by DevicesTab on each connect) + useEffect(() => { + const raw = localStorage.getItem(LAST_DEVICE_KEY) + if (raw) { + try { + setPairedDevice(JSON.parse(raw) as { name: string; id: string; seenAt: number }) + } catch {} + } + // Re-check when the user returns to the app (e.g. after pairing in DevicesTab) + const onStorage = (e: StorageEvent): void => { + if (e.key !== LAST_DEVICE_KEY) return + const val = e.newValue + if (!val) { setPairedDevice(null); return } + try { setPairedDevice(JSON.parse(val)) } catch {} + } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + // Sync tier level from localStorage β€” set by the onboarding flow + useEffect(() => { + const onStorage = (e: StorageEvent): void => { + if (e.key !== TIER_KEY) return + const v = parseInt(e.newValue ?? '', 10) + setTierLevel(isNaN(v) ? 99 : v) + } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + // Check for updates via GitHub releases API β€” mirrors macOS Sparkle check. + // Compares current app version (semver) to the latest windows-tagged release. + useEffect(() => { + void (async () => { + const current = await window.omi.getAppVersion?.() + if (!current) return + try { + const res = await fetch('https://api.github.com/repos/BasedHardware/omi/releases/latest') + if (!res.ok) return + const data = (await res.json()) as { tag_name?: string } + const tag = data.tag_name ?? '' + if (!tag.toLowerCase().includes('windows')) return + const latest = tag.replace(/^v/i, '').replace(/-windows.*/i, '') + if (latest && latest !== current && latest > current) setUpdateVersion(latest) + } catch {} + })() + }, []) + + // Close profile menu when clicking outside it + useEffect(() => { + if (!profileMenuOpen) return + const handler = (e: MouseEvent): void => { + if (profileMenuRef.current && !profileMenuRef.current.contains(e.target as Node)) { + setProfileMenuOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [profileMenuOpen]) + + // Insight unread badge: count insights newer than the last time the user + // visited /insights. Cleared on entry to /insights. + useEffect(() => { + if (pathname === '/insights') { + localStorage.setItem('omi.insights.lastVisited', String(Date.now())) + setInsightBadge(0) + return + } + const lastVisited = parseInt(localStorage.getItem('omi.insights.lastVisited') ?? '0', 10) + void window.omi.insightRecent?.(50).then((list) => { + if (!list) return + const unread = (list as Array<{ ts: number }>).filter((ins) => ins.ts > lastVisited).length + setInsightBadge(unread) + }) + }, [pathname]) + + // Clear loadingNav when navigation completes + useEffect(() => { + if (loadingNav && pathname === loadingNav) { + const t = setTimeout(() => setLoadingNav(null), 600) + return () => clearTimeout(t) + } + return undefined + }, [pathname, loadingNav]) + + // Ctrl+1–N: jump to the nth sidebar item + useEffect(() => { + const allItems = [...navItems, { to: '/settings', Icon: Settings, label: 'Settings' }] + const handler = (e: KeyboardEvent): void => { + if (!e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return + const digit = parseInt(e.key, 10) + if (isNaN(digit) || digit < 1) return + const item = allItems[digit - 1] + if (!item) return + const tag = (document.activeElement as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + e.preventDefault() + navigate(item.to) + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [navigate]) + useEffect(() => { localStorage.setItem(COLLAPSE_KEY, collapsed ? '1' : '0') }, [collapsed]) @@ -53,16 +304,27 @@ export function Sidebar(): React.JSX.Element { void window.omi.rewindGetSettings().then(setRewind) }, []) + const handleNavClick = (to: string): void => { + if (pathname !== to) { + if (loadingNavTimerRef.current) clearTimeout(loadingNavTimerRef.current) + setLoadingNav(to) + loadingNavTimerRef.current = setTimeout(() => setLoadingNav(null), 3000) + } + } + + const dismissGetOmi = (e: React.MouseEvent): void => { + e.stopPropagation() + e.preventDefault() + localStorage.setItem(GET_OMI_DISMISSED_KEY, '1') + setShowGetOmi(false) + } + const email = user?.email - // Prefer the Google account's full name (stable "First Last"), then the - // onboarding-entered name, then the email. const displayName = user?.displayName?.trim() || prefName?.trim() || email || 'Account' const photoURL = user?.photoURL const initial = (user?.displayName?.trim() || prefName?.trim() || email)?.[0]?.toUpperCase() ?? '?' - // Screen-recording = the persistent Rewind capture setting (the toggle that - // used to be a checkbox in Settings). Optimistic flip, reconcile from main. const screenOn = !!rewind?.captureEnabled const toggleScreen = (): void => { if (!rewind) return @@ -71,20 +333,12 @@ export function Sidebar(): React.JSX.Element { void window.omi.rewindSetSettings(next).then(setRewind) } - // Microphone = always-on listening. The toggle reflects the `continuousRecording` - // preference; flipping it starts/stops the background ContinuousRecordingHost - // (which streams the mic to /v4/listen). Viewing the live transcript is a SEPARATE - // affordance (the "New" button in Conversations / opening a conversation row) β€” - // this switch only turns listening on and off. const [micOn, setMicOn] = useState(() => !!getPreferences().continuousRecording) useEffect(() => onPreferencesChange((p) => setMicOn(!!p.continuousRecording)), []) const toggleMic = (): void => { setPreferences({ continuousRecording: !getPreferences().continuousRecording }) } - // The label/name text fades with opacity (and is width-clipped by flexbox) so - // collapsing animates smoothly instead of popping. Row padding/alignment stay - // constant in both states β€” only nav width and text opacity animate. const label = (text: string): React.JSX.Element => ( cn( - 'flex items-center gap-3 rounded-xl px-2.5 py-2 text-sm font-medium transition-[color] duration-150', + // px-4 (16px) matches macOS horizontal padding; text-[13px] matches macOS .scaledFont(size:13) + 'flex items-center gap-3 rounded-xl px-4 py-2 text-[13px] font-medium transition-[color] duration-200', active ? 'nav-active' : cn('text-white/50 hover:text-white/80', HOVER) ) @@ -119,7 +374,7 @@ export function Sidebar(): React.JSX.Element { )} > {label(text)} @@ -141,15 +396,30 @@ export function Sidebar(): React.JSX.Element { ) + // Device connection freshness: if seenAt is within last 60 minutes, treat as "recently connected" + const deviceIsRecent = + pairedDevice != null && Date.now() - pairedDevice.seenAt < 60 * 60 * 1000 + + // Mic permission: show row when denied (shows Grant button) or when 'prompt' (not yet granted) + const showMicPermissionRow = micPermission === 'denied' + + // Bottom utility links: Settings, Permissions, Device, Help + const bottomLinks = [ + { label: 'Settings', to: '/settings', Icon: Settings }, + { label: 'Permissions', to: '/permissions', Icon: ShieldAlert }, + { label: 'Device', to: '/settings?tab=devices', Icon: Bluetooth }, + { label: 'Help from Founder', to: '/help', Icon: HelpCircle }, + ] + return ( ) } diff --git a/desktop/windows/src/renderer/src/components/layout/TitleBar.tsx b/desktop/windows/src/renderer/src/components/layout/TitleBar.tsx new file mode 100644 index 00000000000..e6836941839 --- /dev/null +++ b/desktop/windows/src/renderer/src/components/layout/TitleBar.tsx @@ -0,0 +1,8 @@ +export function TitleBar(): React.JSX.Element { + return ( +
+ ) +} diff --git a/desktop/windows/src/renderer/src/components/memories/MemoryExportModal.tsx b/desktop/windows/src/renderer/src/components/memories/MemoryExportModal.tsx new file mode 100644 index 00000000000..ad4644508a5 --- /dev/null +++ b/desktop/windows/src/renderer/src/components/memories/MemoryExportModal.tsx @@ -0,0 +1,250 @@ +import { useState } from 'react' +import { X, Copy, Check, ExternalLink, BookOpen } from 'lucide-react' +import { cn } from '../../lib/utils' +import type { Memory } from '../../hooks/useMemories' + +type Destination = 'obsidian' | 'notion' | 'chatgpt' | 'claude' | 'agents' + +const DESTINATIONS: { id: Destination; label: string; icon: string; description: string }[] = [ + { id: 'obsidian', label: 'Obsidian', icon: 'πŸ’Ž', description: 'Export as Markdown to your vault' }, + { id: 'notion', label: 'Notion', icon: 'πŸ“„', description: 'Copy as a Notion-ready Markdown block' }, + { id: 'chatgpt', label: 'ChatGPT', icon: 'πŸ€–', description: 'Copy as a memory injection prompt' }, + { id: 'claude', label: 'Claude', icon: '🧠', description: 'Copy as a context prompt for Claude' }, + { id: 'agents', label: 'Agents / MCP', icon: '⚑', description: 'Live connection via MCP server' }, +] + +function buildMemoryPack(memories: Memory[]): string { + const lines = ['# Omi Memory Export', `Generated: ${new Date().toLocaleDateString()}`, ''] + const byCategory: Record = {} + for (const m of memories) { + const cat = m.category ?? 'General' + ;(byCategory[cat] ??= []).push(m) + } + for (const [cat, mems] of Object.entries(byCategory)) { + lines.push(`## ${cat}`) + for (const m of mems) { + lines.push(`- ${m.content}`) + } + lines.push('') + } + return lines.join('\n') +} + +function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }): React.JSX.Element { + const [copied, setCopied] = useState(false) + const copy = (): void => { + void navigator.clipboard.writeText(text).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + } + return ( + + ) +} + +function ObsidianPanel({ memories }: { memories: Memory[] }): React.JSX.Element { + const pack = buildMemoryPack(memories) + return ( +
+

+ Copy your memories as Markdown and paste them into your Obsidian vault, or open the Omi folder directly. +

+
+

Memory Pack ({memories.length} memories)

+
{pack.slice(0, 800)}{pack.length > 800 ? '\n…' : ''}
+
+
+ + +
+
+ ) +} + +function NotionPanel({ memories }: { memories: Memory[] }): React.JSX.Element { + const pack = buildMemoryPack(memories) + return ( +
+

+ Copy your memories as Markdown, then paste them into a Notion page. Notion will auto-format the headings and bullet points. +

+
+ + +
+
+ ) +} + +function ChatGPTPanel({ memories }: { memories: Memory[] }): React.JSX.Element { + const pack = buildMemoryPack(memories) + const prompt = `The following are my personal memories exported from Omi. Use them as context when answering my questions:\n\n${pack}` + return ( +
+

+ Copy this context prompt and paste it at the start of a ChatGPT conversation (or add it as a custom instruction). +

+
+

Context Prompt

+
{prompt.slice(0, 600)}…
+
+
+ + +
+
+ ) +} + +function ClaudePanel({ memories }: { memories: Memory[] }): React.JSX.Element { + const pack = buildMemoryPack(memories) + const prompt = `Here are my personal memories from Omi. Please use these as context for all of our conversations:\n\n${pack}` + return ( +
+

+ Copy this context prompt and paste it into a new Claude project or conversation to give Claude access to your memories. +

+
+ + +
+
+ ) +} + +function AgentsPanel(): React.JSX.Element { + const mcpUrl = `https://api.omi.me/mcp/v1` + const setupPrompt = `Connect to my Omi MCP server at ${mcpUrl} and read my memories, conversations, and tasks. Use omi_get_memories, omi_get_conversations, and omi_get_tasks tools.` + return ( +
+

+ Connect your AI agent (Claude Code, Codex, Cursor, etc.) to Omi via the MCP server for live access to your memories and data. +

+ +
+

MCP Server URL

+
+ {mcpUrl} + +
+
+ +
+

Agent Setup Prompt

+

Paste this into your agent to connect:

+
{setupPrompt}
+ +
+ +
+

Supported Agents

+
+ {['Claude Code', 'Codex CLI', 'Cursor', 'Continue.dev', 'Cline'].map((name) => ( + + {name} + + ))} +
+
+
+ ) +} + +export function MemoryExportModal({ + memories, + onClose +}: { + memories: Memory[] + onClose: () => void +}): React.JSX.Element { + const [active, setActive] = useState('obsidian') + + const dest = DESTINATIONS.find((d) => d.id === active)! + + return ( +
+
+ {/* Header */} +
+ +
+

Export Memories

+

{memories.length} memories

+
+ +
+ +
+ {/* Destination list */} +
+ {DESTINATIONS.map((d) => ( + + ))} +
+ + {/* Panel */} +
+
+
+ {dest.icon} +

{dest.label}

+
+

{dest.description}

+
+ {active === 'obsidian' && } + {active === 'notion' && } + {active === 'chatgpt' && } + {active === 'claude' && } + {active === 'agents' && } +
+
+
+
+ ) +} diff --git a/desktop/windows/src/renderer/src/components/overlay/OverlayApp.tsx b/desktop/windows/src/renderer/src/components/overlay/OverlayApp.tsx index 3b2af41ce23..1a4263883a8 100644 --- a/desktop/windows/src/renderer/src/components/overlay/OverlayApp.tsx +++ b/desktop/windows/src/renderer/src/components/overlay/OverlayApp.tsx @@ -8,12 +8,148 @@ import { Waveform } from './Waveform' import { ChatMessages } from '../chat/ChatMessages' import './overlay.css' +// ── TTS helper ──────────────────────────────────────────────────────────────── +function speak(text: string): void { + if (!window.speechSynthesis) return + window.speechSynthesis.cancel() + const utt = new SpeechSynthesisUtterance(text) + utt.rate = 1.1 + utt.pitch = 1 + window.speechSynthesis.speak(utt) +} + +// ── Proactive notification card ─────────────────────────────────────────────── +type Notification = { title: string; body: string } + +function ProactiveCard({ + notification, + onExecute, + onDismiss +}: { + notification: Notification + onExecute: () => void + onDismiss: () => void +}): React.JSX.Element { + return ( +
+
+ πŸ”” +
+

{notification.title}

+

{notification.body}

+
+
+
+ + +
+
+ ) +} + +// Settings gear for the overlay header row β€” mirrors macOS floating bar. +// Opens Settings in the main window. Shows on hover over the close button area. +function SettingsGear(): React.JSX.Element { + return ( + + ) +} + /** Slim draggable strip with a centered grab handle. The whole strip is a drag * region (-webkit-app-region: drag); the handle just signals that it's movable. */ function DragHandle(): React.JSX.Element { return ( -
-
+
+
+
+ ) +} + +type PillState = 'idle' | 'recording' | 'finalizing' | 'sending' + +/** Single agent pill β€” used in the agent pills row below. */ +function AgentPill({ + label, + dotClass, + active +}: { + label: string + dotClass?: string + active?: boolean +}): React.JSX.Element { + return ( +
+ {dotClass && } + {label} +
+ ) +} + +/** Agent pills row β€” mirrors macOS FloatingBarAgentPillsView. + * Shows Omi pill with live state + always-on capability pills (Memory, Screen). */ +function OmiPill({ state }: { state: PillState }): React.JSX.Element { + const dotClass = + state === 'idle' + ? 'bg-[#4ade80]' + : state === 'recording' + ? 'animate-pulse bg-red-400' + : state === 'finalizing' + ? 'animate-pulse bg-yellow-400' + : 'animate-pulse bg-blue-400' + const label = + state === 'idle' + ? 'Omi' + : state === 'recording' + ? 'Listening…' + : state === 'finalizing' + ? 'Transcribing…' + : 'Thinking…' + return ( +
+ + + +
+ ) +} + +/** Bottom-right resize grip β€” mirrors macOS FloatingControlBarView's + * ResizeHandleView. Visual affordance only; actual resize is native (the window + * is resizable with width locked to OVERLAY_WIDTH). */ +function ResizeGrip(): React.JSX.Element { + return ( +
+ + + + +
) } @@ -29,6 +165,26 @@ function OverlayPanel({ replayEnter }: { replayEnter: () => void }): React.JSX.E const scrollRef = useRef(null) const messagesRef = useRef(null) + // TTS: speak each new assistant reply + const lastSpokenRef = useRef('') + useEffect(() => { + if (!history.length) return + const last = history[history.length - 1] + if (last.role !== 'assistant' || !last.content || sending) return + if (last.content === lastSpokenRef.current) return + lastSpokenRef.current = last.content + speak(last.content) + }, [history, sending]) + + // Proactive notifications from main + const [activeNotification, setActiveNotification] = useState(null) + useEffect(() => { + return window.omiOverlay.onNotification((n) => { + setActiveNotification(n) + speak(n.title + '. ' + n.body) + }) + }, []) + // Serialize sends so a back-to-back voice message isn't fired while the previous // reply is still streaming (which `useChat.send` would no-op). Each send is // chained after the prior one resolves and dispatched through the latest `send`. @@ -147,6 +303,7 @@ function OverlayPanel({ replayEnter }: { replayEnter: () => void }): React.JSX.E className={`overlay-panel relative ${leaving ? 'overlay-leave' : ''} flex flex-col text-neutral-100`} > +
+ {activeNotification && ( + { + // Send the notification body as a message so Omi acts on it + setActiveNotification(null) + setDraft('') + enqueueSend(activeNotification.body) + }} + onDismiss={() => setActiveNotification(null)} + /> + )} {history.length > 0 && (
void }): React.JSX.E last flex child) gets shrunk/clipped and looks like it disappears after a send. Pinning it means the history above shrinks/scrolls instead. */}
+