Compare commits
4 Commits
aec83c30d2
...
d36ca43804
| Author | SHA1 | Date | |
|---|---|---|---|
| d36ca43804 | |||
| b06f5f6022 | |||
| c3f298e384 | |||
| 733a3c16a8 |
@@ -3,7 +3,11 @@
|
||||
"allow": [
|
||||
"Bash(git add AI_REFACTOR_PLAN.md)",
|
||||
"Bash(git commit:*)",
|
||||
"Read(//home/rmusso/adiuva-api/**)"
|
||||
"Read(//home/rmusso/adiuva-api/**)",
|
||||
"mcp__shadcn__get_item_examples_from_registries",
|
||||
"mcp__shadcn__view_items_in_registries",
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npx eslint --ext .ts,.tsx src/renderer/components/ai/blocks/)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,8 +375,10 @@ Key constraints:
|
||||
|
||||
## Phase 6 — Renderer UI Updates
|
||||
|
||||
### Step 6.1 — Auth UI + settings restructure
|
||||
- [ ] `LoginForm.tsx`, auth gate in AppShell, `SettingsPage.tsx` (Account, Backup, Permissions tabs — no AI Providers tab)
|
||||
### Step 6.1 — Auth UI + settings restructure ✅
|
||||
- [x] `LoginForm.tsx` — centered login/register screen (`src/renderer/components/auth/LoginForm.tsx`)
|
||||
- [x] Auth gate in `AppShell` — shows `LoginForm` when `auth.status` returns `authenticated: false`; passes through while loading to avoid flicker; `staleTime: 5min` to avoid hammering backend
|
||||
- [x] `SettingsPage.tsx` Account section simplified — login form removed (AppShell handles it), always shows profile + sign out
|
||||
|
||||
### Step 6.2 — ChatPage with context panel ❌ DEPRECATED
|
||||
> **Superseded by V3.** Home chat with block rendering (charts, entities, tables, timelines) and FloatingChat with domain navigation replace this. See `V3_ELECTRON_MIGRATION_PLAN.md` Steps 4–7.
|
||||
|
||||
@@ -62,7 +62,7 @@ source ~/.nvm/nvm.sh && npm run lint
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [ ] Step 1 complete
|
||||
- [x] Step 1 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
@@ -105,7 +105,7 @@ source ~/.nvm/nvm.sh && npm start
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [ ] Step 2 complete
|
||||
- [x] Step 2 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
@@ -155,7 +155,7 @@ source ~/.nvm/nvm.sh && npm start
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [ ] Step 3 complete
|
||||
- [x] Step 3 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
@@ -206,7 +206,7 @@ source ~/.nvm/nvm.sh && npm start
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [ ] Step 4 complete
|
||||
- [x] Step 4 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
@@ -256,7 +256,7 @@ source ~/.nvm/nvm.sh && npm start
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [ ] Step 5 complete
|
||||
- [x] Step 5 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
@@ -290,7 +290,7 @@ source ~/.nvm/nvm.sh && npm start
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [ ] Step 6 complete
|
||||
- [x] Step 6 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
@@ -327,7 +327,7 @@ source ~/.nvm/nvm.sh && npm start
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [ ] Step 7 complete
|
||||
- [x] Step 7 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
@@ -364,7 +364,7 @@ source ~/.nvm/nvm.sh && npm run lint && npm start
|
||||
```
|
||||
|
||||
**Status**:
|
||||
- [ ] Step 8 complete
|
||||
- [x] Step 8 complete
|
||||
|
||||
**Commit**:
|
||||
```
|
||||
|
||||
347
package-lock.json
generated
347
package-lock.json
generated
@@ -36,6 +36,7 @@
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -7941,6 +7942,69 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -10747,6 +10811,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
@@ -10868,6 +11053,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -11168,6 +11359,16 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
@@ -13569,6 +13770,15 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -14927,6 +15137,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/interpret": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz",
|
||||
@@ -15688,7 +15907,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -16418,6 +16636,18 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lop": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
|
||||
@@ -18235,7 +18465,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -19251,6 +19480,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -19815,6 +20061,12 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -19934,6 +20186,21 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
@@ -19962,6 +20229,22 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-binary-file-arch": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||
@@ -20131,6 +20414,44 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
|
||||
@@ -23829,6 +24150,28 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
/**
|
||||
* @Orchestrator — thin backend-delegation layer.
|
||||
* @Orchestrator — thin backend-delegation layer (v3).
|
||||
*
|
||||
* All AI intelligence lives on the backend. The Electron process:
|
||||
* 1. Checks connectivity + auth status
|
||||
* 2. Assembles a context snapshot from local SQLite
|
||||
* 3. Delegates to BackendClient.chatStream() — which handles the WS lifecycle,
|
||||
* tool-call ↔ DrizzleExecutor round-trips, and text streaming.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.5
|
||||
* 2. Delegates to BackendClient.sendHomeRequest() / sendFloatingRequest()
|
||||
* which handle the WS lifecycle, tool-call ↔ DrizzleExecutor round-trips,
|
||||
* and v3 stream event dispatch.
|
||||
* 3. Forwards v3 typed stream frames to the renderer via IPC.
|
||||
*/
|
||||
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { tasks, projects, clients, notes, checkpoints } from '../db/schema';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { getBackendClient, OfflineError, AuthExpiredError } from '../api/backend-client';
|
||||
import { getAuthManager } from '../auth/auth-manager';
|
||||
import type { ChatContext } from '../../shared/api-types';
|
||||
import { getStore } from '../store';
|
||||
import type { WsFloatingRequest } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -29,165 +27,109 @@ const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
|
||||
interface OrchestrateInput {
|
||||
message: string;
|
||||
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
|
||||
conversationHistory?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>;
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
|
||||
interface OrchestrateFloatingInput {
|
||||
message: string;
|
||||
scope: WsFloatingRequest['scope'];
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Superseded by `ChatResponse` from `@shared/api-types`.
|
||||
* Kept for interface compatibility with `aiRouter` until Step 2.1.
|
||||
*/
|
||||
interface OrchestrateResult {
|
||||
response: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context assembly — SQLite snapshot → ChatContext
|
||||
// IPC helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildChatContext(input: OrchestrateInput['context']): ChatContext {
|
||||
const db = getDb();
|
||||
|
||||
// Recent tasks (last 20, ordered by creation time)
|
||||
const recentTasks = db
|
||||
.select({ id: tasks.id, title: tasks.title, status: tasks.status })
|
||||
.from(tasks)
|
||||
.orderBy(asc(tasks.createdAt))
|
||||
.limit(20)
|
||||
.all()
|
||||
.map((t) => ({ id: t.id, title: t.title, status: t.status }));
|
||||
|
||||
const relevantDocuments: string[] = [];
|
||||
|
||||
if (input.type === 'project' && input.projectId) {
|
||||
const projectId = input.projectId;
|
||||
const project = db.select().from(projects).where(eq(projects.id, projectId)).all()[0];
|
||||
|
||||
if (project) {
|
||||
let clientName = '';
|
||||
if (project.clientId) {
|
||||
const client = db.select().from(clients).where(eq(clients.id, project.clientId)).all()[0];
|
||||
if (client) clientName = client.name;
|
||||
}
|
||||
|
||||
const projectTasks = db
|
||||
.select({ title: tasks.title, status: tasks.status, priority: tasks.priority, dueDate: tasks.dueDate })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.projectId, projectId))
|
||||
.orderBy(asc(tasks.createdAt))
|
||||
.all();
|
||||
|
||||
const projectCheckpoints = db
|
||||
.select({ title: checkpoints.title, date: checkpoints.date })
|
||||
.from(checkpoints)
|
||||
.where(eq(checkpoints.projectId, projectId))
|
||||
.orderBy(asc(checkpoints.date))
|
||||
.all();
|
||||
|
||||
const projectNotes = db
|
||||
.select({ title: notes.title, content: notes.content })
|
||||
.from(notes)
|
||||
.where(eq(notes.projectId, projectId))
|
||||
.orderBy(asc(notes.createdAt))
|
||||
.all();
|
||||
|
||||
const summaryLines: string[] = [
|
||||
`Project: ${project.name}`,
|
||||
clientName ? `Client: ${clientName}` : '',
|
||||
`Status: ${project.status}`,
|
||||
project.aiSummary ? `Summary: ${project.aiSummary}` : '',
|
||||
projectTasks.length > 0
|
||||
? `Tasks (${projectTasks.length}): ${projectTasks.map((t) => `${t.title} [${t.status}]`).join(', ')}`
|
||||
: '',
|
||||
projectCheckpoints.length > 0
|
||||
? `Checkpoints: ${projectCheckpoints.map((c) => `${c.title} (${new Date(c.date).toLocaleDateString()})`).join(', ')}`
|
||||
: '',
|
||||
projectNotes.length > 0
|
||||
? `Notes: ${projectNotes.map((n) => n.title).join(', ')}`
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
|
||||
relevantDocuments.push(summaryLines.join('\n'));
|
||||
|
||||
// Include note contents as individual documents (capped at 500 chars each)
|
||||
for (const note of projectNotes) {
|
||||
const excerpt = note.content.length > 500 ? note.content.slice(0, 500) + '…' : note.content;
|
||||
if (excerpt.trim()) {
|
||||
relevantDocuments.push(`## ${note.title}\n${excerpt}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Global context: workspace summary
|
||||
const allProjects = db
|
||||
.select({ id: projects.id, name: projects.name, status: projects.status })
|
||||
.from(projects)
|
||||
.where(eq(projects.status, 'active'))
|
||||
.orderBy(asc(projects.name))
|
||||
.all();
|
||||
|
||||
if (allProjects.length > 0) {
|
||||
relevantDocuments.push(
|
||||
`Active projects (${allProjects.length}): ${allProjects.map((p) => p.name).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
recentTasks,
|
||||
relevantDocuments: relevantDocuments.length > 0 ? relevantDocuments : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function sendStreamChunk(
|
||||
sender: Electron.WebContents | undefined,
|
||||
token: string,
|
||||
done: boolean,
|
||||
): void {
|
||||
function sendFrame(sender: Electron.WebContents | undefined, payload: Record<string, unknown>): void {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(AI_STREAM_CHANNEL, { token, done });
|
||||
sender.send(AI_STREAM_CHANNEL, payload);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrate (public entry point)
|
||||
// Connectivity + auth guard (shared)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function checkConnectivity(): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const client = getBackendClient();
|
||||
const online = await client.isOnline();
|
||||
if (!online) {
|
||||
return { ok: false, error: 'You are offline. AI features require an internet connection.' };
|
||||
}
|
||||
const authenticated = await getAuthManager().isAuthenticated();
|
||||
if (!authenticated) {
|
||||
return { ok: false, error: 'Please log in to use AI features.' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrate — Home chat (public entry point)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
|
||||
const { message, context, sender } = input;
|
||||
const { message, conversationHistory, sender } = input;
|
||||
|
||||
const check = await checkConnectivity();
|
||||
if (!check.ok) return { response: '', error: check.error };
|
||||
|
||||
try {
|
||||
const client = getBackendClient();
|
||||
const { requestId, promise } = client.sendHomeRequest(message, conversationHistory, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
|
||||
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
|
||||
onBlock: (blockType, data) => sendFrame(sender, { type: 'stream_block', requestId, blockType, data }),
|
||||
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
});
|
||||
|
||||
// 1. Connectivity check
|
||||
const online = await client.isOnline();
|
||||
if (!online) {
|
||||
await promise;
|
||||
return { response: 'ok' };
|
||||
} catch (err) {
|
||||
sendFrame(sender, { type: 'stream_end', requestId: '' });
|
||||
|
||||
if (err instanceof OfflineError) {
|
||||
return { response: '', error: 'You are offline. AI features require an internet connection.' };
|
||||
}
|
||||
|
||||
// 2. Auth check
|
||||
const authenticated = await getAuthManager().isAuthenticated();
|
||||
if (!authenticated) {
|
||||
return { response: '', error: 'Please log in to use AI features.' };
|
||||
if (err instanceof AuthExpiredError) {
|
||||
return { response: '', error: 'Your session has expired. Please log in again.' };
|
||||
}
|
||||
|
||||
// 3. Build context snapshot from local SQLite
|
||||
const chatContext = buildChatContext(context);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return { response: '', error: errMsg };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Stream from backend — tool calls are handled inside BackendClient.chatStream()
|
||||
const result = await client.chatStream(
|
||||
{ message, context: chatContext },
|
||||
(chunk) => sendStreamChunk(sender, chunk, false),
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrate Floating — Floating chat (public entry point)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
sendStreamChunk(sender, '', true);
|
||||
return { response: result.response };
|
||||
export async function orchestrateFloating(input: OrchestrateFloatingInput): Promise<OrchestrateResult> {
|
||||
const { message, scope, sender } = input;
|
||||
|
||||
const check = await checkConnectivity();
|
||||
if (!check.ok) return { response: '', error: check.error };
|
||||
|
||||
try {
|
||||
const client = getBackendClient();
|
||||
const { requestId, promise } = client.sendFloatingRequest(message, scope, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
|
||||
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId, chunk }),
|
||||
onBlock: (blockType, data) => sendFrame(sender, { type: 'stream_block', requestId, blockType, data }),
|
||||
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
onDomain: (domain) => sendFrame(sender, { type: 'floating_domain', requestId, domain }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
});
|
||||
|
||||
await promise;
|
||||
return { response: 'ok' };
|
||||
} catch (err) {
|
||||
sendStreamChunk(sender, '', true);
|
||||
sendFrame(sender, { type: 'stream_end', requestId: '' });
|
||||
|
||||
if (err instanceof OfflineError) {
|
||||
return { response: '', error: 'You are offline. AI features require an internet connection.' };
|
||||
@@ -206,21 +148,95 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAILY_BRIEF_PROMPT =
|
||||
`Act as a professional and efficient executive assistant. Give me a concise daily brief for today.
|
||||
`Act as a professional and efficient executive assistant. Give me a concise daily brief for today.
|
||||
|
||||
Strict Rules:
|
||||
- Adopt a polite, formal, and helpful tone. Do not use emojis, slang, or overly casual encouragement.
|
||||
- Focus strictly on actionable or critical items: tasks due today, upcoming deadlines this week, overdue items, and significant project activity.
|
||||
- Focus strictly on actionable or critical items: tasks due today, upcoming deadlines this week, overdue items, and significant project activity.
|
||||
- Do NOT mention zero-counts (e.g., "no overdue items") or general statistics (e.g., "2 active projects", "2 completed tasks"). Only report what needs my attention.
|
||||
- Do NOT include any headers, titles, dates, or greetings.
|
||||
- Do NOT use labels like "Due today:" or "Overdue:". Integrate the information naturally into sentences.
|
||||
- Use **bold** for key phrases, task names, or project names.
|
||||
- Keep the entire response to 3-5 sentences.`;
|
||||
|
||||
export async function dailyBrief(sender?: Electron.WebContents): Promise<OrchestrateResult> {
|
||||
return orchestrate({
|
||||
message: DAILY_BRIEF_PROMPT,
|
||||
context: { type: 'global' },
|
||||
sender,
|
||||
});
|
||||
function todayString(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Returns cached brief content if it was generated today, otherwise null. */
|
||||
export function getCachedBrief(): string | null {
|
||||
const cache = getStore().get('dailyBriefCache');
|
||||
if (!cache || cache.date !== todayString()) return null;
|
||||
return cache.content;
|
||||
}
|
||||
|
||||
/** Invalidate the cache so the next home visit triggers a fresh generation. */
|
||||
export function invalidateBriefCache(): void {
|
||||
getStore().set('dailyBriefCache', null);
|
||||
}
|
||||
|
||||
/** Regenerate the brief silently in background, cache it, then push to all windows. */
|
||||
export async function generateAndCacheBrief(): Promise<void> {
|
||||
const check = await checkConnectivity();
|
||||
if (!check.ok) return;
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
const client = getBackendClient();
|
||||
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
|
||||
onStart: () => {},
|
||||
onText: (chunk) => { content += chunk; },
|
||||
onBlock: () => {},
|
||||
onEnd: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
await promise;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content) return;
|
||||
|
||||
getStore().set('dailyBriefCache', { content, date: todayString() });
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('ai:brief-updated', content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stream the daily brief to the renderer and cache the result. */
|
||||
export async function dailyBrief(sender?: Electron.WebContents): Promise<OrchestrateResult> {
|
||||
let content = '';
|
||||
|
||||
const check = await checkConnectivity();
|
||||
if (!check.ok) return { response: '', error: check.error };
|
||||
|
||||
try {
|
||||
const client = getBackendClient();
|
||||
const requestId = crypto.randomUUID();
|
||||
const { promise } = client.sendHomeRequest(DAILY_BRIEF_PROMPT, undefined, {
|
||||
onStart: () => sendFrame(sender, { type: 'stream_start', requestId }),
|
||||
onText: (chunk) => {
|
||||
content += chunk;
|
||||
sendFrame(sender, { type: 'stream_text', requestId, chunk });
|
||||
},
|
||||
onBlock: () => {},
|
||||
onEnd: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
onError: () => sendFrame(sender, { type: 'stream_end', requestId }),
|
||||
});
|
||||
await promise;
|
||||
} catch (err) {
|
||||
sendFrame(sender, { type: 'stream_end', requestId: '' });
|
||||
if (err instanceof OfflineError) return { response: '', error: 'You are offline.' };
|
||||
if (err instanceof AuthExpiredError) return { response: '', error: 'Your session has expired. Please log in again.' };
|
||||
return { response: '', error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
||||
if (content) {
|
||||
getStore().set('dailyBriefCache', { content, date: todayString() });
|
||||
}
|
||||
|
||||
return { response: 'ok' };
|
||||
}
|
||||
|
||||
@@ -23,19 +23,45 @@ import { getAuthManager } from '../auth/auth-manager';
|
||||
import { toSnakeCase, toCamelCase } from '../../shared/casing';
|
||||
import {
|
||||
WsServerFrameSchema,
|
||||
ChatResponseSchema,
|
||||
} from '../../shared/api-types';
|
||||
import type {
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
WsToolResult,
|
||||
WsAgentRun,
|
||||
WsAgentData,
|
||||
LocalAgentConfig,
|
||||
WsFloatingRequest,
|
||||
} from '../../shared/api-types';
|
||||
import { DrizzleExecutor } from './drizzle-executor';
|
||||
import { readAgentFiles } from '../agents/file-reader';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-mode logger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||
|
||||
function logHttp(method: string, url: string, body?: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
const bodyStr = body !== undefined ? `\n body: ${JSON.stringify(body)}` : '';
|
||||
console.log(`[BE ▶ HTTP] ${method} ${url}${bodyStr}`);
|
||||
}
|
||||
|
||||
function logHttpResponse(method: string, url: string, status: number, body?: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
const bodyStr = body !== undefined ? `\n response: ${JSON.stringify(body).slice(0, 500)}` : '';
|
||||
console.log(`[BE ◀ HTTP] ${status} ${method} ${url}${bodyStr}`);
|
||||
}
|
||||
|
||||
function logWsSend(payload: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
console.log(`[BE ▶ WS] ${JSON.stringify(payload)}`);
|
||||
}
|
||||
|
||||
function logWsRecv(payload: unknown): void {
|
||||
if (!IS_DEV) return;
|
||||
console.log(`[BE ◀ WS] ${JSON.stringify(payload)}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -45,8 +71,6 @@ const HTTP_TIMEOUT_MS = 10_000;
|
||||
const MAX_RETRIES = 3;
|
||||
/** Base delay for exponential backoff (ms). */
|
||||
const RETRY_BASE_MS = 500;
|
||||
/** Maximum iterations the backend may request before we force-close. */
|
||||
const MAX_TOOL_ITERATIONS = 10;
|
||||
/** Interval between client-side heartbeat pings on the persistent device WS. */
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
/** Time to wait for any response after a ping before treating the connection as dead. */
|
||||
@@ -91,6 +115,21 @@ export class ServerError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V3 stream listener types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StreamListener {
|
||||
onStart: () => void;
|
||||
onText: (chunk: string) => void;
|
||||
onBlock: (blockType: string, data: Record<string, unknown>) => void;
|
||||
onEnd: (mutations?: Record<string, unknown>) => void;
|
||||
onDomain: (domain: string) => void;
|
||||
onError: (err: Error) => void;
|
||||
resolve: () => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackendClient
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -99,7 +138,7 @@ export class BackendClient {
|
||||
private static instance: BackendClient | null = null;
|
||||
private executor = new DrizzleExecutor();
|
||||
|
||||
// Persistent device WebSocket state (Step 3.5)
|
||||
// Persistent device WebSocket state
|
||||
private persistentWs: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -111,6 +150,9 @@ export class BackendClient {
|
||||
/** Optional callback fired when the persistent WS successfully connects. */
|
||||
private onConnectedCallback: (() => void) | null = null;
|
||||
|
||||
/** V3 stream listeners keyed by requestId. */
|
||||
private streamListeners: Map<string, StreamListener> = new Map();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
@@ -141,9 +183,11 @@ export class BackendClient {
|
||||
/** Returns `true` if the backend responds within 3 s, `false` otherwise. */
|
||||
async isOnline(): Promise<boolean> {
|
||||
try {
|
||||
logHttp('GET', `${this.baseUrl}/api/v1/health`);
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/health`, {
|
||||
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
||||
});
|
||||
logHttpResponse('GET', `${this.baseUrl}/api/v1/health`, res.status);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -160,6 +204,7 @@ export class BackendClient {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
logHttp('POST', `${this.baseUrl}/api/v1/storage/vectors/embed`, { text: text.slice(0, 80) + '…' });
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/storage/vectors/embed`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -170,6 +215,7 @@ export class BackendClient {
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
logHttpResponse('POST', `${this.baseUrl}/api/v1/storage/vectors/embed`, res.status);
|
||||
await this.assertHttpOk(res);
|
||||
const data = toCamelCase<{ vector: number[] }>(await res.json());
|
||||
return data.vector;
|
||||
@@ -177,134 +223,98 @@ export class BackendClient {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Chat stream (bidirectional WebSocket)
|
||||
// V3 Chat — send via persistent device WS
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open a WebSocket to the backend, stream chat, and handle tool calls
|
||||
* in-band via the Drizzle executor.
|
||||
*
|
||||
* @param request The enriched chat request (context includes DB snapshot).
|
||||
* @param onChunk Called with each text token as it streams in.
|
||||
* @returns The final response once the backend sends a `final` frame.
|
||||
* Send a home chat request over the persistent device WS.
|
||||
* Registers a stream listener keyed by `requestId` and returns a promise
|
||||
* that resolves when `stream_end` arrives.
|
||||
*/
|
||||
async chatStream(
|
||||
request: ChatRequest,
|
||||
onChunk: (text: string) => void,
|
||||
): Promise<ChatResponse> {
|
||||
return this.withRetry(
|
||||
async () => {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
sendHomeRequest(
|
||||
message: string,
|
||||
conversationHistory?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
|
||||
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
|
||||
): { requestId: string; promise: Promise<void> } {
|
||||
const requestId = crypto.randomUUID();
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
this.streamListeners.set(requestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onBlock: callbacks?.onBlock ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(requestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(requestId);
|
||||
reject(err);
|
||||
},
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
return this.openChatWebSocket(request, token, onChunk);
|
||||
},
|
||||
// Don't retry auth failures
|
||||
(err) => !(err instanceof AuthExpiredError),
|
||||
);
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
this.streamListeners.delete(requestId);
|
||||
reject(new OfflineError('Persistent WS not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const homePayload = toSnakeCase({ type: 'home_request', requestId, message, conversationHistory });
|
||||
logWsSend(homePayload);
|
||||
ws.send(JSON.stringify(homePayload));
|
||||
});
|
||||
|
||||
return { requestId, promise };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSocket internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private openChatWebSocket(
|
||||
request: ChatRequest,
|
||||
token: string,
|
||||
onChunk: (text: string) => void,
|
||||
): Promise<ChatResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = `${this.wsBaseUrl}/api/v1/chat/stream?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let toolIteration = 0;
|
||||
let settled = false;
|
||||
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
if (err) reject(err);
|
||||
};
|
||||
|
||||
ws.on('open', () => {
|
||||
const frame = toSnakeCase({
|
||||
type: 'chat_request',
|
||||
message: request.message,
|
||||
context: request.context,
|
||||
});
|
||||
ws.send(JSON.stringify(frame));
|
||||
/**
|
||||
* Send a floating chat request over the persistent device WS.
|
||||
* Same listener pattern as `sendHomeRequest`.
|
||||
*/
|
||||
sendFloatingRequest(
|
||||
message: string,
|
||||
scope: WsFloatingRequest['scope'],
|
||||
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
|
||||
): { requestId: string; promise: Promise<void> } {
|
||||
const requestId = crypto.randomUUID();
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
this.streamListeners.set(requestId, {
|
||||
onStart: callbacks?.onStart ?? ((): void => { /* no-op */ }),
|
||||
onText: callbacks?.onText ?? ((): void => { /* no-op */ }),
|
||||
onBlock: callbacks?.onBlock ?? ((): void => { /* no-op */ }),
|
||||
onEnd: (mutations) => {
|
||||
callbacks?.onEnd?.(mutations);
|
||||
this.streamListeners.delete(requestId);
|
||||
resolve();
|
||||
},
|
||||
onDomain: callbacks?.onDomain ?? ((): void => { /* no-op */ }),
|
||||
onError: (err) => {
|
||||
callbacks?.onError?.(err);
|
||||
this.streamListeners.delete(requestId);
|
||||
reject(err);
|
||||
},
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
ws.on('message', (raw: Buffer | string) => {
|
||||
const text = typeof raw === 'string' ? raw : raw.toString('utf8');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return; // Malformed frame — ignore
|
||||
}
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
this.streamListeners.delete(requestId);
|
||||
reject(new OfflineError('Persistent WS not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = WsServerFrameSchema.safeParse(toCamelCase(parsed));
|
||||
if (!frame.success) return; // Unknown frame type — ignore
|
||||
|
||||
switch (frame.data.type) {
|
||||
case 'text_chunk':
|
||||
onChunk(frame.data.text);
|
||||
break;
|
||||
|
||||
case 'tool_call': {
|
||||
if (++toolIteration > MAX_TOOL_ITERATIONS) {
|
||||
finish(new ServerError('Exceeded maximum tool call iterations', 500));
|
||||
return;
|
||||
}
|
||||
const toolCall = frame.data;
|
||||
// Fire-and-forget async; errors are sent back as tool_result.error
|
||||
void (async () => {
|
||||
let result: WsToolResult;
|
||||
try {
|
||||
const output = await this.executor.execute(toolCall);
|
||||
result = { type: 'tool_result', id: toolCall.id, ...output } as WsToolResult;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Executor error';
|
||||
result = { type: 'tool_result', id: toolCall.id, error: msg };
|
||||
}
|
||||
if (!settled) {
|
||||
ws.send(JSON.stringify(toSnakeCase(result)));
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'final': {
|
||||
const validated = ChatResponseSchema.safeParse({ response: frame.data.response });
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
if (validated.success) {
|
||||
resolve(validated.data);
|
||||
} else {
|
||||
resolve({ response: frame.data.response });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ping':
|
||||
// No-op — keep-alive from server
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err: Error) => {
|
||||
finish(new OfflineError(`WebSocket error: ${err.message}`));
|
||||
});
|
||||
|
||||
ws.on('close', (code: number) => {
|
||||
if (!settled) {
|
||||
finish(new OfflineError(`WebSocket closed unexpectedly (code ${code})`));
|
||||
}
|
||||
});
|
||||
const floatingPayload = toSnakeCase({ type: 'floating_request', requestId, message, scope });
|
||||
logWsSend(floatingPayload);
|
||||
ws.send(JSON.stringify(floatingPayload));
|
||||
});
|
||||
|
||||
return { requestId, promise };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -332,12 +342,14 @@ export class BackendClient {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
logHttp('GET', `${this.baseUrl}${path}`);
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
logHttpResponse('GET', `${this.baseUrl}${path}`, res.status);
|
||||
await this.assertHttpOk(res);
|
||||
return toCamelCase<T>(await res.json());
|
||||
},
|
||||
@@ -352,6 +364,7 @@ export class BackendClient {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
logHttp('POST', `${this.baseUrl}${path}`, body);
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -362,6 +375,7 @@ export class BackendClient {
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
logHttpResponse('POST', `${this.baseUrl}${path}`, res.status);
|
||||
await this.assertHttpOk(res);
|
||||
return toCamelCase<T>(await res.json());
|
||||
},
|
||||
@@ -376,6 +390,7 @@ export class BackendClient {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
logHttp('PUT', `${this.baseUrl}${path}`, body);
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -386,6 +401,7 @@ export class BackendClient {
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
logHttpResponse('PUT', `${this.baseUrl}${path}`, res.status);
|
||||
await this.assertHttpOk(res);
|
||||
return toCamelCase<T>(await res.json());
|
||||
},
|
||||
@@ -400,12 +416,14 @@ export class BackendClient {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
logHttp('DELETE', `${this.baseUrl}${path}`);
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
logHttpResponse('DELETE', `${this.baseUrl}${path}`, res.status);
|
||||
await this.assertHttpOk(res);
|
||||
return toCamelCase<T>(await res.json());
|
||||
},
|
||||
@@ -608,6 +626,7 @@ export class BackendClient {
|
||||
return;
|
||||
}
|
||||
|
||||
logWsRecv(parsed);
|
||||
const frame = WsServerFrameSchema.safeParse(toCamelCase(parsed));
|
||||
if (!frame.success) return;
|
||||
|
||||
@@ -631,6 +650,7 @@ export class BackendClient {
|
||||
result = { type: 'tool_result', id: toolCall.id, error: msg };
|
||||
}
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
logWsSend(result);
|
||||
ws.send(JSON.stringify(toSnakeCase(result)));
|
||||
}
|
||||
})();
|
||||
@@ -641,10 +661,36 @@ export class BackendClient {
|
||||
// Server keep-alive — no-op
|
||||
break;
|
||||
|
||||
case 'text_chunk':
|
||||
case 'final':
|
||||
// Chat-only frames — ignore on persistent device WS
|
||||
// V3 streaming frames — dispatch to registered listener
|
||||
case 'stream_start': {
|
||||
const listener = this.streamListeners.get(frame.data.requestId);
|
||||
listener?.onStart();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_text': {
|
||||
const listener = this.streamListeners.get(frame.data.requestId);
|
||||
listener?.onText(frame.data.chunk);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_block': {
|
||||
const listener = this.streamListeners.get(frame.data.requestId);
|
||||
listener?.onBlock(frame.data.blockType, frame.data.data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_end': {
|
||||
const listener = this.streamListeners.get(frame.data.requestId);
|
||||
listener?.onEnd(frame.data.mutations);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'floating_domain': {
|
||||
const listener = this.streamListeners.get(frame.data.requestId);
|
||||
listener?.onDomain(frame.data.domain);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -664,6 +710,16 @@ export class BackendClient {
|
||||
this.persistentWs = null;
|
||||
this.stopHeartbeat();
|
||||
console.log(`[DeviceWS] Connection closed (code ${code}).`);
|
||||
|
||||
// Reject any in-flight stream listeners so callers don't hang forever.
|
||||
if (this.streamListeners.size > 0) {
|
||||
const err = new Error('WebSocket connection closed');
|
||||
for (const listener of this.streamListeners.values()) {
|
||||
try { listener.onError(err); } catch { /* ignore */ }
|
||||
}
|
||||
this.streamListeners.clear();
|
||||
}
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { clients, projects, tasks, checkpoints, notes, taskComments } from '../d
|
||||
import { getStore, getDeviceId } from '../store';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
import type { AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog, JourneyMessage, BackupMetadata } from '../../shared/api-types';
|
||||
import { orchestrate, dailyBrief } from '../ai/orchestrator';
|
||||
import { orchestrate, orchestrateFloating, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
|
||||
import { upsertNoteEmbedding } from '../db/vectordb';
|
||||
import { getAuthManager, AuthError } from '../auth/auth-manager';
|
||||
import { getBackupManager } from '../backup/backup-manager';
|
||||
@@ -15,6 +15,14 @@ import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
/** Returns true if the given Unix-ms timestamp falls on today's calendar date. */
|
||||
function isDueToday(ts: number | null | undefined): boolean {
|
||||
if (!ts) return false;
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
const router = t.router;
|
||||
const publicProcedure = t.procedure;
|
||||
|
||||
@@ -292,6 +300,7 @@ const tasksRouter = router({
|
||||
isApproved: input.isApproved ?? 1,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
if (isDueToday(input.dueDate)) invalidateBriefCache();
|
||||
return { id };
|
||||
}),
|
||||
|
||||
@@ -329,6 +338,7 @@ const tasksRouter = router({
|
||||
if (Object.keys(set).length > 0) {
|
||||
getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run();
|
||||
}
|
||||
if (isDueToday(input.dueDate)) invalidateBriefCache();
|
||||
return null;
|
||||
}),
|
||||
|
||||
@@ -392,6 +402,7 @@ const checkpointsRouter = router({
|
||||
isApproved: input.isApproved ?? 0,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
if (isDueToday(input.date)) invalidateBriefCache();
|
||||
return { id };
|
||||
}),
|
||||
|
||||
@@ -410,6 +421,7 @@ const checkpointsRouter = router({
|
||||
if (Object.keys(set).length > 0) {
|
||||
getDb().update(checkpoints).set(set).where(eq(checkpoints.id, input.id)).run();
|
||||
}
|
||||
if (isDueToday(input.date)) invalidateBriefCache();
|
||||
return null;
|
||||
}),
|
||||
|
||||
@@ -551,25 +563,31 @@ const settingsRouter = router({
|
||||
});
|
||||
|
||||
const aiRouter = router({
|
||||
/**
|
||||
* Chat mutation — local orchestration.
|
||||
* The inline input schema mirrors `ChatRequest` from `@shared/api-types`.
|
||||
* Will be replaced with the shared schema in Step 3.2.
|
||||
*/
|
||||
chat: publicProcedure
|
||||
.input(z.object({
|
||||
message: z.string(),
|
||||
context: z.object({
|
||||
type: z.enum(['global', 'project']),
|
||||
projectId: z.string().optional(),
|
||||
uiContext: z.string().optional(),
|
||||
}),
|
||||
conversationHistory: z.array(z.object({
|
||||
role: z.enum(['user', 'assistant', 'system']),
|
||||
content: z.string(),
|
||||
})).optional(),
|
||||
mode: z.enum(['home', 'floating']).optional(),
|
||||
scope: z.object({
|
||||
type: z.enum(['task', 'project', 'note', 'checkpoint']),
|
||||
id: z.string().optional(),
|
||||
}).optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
if (input.mode === 'floating' && input.scope) {
|
||||
return await orchestrateFloating({
|
||||
message: input.message,
|
||||
scope: input.scope,
|
||||
sender: ctx.sender,
|
||||
});
|
||||
}
|
||||
return await orchestrate({
|
||||
message: input.message,
|
||||
context: input.context,
|
||||
conversationHistory: input.conversationHistory,
|
||||
sender: ctx.sender,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -577,6 +595,9 @@ const aiRouter = router({
|
||||
return { response: '', error: msg };
|
||||
}
|
||||
}),
|
||||
getBrief: publicProcedure
|
||||
.query(() => getCachedBrief()),
|
||||
|
||||
dailyBrief: publicProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,8 @@ interface AppSettings {
|
||||
backupIntervalHours: number;
|
||||
/** Unix epoch ms of the last successful backup upload. Null if none. */
|
||||
lastBackupAt: number | null;
|
||||
/** Cached daily brief — regenerated once per day or when relevant data changes. */
|
||||
dailyBriefCache: { content: string; date: string } | null;
|
||||
}
|
||||
|
||||
let _store: Store<AppSettings> | null = null;
|
||||
@@ -34,6 +36,7 @@ export function getStore(): Store<AppSettings> {
|
||||
backupEnabled: false,
|
||||
backupIntervalHours: 24,
|
||||
lastBackupAt: null,
|
||||
dailyBriefCache: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,24 +20,30 @@ contextBridge.exposeInMainWorld('electronTRPC', {
|
||||
});
|
||||
|
||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
const AI_ACTION_CHANNEL = 'ai:action';
|
||||
|
||||
// V3 stream event — discriminated union of all frame types the renderer can receive.
|
||||
type V3StreamEvent =
|
||||
| { type: 'stream_start'; requestId: string }
|
||||
| { type: 'stream_text'; requestId: string; chunk: string }
|
||||
| { type: 'stream_block'; requestId: string; blockType: 'chart' | 'entity_ref' | 'table' | 'timeline'; data: Record<string, unknown> }
|
||||
| { type: 'stream_end'; requestId: string; mutations?: Record<string, unknown> }
|
||||
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'checkpoints' | 'projects' };
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAI', {
|
||||
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
|
||||
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { token: string; done: boolean }) => cb(data);
|
||||
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */
|
||||
onStreamEvent: (cb: (data: V3StreamEvent) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: V3StreamEvent) => cb(data);
|
||||
ipcRenderer.on(AI_STREAM_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
|
||||
/** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */
|
||||
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data);
|
||||
ipcRenderer.on(AI_ACTION_CHANNEL, handler);
|
||||
/** Subscribe to background brief-updated push events. Returns an unsubscribe function. */
|
||||
onBriefUpdated: (cb: (content: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, content: string) => cb(content);
|
||||
ipcRenderer.on('ai:brief-updated', handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
|
||||
ipcRenderer.removeListener('ai:brief-updated', handler);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { GradualBlur } from '@/components/ui/gradual-blur';
|
||||
import { BlockRenderer } from './blocks';
|
||||
|
||||
/** Fluid font size for chat messages — scales with viewport width */
|
||||
const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
|
||||
@@ -43,6 +44,16 @@ const fadeUp = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Module-level brief state — survives component remounts (navigation away and back).
|
||||
* `content` is the text to display immediately on remount without waiting for the query.
|
||||
* `streamFired` prevents re-streaming across remounts.
|
||||
*/
|
||||
const briefModule = {
|
||||
content: null as string | null,
|
||||
streamFired: false,
|
||||
};
|
||||
|
||||
interface AIChatPanelProps {
|
||||
isHomePage?: boolean;
|
||||
}
|
||||
@@ -50,6 +61,7 @@ interface AIChatPanelProps {
|
||||
export function AIChatPanel({
|
||||
isHomePage,
|
||||
}: AIChatPanelProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const authStatusQuery = trpc.auth.status.useQuery();
|
||||
|
||||
// Home-specific queries
|
||||
@@ -66,14 +78,13 @@ export function AIChatPanel({
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingBlocks,
|
||||
handleSend: chatHandleSend,
|
||||
} = useAIChat(chatContext);
|
||||
|
||||
// Daily brief state (home page only)
|
||||
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
|
||||
const [briefLoading, setBriefLoading] = useState(false);
|
||||
// Daily brief state — initialized from module-level cache so remounts are instant.
|
||||
const [dailyBrief, setDailyBrief] = useState<string | null>(() => briefModule.content);
|
||||
const briefContentRef = useRef('');
|
||||
const hasFiredBrief = useRef(false);
|
||||
|
||||
const [briefExpanded, setBriefExpanded] = useState(false);
|
||||
const [briefDismissed, setBriefDismissed] = useState(false);
|
||||
@@ -88,6 +99,15 @@ export function AIChatPanel({
|
||||
const pendingScrollRef = useRef(false);
|
||||
|
||||
const briefMutation = trpc.ai.dailyBrief.useMutation();
|
||||
const briefLoading = briefMutation.isPending;
|
||||
|
||||
// Fetch cached brief from main process — null means stale/missing (needs regen).
|
||||
const cachedBriefQuery = trpc.ai.getBrief.useQuery(undefined, {
|
||||
enabled: !!isHomePage && !!authStatusQuery.data?.authenticated,
|
||||
// Don't auto-refetch; we'll invalidate manually on push events.
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// When the user message appears in the list, set the placeholder and scroll it to the top
|
||||
useEffect(() => {
|
||||
@@ -120,44 +140,70 @@ export function AIChatPanel({
|
||||
return () => observer.disconnect();
|
||||
}, [isStreaming, streamingEl]);
|
||||
|
||||
// Auto-fire daily brief on home page
|
||||
// Sync query result into module cache so remounts get it instantly.
|
||||
useEffect(() => {
|
||||
if (!isHomePage || hasFiredBrief.current || !authStatusQuery.data?.authenticated) return;
|
||||
hasFiredBrief.current = true;
|
||||
setBriefLoading(true);
|
||||
const cached = cachedBriefQuery.data;
|
||||
if (!cached) return;
|
||||
briefModule.content = cached;
|
||||
setDailyBrief(cached);
|
||||
}, [cachedBriefQuery.data]);
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
|
||||
if (done) {
|
||||
setDailyBrief(briefContentRef.current);
|
||||
setBriefLoading(false);
|
||||
unsubscribe();
|
||||
// Stream a fresh brief when cache is empty (once per app session).
|
||||
useEffect(() => {
|
||||
if (!isHomePage || !authStatusQuery.data?.authenticated) return;
|
||||
// Already have content (from module cache or query) — nothing to do.
|
||||
if (briefModule.content) return;
|
||||
// Wait for query to finish before deciding whether to stream.
|
||||
if (cachedBriefQuery.isLoading) return;
|
||||
// Query returned content — handled by the sync effect above.
|
||||
if (cachedBriefQuery.data) return;
|
||||
// Guard against firing twice.
|
||||
if (briefModule.streamFired) return;
|
||||
briefModule.streamFired = true;
|
||||
briefContentRef.current = '';
|
||||
|
||||
let briefRequestId: string | null = null;
|
||||
let unsubscribe: (() => void) | null = window.electronAI.onStreamEvent((event) => {
|
||||
if (event.type === 'stream_start') {
|
||||
briefRequestId = event.requestId;
|
||||
return;
|
||||
}
|
||||
briefContentRef.current += token;
|
||||
setDailyBrief(briefContentRef.current);
|
||||
if (briefRequestId !== null && event.requestId !== briefRequestId) return;
|
||||
|
||||
if (event.type === 'stream_text') {
|
||||
briefContentRef.current += event.chunk;
|
||||
briefModule.content = briefContentRef.current;
|
||||
setDailyBrief(briefContentRef.current);
|
||||
} else if (event.type === 'stream_end') {
|
||||
const final = briefContentRef.current || null;
|
||||
briefModule.content = final;
|
||||
setDailyBrief(final);
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
// Invalidate so the persisted cache is loaded on next app launch / reload.
|
||||
void utils.ai.getBrief.invalidate();
|
||||
}
|
||||
});
|
||||
|
||||
briefMutation.mutate(undefined, {
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
unsubscribe();
|
||||
setDailyBrief(null);
|
||||
setBriefLoading(false);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
unsubscribe();
|
||||
setDailyBrief(null);
|
||||
setBriefLoading(false);
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
briefModule.streamFired = false; // allow retry on error
|
||||
},
|
||||
});
|
||||
}, [isHomePage, authStatusQuery.data?.authenticated]); // briefMutation excluded — only fire once
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isHomePage, authStatusQuery.data?.authenticated, cachedBriefQuery.isLoading, cachedBriefQuery.data]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (briefLoading) return;
|
||||
pendingScrollRef.current = true;
|
||||
chatHandleSend();
|
||||
}, [briefLoading, chatHandleSend]);
|
||||
}, [chatHandleSend]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -313,18 +359,14 @@ export function AIChatPanel({
|
||||
</div>
|
||||
) : dailyBrief ? (
|
||||
<ChatMarkdown content={dailyBrief} size="lg" />
|
||||
) : (
|
||||
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
|
||||
Your daily brief will appear here.
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
{/* Input + suggestion links */}
|
||||
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||
<ChatInput
|
||||
input={input}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
isStreaming={isStreaming}
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
@@ -389,8 +431,11 @@ export function AIChatPanel({
|
||||
<Sparkles size={16} className="text-foreground" />
|
||||
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||
</div>
|
||||
<div className="pl-[22px]">
|
||||
<div className="pl-[22px] flex flex-col gap-3">
|
||||
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||
{msg.blocks.map((block) => (
|
||||
<BlockRenderer key={block.id} block={block} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -404,8 +449,11 @@ export function AIChatPanel({
|
||||
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||
</div>
|
||||
{streamingContent ? (
|
||||
<div className="pl-[22px]">
|
||||
<div className="pl-[22px] flex flex-col gap-3">
|
||||
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
|
||||
{streamingBlocks.map((block) => (
|
||||
<BlockRenderer key={block.id} block={block} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 pl-[22px]">
|
||||
@@ -441,7 +489,7 @@ export function AIChatPanel({
|
||||
<div className="relative pointer-events-auto mx-auto max-w-3xl">
|
||||
<ChatInput
|
||||
input={input}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
isStreaming={isStreaming}
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
|
||||
@@ -13,23 +13,17 @@ import {
|
||||
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
|
||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
/** Map section IDs to their routes for cross-page navigation */
|
||||
const SECTION_ROUTES: Record<string, string> = {
|
||||
'project-summary': 'project',
|
||||
'project-timeline': 'project',
|
||||
'project-tasks': 'project',
|
||||
'project-notes': 'project',
|
||||
'tasks-overview': '/tasks',
|
||||
'tasks-list': '/tasks',
|
||||
'timeline-chart': '/timeline',
|
||||
'note-editor': 'note',
|
||||
/** Map floating_domain signals to routes for background navigation */
|
||||
const DOMAIN_ROUTES: Record<string, string> = {
|
||||
tasks: '/tasks',
|
||||
notes: '/notes',
|
||||
checkpoints: '/timeline',
|
||||
projects: '/projects',
|
||||
};
|
||||
|
||||
function FloatingChatInner() {
|
||||
const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
|
||||
const utils = trpc.useUtils();
|
||||
const { state, sections, close, updatePosition, setPendingSection } = useFloatingChat();
|
||||
const navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const prevPathRef = useRef(routerState.location.pathname);
|
||||
@@ -37,41 +31,42 @@ function FloatingChatInner() {
|
||||
// Active section lookup
|
||||
const activeSection = sections.get(state.activeSectionId ?? '');
|
||||
|
||||
// Chat context derived from active section
|
||||
const chatContext = useMemo<UIChatContext>(
|
||||
() => ({
|
||||
type: activeSection?.projectId ? 'project' : 'global',
|
||||
// Chat context — floating mode with scope derived from active section
|
||||
const chatContext = useMemo<UIChatContext>(() => {
|
||||
const scope = activeSection
|
||||
? {
|
||||
type: (activeSection.label?.toLowerCase().includes('task')
|
||||
? 'task'
|
||||
: activeSection.label?.toLowerCase().includes('note')
|
||||
? 'note'
|
||||
: activeSection.label?.toLowerCase().includes('checkpoint') || activeSection.label?.toLowerCase().includes('timeline')
|
||||
? 'checkpoint'
|
||||
: 'project') as 'task' | 'project' | 'note' | 'checkpoint',
|
||||
id: activeSection.projectId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: 'floating' as const,
|
||||
projectId: activeSection?.projectId,
|
||||
uiContext: activeSection?.label,
|
||||
}),
|
||||
[activeSection?.projectId, activeSection?.label],
|
||||
);
|
||||
scope,
|
||||
};
|
||||
}, [activeSection?.projectId, activeSection?.label]);
|
||||
|
||||
// Handle [SECTION:xxx] tags from AI responses
|
||||
const handleSectionTag = useCallback((sectionId: string) => {
|
||||
// Same-page: section is already registered
|
||||
const targetSection = sections.get(sectionId);
|
||||
if (targetSection) {
|
||||
moveToSection(sectionId);
|
||||
targetSection.ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
// Handle floating_domain signals — navigate in background
|
||||
const handleDomainSignal = useCallback(
|
||||
(domain: 'tasks' | 'notes' | 'checkpoints' | 'projects') => {
|
||||
const route = DOMAIN_ROUTES[domain];
|
||||
if (!route) return;
|
||||
|
||||
// Cross-page: section not registered, navigate to its route
|
||||
const route = SECTION_ROUTES[sectionId];
|
||||
if (!route) return;
|
||||
const currentPath = routerState.location.pathname;
|
||||
if (currentPath === route) return;
|
||||
|
||||
setPendingSection({ sectionId });
|
||||
|
||||
if (route === 'project' && state.projectId) {
|
||||
// Navigate to the project page (stay on same project)
|
||||
// Project sections re-register on mount and pendingSection will auto-open
|
||||
void navigate({ to: '/projects', search: { projectId: state.projectId } });
|
||||
} else if (route.startsWith('/')) {
|
||||
setPendingSection({ sectionId: domain });
|
||||
void navigate({ to: route });
|
||||
}
|
||||
// 'note' type requires noteId — skip cross-page for now
|
||||
}, [sections, moveToSection, setPendingSection, state.projectId, navigate]);
|
||||
},
|
||||
[routerState.location.pathname, navigate, setPendingSection],
|
||||
);
|
||||
|
||||
const {
|
||||
messages,
|
||||
@@ -81,7 +76,7 @@ function FloatingChatInner() {
|
||||
streamingContent,
|
||||
handleSend,
|
||||
clearMessages,
|
||||
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
|
||||
} = useAIChat(chatContext, { onDomainSignal: handleDomainSignal });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -119,31 +114,6 @@ function FloatingChatInner() {
|
||||
prevOpenRef.current = state.isOpen;
|
||||
}, [state.isOpen, clearMessages]);
|
||||
|
||||
// ---- AI action: morph into newly-created task ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
|
||||
const unsubscribe = window.electronAI.onAction((action) => {
|
||||
if (action.type === 'task_created' && action.taskId) {
|
||||
// Invalidate task queries so the new TaskRow renders
|
||||
void utils.tasks.list.invalidate();
|
||||
|
||||
// Set the morph target layoutId
|
||||
setMorphTarget(`task-morph-${action.taskId}`);
|
||||
|
||||
// Wait for the TaskRow to render, then close (triggering FLIP)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
close();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [state.isOpen, utils, setMorphTarget, close]);
|
||||
|
||||
// ---- Window resize: keep within bounds ----
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
161
src/renderer/components/ai/blocks/ChatChartBlock.tsx
Normal file
161
src/renderer/components/ai/blocks/ChatChartBlock.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
Radar,
|
||||
RadarChart,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
XAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import type { ChartBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatChartBlock({ data: blockData }: { data: ChartBlockData }) {
|
||||
const { chartType, title, data, config } = blockData;
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
for (const [key, val] of Object.entries(config)) {
|
||||
cfg[key] = { label: val.label, color: val.color };
|
||||
}
|
||||
return cfg;
|
||||
}, [config]);
|
||||
|
||||
const dataKeys = useMemo(() => Object.keys(config), [config]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
{title && (
|
||||
<p className="mb-3 text-sm font-medium">{title}</p>
|
||||
)}
|
||||
<ChartContainer config={chartConfig} className="max-h-[240px] w-full">
|
||||
{renderChart(chartType, data, dataKeys)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderChart(
|
||||
chartType: ChartBlockData['chartType'],
|
||||
data: Record<string, unknown>[],
|
||||
dataKeys: string[],
|
||||
) {
|
||||
switch (chartType) {
|
||||
case 'area':
|
||||
return (
|
||||
<AreaChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
fill={`var(--color-${key})`}
|
||||
stroke={`var(--color-${key})`}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<BarChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Bar key={key} dataKey={key} fill={`var(--color-${key})`} radius={4} />
|
||||
))}
|
||||
</BarChart>
|
||||
);
|
||||
case 'line':
|
||||
return (
|
||||
<LineChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={`var(--color-${key})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
);
|
||||
case 'pie':
|
||||
return (
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={dataKeys[0] ?? 'value'}
|
||||
nameKey="name"
|
||||
innerRadius="40%"
|
||||
outerRadius="70%"
|
||||
/>
|
||||
</PieChart>
|
||||
);
|
||||
case 'radar':
|
||||
return (
|
||||
<RadarChart data={data}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="name" />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Radar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={`var(--color-${key})`}
|
||||
fillOpacity={0.3}
|
||||
stroke={`var(--color-${key})`}
|
||||
/>
|
||||
))}
|
||||
</RadarChart>
|
||||
);
|
||||
case 'radial':
|
||||
return (
|
||||
<RadialBarChart data={data} innerRadius="30%" outerRadius="90%">
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<RadialBar key={key} dataKey={key} fill={`var(--color-${key})`} />
|
||||
))}
|
||||
</RadialBarChart>
|
||||
);
|
||||
}
|
||||
}
|
||||
107
src/renderer/components/ai/blocks/ChatEntityBlock.tsx
Normal file
107
src/renderer/components/ai/blocks/ChatEntityBlock.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { FileText, FolderOpen, Sparkles } from 'lucide-react';
|
||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||
import type { EntityRefBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
function noop() {}
|
||||
|
||||
export function ChatEntityBlock({ data }: { data: EntityRefBlockData }) {
|
||||
const { entity, items } = data;
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{entity === 'task' ? 'Tasks' : entity === 'project' ? 'Projects' : entity === 'note' ? 'Notes' : 'Checkpoints'}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.map((item, i) => {
|
||||
const raw = item as Record<string, unknown>;
|
||||
const id = String(raw.id ?? i);
|
||||
|
||||
switch (entity) {
|
||||
case 'task':
|
||||
return (
|
||||
<TaskRow
|
||||
key={id}
|
||||
task={mapToTaskItem(raw)}
|
||||
onToggle={noop}
|
||||
hideBreadcrumb
|
||||
/>
|
||||
);
|
||||
case 'project':
|
||||
return (
|
||||
<Item key={id} variant="outline" size="sm">
|
||||
<ItemMedia variant="icon">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{String(raw.name ?? raw.title ?? 'Untitled')}</ItemTitle>
|
||||
{raw.description && (
|
||||
<ItemDescription>{String(raw.description)}</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
case 'note':
|
||||
return (
|
||||
<Item key={id} variant="outline" size="sm">
|
||||
<ItemMedia variant="icon">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{String(raw.title ?? 'Untitled')}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
case 'checkpoint':
|
||||
return (
|
||||
<Item
|
||||
key={id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={raw.isAiSuggested ? 'border-dashed' : ''}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
{raw.isAiSuggested ? (
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{String(raw.title ?? 'Untitled')}</ItemTitle>
|
||||
{raw.date && (
|
||||
<ItemDescription>
|
||||
{new Date(Number(raw.date)).toLocaleDateString()}
|
||||
</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mapToTaskItem(raw: Record<string, unknown>): TaskItem {
|
||||
return {
|
||||
id: String(raw.id ?? ''),
|
||||
projectId: raw.projectId != null ? String(raw.projectId) : null,
|
||||
title: String(raw.title ?? 'Untitled'),
|
||||
description: raw.description != null ? String(raw.description) : null,
|
||||
status: raw.status != null ? String(raw.status) : null,
|
||||
priority: raw.priority != null ? String(raw.priority) : null,
|
||||
assignee: raw.assignee != null ? String(raw.assignee) : null,
|
||||
dueDate: raw.dueDate != null ? Number(raw.dueDate) : null,
|
||||
isAiSuggested: Number(raw.isAiSuggested ?? 0),
|
||||
isApproved: Number(raw.isApproved ?? 0),
|
||||
projectName: raw.projectName != null ? String(raw.projectName) : null,
|
||||
clientName: raw.clientName != null ? String(raw.clientName) : null,
|
||||
subClientName: raw.subClientName != null ? String(raw.subClientName) : null,
|
||||
};
|
||||
}
|
||||
40
src/renderer/components/ai/blocks/ChatTableBlock.tsx
Normal file
40
src/renderer/components/ai/blocks/ChatTableBlock.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import type { TableBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatTableBlock({ data }: { data: TableBlockData }) {
|
||||
const { headers, rows } = data;
|
||||
|
||||
if (!headers.length && !rows.length) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<Table>
|
||||
{headers.length > 0 && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headers.map((h, i) => (
|
||||
<TableHead key={i}>{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)}
|
||||
<TableBody>
|
||||
{rows.map((row, ri) => (
|
||||
<TableRow key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<TableCell key={ci}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/renderer/components/ai/blocks/ChatTimelineBlock.tsx
Normal file
27
src/renderer/components/ai/blocks/ChatTimelineBlock.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { format } from 'date-fns';
|
||||
import type { TimelineBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatTimelineBlock({ data }: { data: TimelineBlockData }) {
|
||||
const { checkpoints } = data;
|
||||
|
||||
if (!checkpoints.length) return null;
|
||||
|
||||
const sorted = [...checkpoints].sort((a, b) => a.date - b.date);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="relative ml-3 border-l border-border pl-6">
|
||||
{sorted.map((cp, i) => (
|
||||
<div key={cp.id} className={i < sorted.length - 1 ? 'pb-5' : ''}>
|
||||
{/* Dot on the timeline */}
|
||||
<div className="absolute -left-[5px] mt-1.5 h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
<p className="text-sm font-medium leading-tight">{cp.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{format(new Date(cp.date), 'MMM d, yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/renderer/components/ai/blocks/index.tsx
Normal file
41
src/renderer/components/ai/blocks/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { StreamBlock } from '@/hooks/useAIChat';
|
||||
import type {
|
||||
ChartBlockData,
|
||||
EntityRefBlockData,
|
||||
TableBlockData,
|
||||
TimelineBlockData,
|
||||
} from '../../../../../shared/api-types';
|
||||
import { ChatChartBlock } from './ChatChartBlock';
|
||||
import { ChatEntityBlock } from './ChatEntityBlock';
|
||||
import { ChatTableBlock } from './ChatTableBlock';
|
||||
import { ChatTimelineBlock } from './ChatTimelineBlock';
|
||||
|
||||
const blockAnimation = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
|
||||
};
|
||||
|
||||
export function BlockRenderer({ block }: { block: StreamBlock }) {
|
||||
return (
|
||||
<motion.div {...blockAnimation}>
|
||||
{renderBlock(block)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBlock(block: StreamBlock) {
|
||||
switch (block.blockType) {
|
||||
case 'chart':
|
||||
return <ChatChartBlock data={block.data as unknown as ChartBlockData} />;
|
||||
case 'entity_ref':
|
||||
return <ChatEntityBlock data={block.data as unknown as EntityRefBlockData} />;
|
||||
case 'table':
|
||||
return <ChatTableBlock data={block.data as unknown as TableBlockData} />;
|
||||
case 'timeline':
|
||||
return <ChatTimelineBlock data={block.data as unknown as TimelineBlockData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
211
src/renderer/components/auth/LoginForm.tsx
Normal file
211
src/renderer/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sign-in form (login-03 layout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SignInForm({
|
||||
className,
|
||||
onSwitchMode,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
|
||||
const utils = trpc.useUtils();
|
||||
const loginMutation = trpc.auth.login.useMutation();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function handleSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!email || !password) return;
|
||||
setError('');
|
||||
loginMutation.mutate({ email, password }, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.success) setError(res.error ?? 'Authentication failed');
|
||||
else void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<CardDescription>Sign in to your Adiuva account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
disabled={loginMutation.isPending}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
disabled={loginMutation.isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive -mt-2">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loginMutation.isPending || !email || !password}>
|
||||
Sign in
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode}>
|
||||
Sign up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-balance text-center text-xs text-muted-foreground">
|
||||
AI features require a connection to the Adiuva backend.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sign-up form (signup-03 layout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SignUpForm({
|
||||
className,
|
||||
onSwitchMode,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
|
||||
const utils = trpc.useUtils();
|
||||
const registerMutation = trpc.auth.register.useMutation();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function handleSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!email || !password) return;
|
||||
setError('');
|
||||
registerMutation.mutate({ email, password }, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.success) setError(res.error ?? 'Registration failed');
|
||||
else void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Create your account</CardTitle>
|
||||
<CardDescription>Enter your email below to create your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-email">Email</FieldLabel>
|
||||
<Input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-password">Password</FieldLabel>
|
||||
<Input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<FieldDescription>Must be at least 8 characters long.</FieldDescription>
|
||||
</Field>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Field>
|
||||
<Button type="submit" className="w-full" disabled={registerMutation.isPending || !email || !password}>
|
||||
Create Account
|
||||
</Button>
|
||||
<FieldDescription className="text-center">
|
||||
Already have an account?{' '}
|
||||
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode}>
|
||||
Sign in
|
||||
</button>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FieldDescription className="px-6 text-center">
|
||||
By creating an account, you agree to our terms of service.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell — logo + mode switcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LoginForm() {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-full flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<div className="flex items-center gap-2 self-center font-medium">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L13.5 8.5L20 10L13.5 11.5L12 18L10.5 11.5L4 10L10.5 8.5L12 2Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
Adiuva
|
||||
</div>
|
||||
{mode === 'login' ? (
|
||||
<SignInForm onSwitchMode={() => setMode('register')} />
|
||||
) : (
|
||||
<SignUpForm onSwitchMode={() => setMode('login')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
FolderKanban,
|
||||
PanelLeft,
|
||||
Settings,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
||||
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
|
||||
import { FloatingChatProvider } from '@/context/FloatingChatContext';
|
||||
import { LoginForm } from '@/components/auth/LoginForm';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', icon: House, label: 'Home' },
|
||||
@@ -45,7 +45,9 @@ interface AppShellProps {
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<FloatingChatProvider>
|
||||
<AppShellInner>{children}</AppShellInner>
|
||||
<div className="flex w-full h-full">
|
||||
<AppShellInner>{children}</AppShellInner>
|
||||
</div>
|
||||
</FloatingChatProvider>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +55,11 @@ export function AppShell({ children }: AppShellProps) {
|
||||
function AppShellInner({ children }: AppShellProps) {
|
||||
useDoubleClickAI();
|
||||
|
||||
const authStatusQuery = trpc.auth.status.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000, // 5 min — don't hammer the backend on every focus
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
@@ -76,6 +83,13 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
|
||||
const isHomePage = currentPath === '/';
|
||||
|
||||
// Auth gate — show login screen until we know the user is authenticated.
|
||||
// While loading (data is undefined), fall through to app to avoid flicker;
|
||||
// only show the login form when we have a definitive `authenticated: false`.
|
||||
if (authStatusQuery.data?.authenticated === false) {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
357
src/renderer/components/ui/chart.tsx
Normal file
357
src/renderer/components/ui/chart.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
246
src/renderer/components/ui/field.tsx
Normal file
246
src/renderer/components/ui/field.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
22
src/renderer/components/ui/label.tsx
Normal file
22
src/renderer/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
114
src/renderer/components/ui/table.tsx
Normal file
114
src/renderer/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -2,27 +2,34 @@ import { useState, useCallback, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI-only chat context (renderer concern — not sent to backend)
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renderer-only context describing where the user is in the UI.
|
||||
* The main process uses these fields to build the enriched `ChatContext`
|
||||
* (from `@shared/api-types`) before forwarding to the backend.
|
||||
* Retained for call-site compatibility; mode/scope fields support v3 routing.
|
||||
*/
|
||||
export interface UIChatContext {
|
||||
/** Scope of the conversation. */
|
||||
type: 'global' | 'project';
|
||||
/** Active project ID when `type === 'project'`. */
|
||||
type: 'global' | 'project' | 'floating';
|
||||
projectId?: string;
|
||||
/** Serialised description of the current UI state (visible section, selected item, etc.). */
|
||||
uiContext?: string;
|
||||
/** For floating mode — the entity scope to pass to the backend. */
|
||||
scope?: {
|
||||
type: 'task' | 'project' | 'note' | 'checkpoint';
|
||||
id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
export interface StreamBlock {
|
||||
id: string;
|
||||
blockType: 'chart' | 'entity_ref' | 'table' | 'timeline';
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
blocks: StreamBlock[];
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
@@ -32,27 +39,36 @@ interface UseAIChatReturn {
|
||||
setInput: (v: string) => void;
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
streamingBlocks: StreamBlock[];
|
||||
handleSend: (overrideMessage?: string, overrideContext?: UIChatContext) => void;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
interface UseAIChatOptions {
|
||||
onSectionTag?: (sectionId: string) => void;
|
||||
onDomainSignal?: (domain: 'tasks' | 'notes' | 'checkpoints' | 'projects') => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOptions): UseAIChatReturn {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [streamingBlocks, setStreamingBlocks] = useState<StreamBlock[]>([]);
|
||||
|
||||
const streamingContentRef = useRef('');
|
||||
const streamingBlocksRef = useRef<StreamBlock[]>([]);
|
||||
const chatMutation = trpc.ai.chat.useMutation();
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
@@ -60,53 +76,100 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
const trimmed = (overrideMessage ?? input).trim();
|
||||
if (!trimmed || isStreaming) return;
|
||||
|
||||
const ctx = overrideContext ?? defaultContext;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
blocks: [],
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
if (!overrideMessage) setInput('');
|
||||
setIsStreaming(true);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
|
||||
if (done) {
|
||||
let finalContent = streamingContentRef.current;
|
||||
// Capture the requestId from stream_start so we only handle events
|
||||
// for this specific chat request (avoids cross-contamination with
|
||||
// other concurrent streams like the daily brief).
|
||||
let activeRequestId: string | null = null;
|
||||
|
||||
// Parse and strip [SECTION:xxx] tag from AI response
|
||||
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
|
||||
if (sectionMatch) {
|
||||
finalContent = finalContent.slice(sectionMatch[0].length);
|
||||
options?.onSectionTag?.(sectionMatch[1]!);
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
|
||||
]);
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
setIsStreaming(false);
|
||||
unsubscribe();
|
||||
const unsubscribe = window.electronAI.onStreamEvent((event) => {
|
||||
// Latch the requestId on first stream_start
|
||||
if (event.type === 'stream_start') {
|
||||
activeRequestId = event.requestId;
|
||||
return;
|
||||
}
|
||||
streamingContentRef.current += token;
|
||||
setStreamingContent(streamingContentRef.current);
|
||||
|
||||
// Once we have a requestId, ignore events from other streams
|
||||
if (activeRequestId !== null && event.requestId !== activeRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'stream_text':
|
||||
streamingContentRef.current += event.chunk;
|
||||
setStreamingContent(streamingContentRef.current);
|
||||
break;
|
||||
|
||||
case 'stream_block': {
|
||||
const block: StreamBlock = {
|
||||
id: crypto.randomUUID(),
|
||||
blockType: event.blockType,
|
||||
data: event.data,
|
||||
};
|
||||
streamingBlocksRef.current = [...streamingBlocksRef.current, block];
|
||||
setStreamingBlocks([...streamingBlocksRef.current]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream_end': {
|
||||
const finalContent = streamingContentRef.current;
|
||||
const finalBlocks = streamingBlocksRef.current;
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
blocks: finalBlocks,
|
||||
},
|
||||
]);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
setIsStreaming(false);
|
||||
unsubscribe();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'floating_domain':
|
||||
options?.onDomainSignal?.(event.domain);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const ctx = overrideContext ?? defaultContext;
|
||||
// Build conversation history from current messages (before this send)
|
||||
const conversationHistory = messages.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const isFloating = ctx.type === 'floating';
|
||||
|
||||
chatMutation.mutate(
|
||||
{
|
||||
message: trimmed,
|
||||
context: {
|
||||
type: ctx.type,
|
||||
...(ctx.type === 'project' && ctx.projectId ? { projectId: ctx.projectId } : {}),
|
||||
...(ctx.uiContext ? { uiContext: ctx.uiContext } : {}),
|
||||
},
|
||||
conversationHistory,
|
||||
...(isFloating && ctx.scope
|
||||
? { mode: 'floating' as const, scope: ctx.scope }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
@@ -114,10 +177,35 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
unsubscribe();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, blocks: [], error: true },
|
||||
]);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
setIsStreaming(false);
|
||||
} else {
|
||||
// Safety fallback: the tRPC response always arrives after the
|
||||
// orchestrator finishes (which is after stream_end was sent).
|
||||
// If the IPC stream_end was missed (e.g. listener removed by
|
||||
// React StrictMode, or requestId mismatch), this ensures
|
||||
// isStreaming is reset.
|
||||
unsubscribe();
|
||||
if (streamingContentRef.current || streamingBlocksRef.current.length) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: streamingContentRef.current,
|
||||
blocks: streamingBlocksRef.current,
|
||||
},
|
||||
]);
|
||||
}
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
@@ -129,17 +217,20 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: err.message || 'An unexpected error occurred.',
|
||||
blocks: [],
|
||||
error: true,
|
||||
},
|
||||
]);
|
||||
setStreamingContent('');
|
||||
setStreamingBlocks([]);
|
||||
streamingContentRef.current = '';
|
||||
streamingBlocksRef.current = [];
|
||||
setIsStreaming(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[input, isStreaming, defaultContext, chatMutation],
|
||||
[input, isStreaming, defaultContext, chatMutation, messages, options],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -148,6 +239,7 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
streamingBlocks,
|
||||
handleSend,
|
||||
clearMessages,
|
||||
};
|
||||
|
||||
@@ -13,9 +13,16 @@ interface ElectronTRPC {
|
||||
onMessage: (cb: (data: unknown) => void) => (() => void) | void;
|
||||
}
|
||||
|
||||
type V3StreamEvent =
|
||||
| { type: 'stream_start'; requestId: string }
|
||||
| { type: 'stream_text'; requestId: string; chunk: string }
|
||||
| { type: 'stream_block'; requestId: string; blockType: 'chart' | 'entity_ref' | 'table' | 'timeline'; data: Record<string, unknown> }
|
||||
| { type: 'stream_end'; requestId: string; mutations?: Record<string, unknown> }
|
||||
| { type: 'floating_domain'; requestId: string; domain: 'tasks' | 'notes' | 'checkpoints' | 'projects' };
|
||||
|
||||
interface ElectronAI {
|
||||
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
|
||||
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => () => void;
|
||||
onStreamEvent: (cb: (data: V3StreamEvent) => void) => () => void;
|
||||
onBriefUpdated: (cb: (content: string) => void) => () => void;
|
||||
}
|
||||
|
||||
interface ElectronDialog {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from 'zod';
|
||||
import {
|
||||
Settings,
|
||||
User,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Bot,
|
||||
Palette,
|
||||
@@ -232,19 +231,13 @@ function GeneralSection() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AccountSection() {
|
||||
const authStatusQuery = trpc.auth.status.useQuery();
|
||||
const authStatusQuery = trpc.auth.status.useQuery(undefined, { staleTime: 5 * 60 * 1000 });
|
||||
const backendUrlQuery = trpc.auth.getBackendUrl.useQuery();
|
||||
const loginMutation = trpc.auth.login.useMutation();
|
||||
const logoutMutation = trpc.auth.logout.useMutation();
|
||||
const registerMutation = trpc.auth.register.useMutation();
|
||||
const setBackendUrlMutation = trpc.auth.setBackendUrl.useMutation();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [backendUrl, setBackendUrl] = useState('');
|
||||
const [urlSaved, setUrlSaved] = useState(false);
|
||||
|
||||
@@ -252,23 +245,6 @@ function AccountSection() {
|
||||
if (backendUrlQuery.data) setBackendUrl(backendUrlQuery.data);
|
||||
}, [backendUrlQuery.data]);
|
||||
|
||||
const isAuthenticated = authStatusQuery.data?.authenticated;
|
||||
|
||||
function handleAuth() {
|
||||
setAuthError('');
|
||||
const mutation = mode === 'login' ? loginMutation : registerMutation;
|
||||
mutation.mutate({ email, password }, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.success) {
|
||||
setAuthError(res.error ?? 'Failed');
|
||||
} else {
|
||||
void utils.auth.status.invalidate();
|
||||
}
|
||||
},
|
||||
onError: (err) => setAuthError(err.message),
|
||||
});
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: () => void utils.auth.status.invalidate(),
|
||||
@@ -299,66 +275,26 @@ function AccountSection() {
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<SettingsCard title="Account" description="Manage your account and subscription.">
|
||||
<div className="flex flex-col gap-4">
|
||||
{authStatusQuery.data?.profile && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">{authStatusQuery.data.profile.email}</p>
|
||||
<Badge variant="outline" className="w-fit capitalize">{authStatusQuery.data.profile.tier}</Badge>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={handleLogout}
|
||||
disabled={logoutMutation.isPending}
|
||||
>
|
||||
<LogOut className="size-4 mr-2" />
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<SettingsCard
|
||||
title={mode === 'login' ? 'Sign in' : 'Create account'}
|
||||
description="Sign in to enable AI features, agents, and cloud sync."
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAuth()}
|
||||
/>
|
||||
{authError && <p className="text-xs text-destructive">{authError}</p>}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAuth}
|
||||
disabled={loginMutation.isPending || registerMutation.isPending}
|
||||
>
|
||||
<LogIn className="size-4 mr-2" />
|
||||
{mode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setAuthError(''); }}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{mode === 'login' ? 'No account? Register' : 'Have an account? Sign in'}
|
||||
</button>
|
||||
<SettingsCard title="Account" description="Manage your account and subscription.">
|
||||
<div className="flex flex-col gap-4">
|
||||
{authStatusQuery.data?.profile && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">{authStatusQuery.data.profile.email}</p>
|
||||
<Badge variant="outline" className="w-fit capitalize">{authStatusQuery.data.profile.tier}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={handleLogout}
|
||||
disabled={logoutMutation.isPending}
|
||||
>
|
||||
<LogOut className="size-4 mr-2" />
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,48 +30,6 @@ export const UserProfileSchema = z.object({
|
||||
});
|
||||
export type UserProfile = z.infer<typeof UserProfileSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat — Backend Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Chat context sent to the backend via WebSocket.
|
||||
*
|
||||
* Matches the backend `ChatContext` Pydantic model (flat structure).
|
||||
* UI-only fields (`type`, `projectId`, `uiContext`) live in `UIChatContext`
|
||||
* in the renderer — they are NOT sent to the backend.
|
||||
*/
|
||||
export const ChatContextSchema = z.object({
|
||||
/** User profile snapshot. */
|
||||
userProfile: UserProfileSchema.optional(),
|
||||
/** Relevant document snippets retrieved from the vector store. */
|
||||
relevantDocuments: z.array(z.string()).optional(),
|
||||
/** Recent tasks for additional context. */
|
||||
recentTasks: z
|
||||
.array(z.object({ id: z.string(), title: z.string(), status: z.string() }))
|
||||
.optional(),
|
||||
/** Previous messages in the conversation. */
|
||||
conversationHistory: z
|
||||
.array(z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string() }))
|
||||
.optional(),
|
||||
});
|
||||
export type ChatContext = z.infer<typeof ChatContextSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat Request / Response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ChatRequestSchema = z.object({
|
||||
message: z.string(),
|
||||
context: ChatContextSchema,
|
||||
});
|
||||
export type ChatRequest = z.infer<typeof ChatRequestSchema>;
|
||||
|
||||
export const ChatResponseSchema = z.object({
|
||||
response: z.string(),
|
||||
});
|
||||
export type ChatResponse = z.infer<typeof ChatResponseSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket Frames — Bidirectional Protocol
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -90,13 +48,6 @@ export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
|
||||
|
||||
// --- Client → Server frames ------------------------------------------------
|
||||
|
||||
export const WsChatRequestSchema = z.object({
|
||||
type: z.literal('chat_request'),
|
||||
message: z.string(),
|
||||
context: ChatContextSchema,
|
||||
});
|
||||
export type WsChatRequest = z.infer<typeof WsChatRequestSchema>;
|
||||
|
||||
export const WsToolResultSchema = z.object({
|
||||
type: z.literal('tool_result'),
|
||||
id: z.string(),
|
||||
@@ -148,23 +99,39 @@ export const WsDeviceHelloSchema = z.object({
|
||||
});
|
||||
export type WsDeviceHello = z.infer<typeof WsDeviceHelloSchema>;
|
||||
|
||||
// --- V3 Chat frames — Client → Server ----------------------------------------
|
||||
|
||||
export const WsHomeRequestSchema = z.object({
|
||||
type: z.literal('home_request'),
|
||||
message: z.string(),
|
||||
conversationHistory: z
|
||||
.array(z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string() }))
|
||||
.optional(),
|
||||
});
|
||||
export type WsHomeRequest = z.infer<typeof WsHomeRequestSchema>;
|
||||
|
||||
export const WsFloatingRequestSchema = z.object({
|
||||
type: z.literal('floating_request'),
|
||||
message: z.string(),
|
||||
scope: z.object({
|
||||
type: z.enum(['task', 'project', 'note', 'checkpoint']),
|
||||
id: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
export type WsFloatingRequest = z.infer<typeof WsFloatingRequestSchema>;
|
||||
|
||||
export const WsClientFrameSchema = z.discriminatedUnion('type', [
|
||||
WsChatRequestSchema,
|
||||
WsToolResultSchema,
|
||||
WsAgentDataSchema,
|
||||
WsAgentCompleteSchema,
|
||||
WsDeviceHelloSchema,
|
||||
WsHomeRequestSchema,
|
||||
WsFloatingRequestSchema,
|
||||
]);
|
||||
export type WsClientFrame = z.infer<typeof WsClientFrameSchema>;
|
||||
|
||||
// --- Server → Client frames ------------------------------------------------
|
||||
|
||||
export const WsTextChunkSchema = z.object({
|
||||
type: z.literal('text_chunk'),
|
||||
text: z.string(),
|
||||
});
|
||||
export type WsTextChunk = z.infer<typeof WsTextChunkSchema>;
|
||||
|
||||
export const WsToolCallSchema = z.object({
|
||||
type: z.literal('tool_call'),
|
||||
id: z.string(),
|
||||
@@ -177,12 +144,6 @@ export const WsToolCallSchema = z.object({
|
||||
});
|
||||
export type WsToolCall = z.infer<typeof WsToolCallSchema>;
|
||||
|
||||
export const WsFinalSchema = z.object({
|
||||
type: z.literal('final'),
|
||||
response: z.string(),
|
||||
});
|
||||
export type WsFinal = z.infer<typeof WsFinalSchema>;
|
||||
|
||||
export const WsPingSchema = z.object({
|
||||
type: z.literal('ping'),
|
||||
});
|
||||
@@ -208,12 +169,78 @@ export const WsAgentRunSchema = z.object({
|
||||
});
|
||||
export type WsAgentRun = z.infer<typeof WsAgentRunSchema>;
|
||||
|
||||
// --- V3 Chat frames — Server → Client ----------------------------------------
|
||||
|
||||
export const WsStreamStartSchema = z.object({
|
||||
type: z.literal('stream_start'),
|
||||
requestId: z.string(),
|
||||
});
|
||||
export type WsStreamStart = z.infer<typeof WsStreamStartSchema>;
|
||||
|
||||
export const WsStreamTextSchema = z.object({
|
||||
type: z.literal('stream_text'),
|
||||
requestId: z.string(),
|
||||
chunk: z.string(),
|
||||
});
|
||||
export type WsStreamText = z.infer<typeof WsStreamTextSchema>;
|
||||
|
||||
export const WsStreamBlockSchema = z.object({
|
||||
type: z.literal('stream_block'),
|
||||
requestId: z.string(),
|
||||
blockType: z.enum(['chart', 'entity_ref', 'table', 'timeline']),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
export type WsStreamBlock = z.infer<typeof WsStreamBlockSchema>;
|
||||
|
||||
export const WsStreamEndSchema = z.object({
|
||||
type: z.literal('stream_end'),
|
||||
requestId: z.string(),
|
||||
mutations: z.union([
|
||||
z.record(z.string(), z.unknown()),
|
||||
z.array(z.unknown()),
|
||||
]).optional(),
|
||||
});
|
||||
export type WsStreamEnd = z.infer<typeof WsStreamEndSchema>;
|
||||
|
||||
export const WsFloatingDomainSchema = z.object({
|
||||
type: z.literal('floating_domain'),
|
||||
requestId: z.string(),
|
||||
domain: z.enum(['tasks', 'notes', 'checkpoints', 'projects']),
|
||||
});
|
||||
export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;
|
||||
|
||||
// --- V3 Block data interfaces ------------------------------------------------
|
||||
|
||||
export interface ChartBlockData {
|
||||
chartType: 'area' | 'bar' | 'line' | 'pie' | 'radar' | 'radial';
|
||||
title: string;
|
||||
data: Record<string, unknown>[];
|
||||
config: Record<string, { label: string; color: string }>;
|
||||
}
|
||||
|
||||
export interface EntityRefBlockData {
|
||||
entity: 'task' | 'project' | 'note' | 'checkpoint';
|
||||
items: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface TableBlockData {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export interface TimelineBlockData {
|
||||
checkpoints: { id: string; title: string; date: number }[];
|
||||
}
|
||||
|
||||
export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsTextChunkSchema,
|
||||
WsToolCallSchema,
|
||||
WsFinalSchema,
|
||||
WsPingSchema,
|
||||
WsAgentRunSchema,
|
||||
WsStreamStartSchema,
|
||||
WsStreamTextSchema,
|
||||
WsStreamBlockSchema,
|
||||
WsStreamEndSchema,
|
||||
WsFloatingDomainSchema,
|
||||
]);
|
||||
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user