mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
516 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fc0b39730 | |||
| 194bfe23a1 | |||
| 35110480ef | |||
| 959595e33a | |||
| a960424dfb | |||
| 0df6c7d08b | |||
| 65c841e7a7 | |||
| b21b8cc982 | |||
| aa2c4f06b7 | |||
| b8d7b4ec10 | |||
| c48520255a | |||
| 0790da989d | |||
| 506d24d2fd | |||
| 1348dbf493 | |||
| ce677f3cd9 | |||
| 39203d78e3 | |||
| 2ef7daf369 | |||
| cff3d90613 | |||
| 9f89243d7f | |||
| 784ee9a4da | |||
| 678e6b8ba1 | |||
| 30e301c496 | |||
| b22904f6bb | |||
| 3f0de7ddca | |||
| 9a6f0f9202 | |||
| 4f0bae5657 | |||
| 2101f06195 | |||
| 6d54b5594c | |||
| 36b8e5b1df | |||
| 8252d671c7 | |||
| 30d97c94aa | |||
| 82654a00d4 | |||
| 9595f14ddc | |||
| 8c496074b2 | |||
| 4d097d7136 | |||
| 178619d275 | |||
| 59c8b2538d | |||
| 443b72c52a | |||
| ae13abef45 | |||
| 83ae02ef9b | |||
| 9bb178413b | |||
| d85f0ebfc4 | |||
| 8f84dc2f24 | |||
| c8b4301bcd | |||
| bd8eaf0b9f | |||
| a4148cf694 | |||
| 4cb0b493dc | |||
| e6354e9089 | |||
| 08506abaee | |||
| 078c80d572 | |||
| b1c9f6be45 | |||
| fc497e9beb | |||
| 6ad01fd981 | |||
| 44ed8664c8 | |||
| 4cb16ee715 | |||
| 2dc9b87cda | |||
| 0e587c4889 | |||
| 41d42d82fb | |||
| f703c8a8c9 | |||
| bf753eab55 | |||
| 698b67af06 | |||
| 377d61056a | |||
| 94b32c8fe3 | |||
| 1e70a59ad6 | |||
| 44d05181f4 | |||
| 996998a5cc | |||
| 98474b2721 | |||
| 198dc0e23f | |||
| 079731c573 | |||
| 492c89650a | |||
| 5b5bbb7649 | |||
| 27d1f081ab | |||
| 76183fd840 | |||
| 345165eabf | |||
| c186732b3b | |||
| 04916b700e | |||
| 013dab185c | |||
| 5ab93faccf | |||
| fa301e3675 | |||
| fa6e7dd9c5 | |||
| 01736ad5da | |||
| ce682b1f85 | |||
| 96d801f40a | |||
| 8985868f63 | |||
| 8febdcd0c0 | |||
| 4d21d5134a | |||
| 09d44a4314 | |||
| 40066e975a | |||
| 202382c80a | |||
| 6ffbb32c57 | |||
| 9b8a3ca503 | |||
| cdd7892077 | |||
| 974aa12137 | |||
| d8f8999333 | |||
| 0efd87b522 | |||
| ec76e1c5cf | |||
| 1e04efe748 | |||
| 69c135ae78 | |||
| 205fb1bb5b | |||
| c8e7315de3 | |||
| 725f3b0fd7 | |||
| 7ee3701607 | |||
| 9537ce59e8 | |||
| 6c0a60e0d1 | |||
| 436a858cb0 | |||
| 6ea6c55f65 | |||
| c477fa86ce | |||
| 08cd5ed5b6 | |||
| b5f2cd35f2 | |||
| 4cb0f6d67e | |||
| 5260ec68cc | |||
| 72ce4d2884 | |||
| ed65f989d9 | |||
| 588ebf4993 | |||
| 22969033a7 | |||
| 8b5e00480b | |||
| aaf752fa9c | |||
| 82d3b36048 | |||
| 588c81f9ad | |||
| 4013a3f997 | |||
| 5823e18904 | |||
| 31ea6863aa | |||
| f3f58f26ae | |||
| 67132f285e | |||
| 20a638a8c9 | |||
| c9174e995f | |||
| 656c507c94 | |||
| a1fb744eb1 | |||
| 28367547fd | |||
| 6610211eac | |||
| b66e3e2afa | |||
| 4bf965953a | |||
| 1bd6513d59 | |||
| 6ce457913e | |||
| ef84ca5a04 | |||
| f76524c650 | |||
| 0be676229f | |||
| 40a0ca7235 | |||
| 1563c3a9dc | |||
| 80f32be80d | |||
| eea53714cc | |||
| 148f1ec22c | |||
| b5a2a70e73 | |||
| e7667e4b7d | |||
| 9250eb9aff | |||
| 92883caaab | |||
| 6d57450efc | |||
| 5dd4c600ea | |||
| 392a3b7949 | |||
| e22c40c7e4 | |||
| c7abee6969 | |||
| 4772e63fdb | |||
| f3d7abefec | |||
| ac76b156cf | |||
| 97e65efc31 | |||
| 13dcaa0a57 | |||
| 1f42b0ae66 | |||
| 003a50f181 | |||
| 32c5849a50 | |||
| 44a8ee0593 | |||
| 1ad70c7b1b | |||
| 7413983159 | |||
| 6c3e8c6a8f | |||
| 7e3e9854ac | |||
| 41fc93345c | |||
| b9275177e3 | |||
| 5ea95e4095 | |||
| 0ea041ed5b | |||
| 037e3b62d8 | |||
| 517c18c902 | |||
| 685b5c5130 | |||
| cfdab2f900 | |||
| 1a743ff264 | |||
| 85463fafb1 | |||
| 0641b0df97 | |||
| 98825081a9 | |||
| f549c13465 | |||
| 8bf7fd7106 | |||
| d8d889c706 | |||
| 90665ed84a | |||
| dd3d10a391 | |||
| 19ebd399a8 | |||
| f21a2973e9 | |||
| 04bb8f9c12 | |||
| 5ea63c8734 | |||
| f4f4ad9373 | |||
| ba06d70c05 | |||
| 62ddd17715 | |||
| f76db1d19e | |||
| f0901dbc03 | |||
| c65a2ce387 | |||
| eaee372938 | |||
| d8836534cb | |||
| 7d2e64b458 | |||
| bc942c5581 | |||
| 4ca24f8314 | |||
| b299dec68e | |||
| b9f07d011b | |||
| 9259be8dbb | |||
| 4b0b7c4493 | |||
| 73f0760809 | |||
| db6c2b1620 | |||
| 1233e846db | |||
| 27312537a7 | |||
| 1dfd4d8395 | |||
| ccd9f0980f | |||
| 5cc48d24ec | |||
| 7929d4eb30 | |||
| 14c5c83f91 | |||
| 263412c422 | |||
| d395fa817d | |||
| 9cfc8c513b | |||
| c92a1cfcb1 | |||
| f45e45ca8f | |||
| e44d4b8b01 | |||
| c342f553db | |||
| 2fab208ccf | |||
| eab3eee19f | |||
| fcb3903b5f | |||
| 90ccb64bd0 | |||
| 1772db5e98 | |||
| a04ee4de95 | |||
| 73b6a54f9e | |||
| 52b08b407c | |||
| 269a3a9991 | |||
| 1b2050cd96 | |||
| a71dd5e3aa | |||
| 8d91ea0413 | |||
| 81b39c7f9c | |||
| a3200e1aab | |||
| 4c8fa8e477 | |||
| f64aae10c5 | |||
| bd8f484cd2 | |||
| 4c3151e3be | |||
| 4e3377f1df | |||
| f95b643a5c | |||
| 85083f323d | |||
| b884386143 | |||
| 01a8d858cf | |||
| 08fed36a61 | |||
| f8b110e108 | |||
| b78b0f1323 | |||
| 148c0b1d77 | |||
| fe501831b2 | |||
| 1862b72ba5 | |||
| a609071966 | |||
| dc2d162e6e | |||
| 07f2cd291e | |||
| a6e040e3e5 | |||
| 3e6cfc9775 | |||
| 0e2abd2615 | |||
| 394e79510e | |||
| 848977820e | |||
| c893f1969c | |||
| bb9a8b81d1 | |||
| 188b338bdc | |||
| 463ef406a7 | |||
| a916ff46dc | |||
| db3a5c0b1b | |||
| b760250da1 | |||
| b5829ac541 | |||
| fa4f2b8fcd | |||
| 333c318a62 | |||
| 5f6f7086d0 | |||
| a7495bd4cf | |||
| 76c4919e9c | |||
| 5530a0253e | |||
| 86aaa65d10 | |||
| 65bf147e04 | |||
| f76ad186f0 | |||
| e5e333db70 | |||
| ddee08c2da | |||
| 93b7686f18 | |||
| e61e9626e2 | |||
| 3c6bfe0152 | |||
| e4fc44bc9c | |||
| 51e23ad3a4 | |||
| 5ebbe45a63 | |||
| 6df276d51d | |||
| f811500b60 | |||
| 2b51605c18 | |||
| 513b840b47 | |||
| d94c8c8a3b | |||
| 3dd641a398 | |||
| 8e545f1738 | |||
| 2a12597567 | |||
| e003683040 | |||
| 0338b3d2e9 | |||
| 5d5bc403c4 | |||
| b646149980 | |||
| 1e7e8ac632 | |||
| 309786e01e | |||
| 08e3caf8c2 | |||
| 21b68d7660 | |||
| 4986c61b2a | |||
| 801479cb5c | |||
| 1d18e21018 | |||
| 4c329a8f51 | |||
| 1eb4eeea42 | |||
| 5ca094111c | |||
| 4ce4202750 | |||
| 4873c0c390 | |||
| 351a28f34f | |||
| a2e99ed84f | |||
| 7d2a26ab66 | |||
| 94268187f1 | |||
| 5aafa98f1c | |||
| c42c34acb4 | |||
| f052963da3 | |||
| 07fa93609d | |||
| cbef9e5a57 | |||
| 0b342339d4 | |||
| 9de3d5a26f | |||
| 78878076c2 | |||
| 65cca958a6 | |||
| 19263f8494 | |||
| 5f71cbed47 | |||
| fe93a66d3b | |||
| aa3b451e00 | |||
| ca245bf8b8 | |||
| 9868068cd6 | |||
| 5fd27629d0 | |||
| 4bfc7636c9 | |||
| 305a7784ee | |||
| 87ecc11661 | |||
| 0faf5d5957 | |||
| 55d7ebd804 | |||
| 842b5b96c2 | |||
| b07fc759c2 | |||
| 0afa70aaab | |||
| c2cf93bf1a | |||
| 88639b8b57 | |||
| bfecc63d0d | |||
| 20bea327e4 | |||
| 1e5c26b490 | |||
| d9183c9658 | |||
| 3ecbbc3b70 | |||
| 1c1d21eed7 | |||
| 6129971bb2 | |||
| 8a3d75f077 | |||
| 9c249b513f | |||
| 04d3fe6e99 | |||
| ea7283b96e | |||
| 295fc111c4 | |||
| 58d73d5d81 | |||
| fd8ce2e99a | |||
| c8a33a06fa | |||
| 874be92a56 | |||
| 6bdb01e3c5 | |||
| ba03ab3aa8 | |||
| 3d554e513d | |||
| e516b9dae9 | |||
| 281d5a611e | |||
| 03eec23efe | |||
| e3d01f6615 | |||
| 99e15333cb | |||
| 5efd16c060 | |||
| b4a6c80d8c | |||
| 7991920f08 | |||
| a113b8223b | |||
| 7bb720a903 | |||
| 515de2679e | |||
| 38caacf816 | |||
| 676b0537e6 | |||
| a24341cda6 | |||
| d937bc246a | |||
| 5d2543131a | |||
| ca5d6872b5 | |||
| a97ce26072 | |||
| c698f78f92 | |||
| 77782a63eb | |||
| 41e1e44ef0 | |||
| 7b1fc56320 | |||
| c0ed41a529 | |||
| ba47fe1cfe | |||
| f1356d8fdc | |||
| 7a899c538f | |||
| 3daac973b1 | |||
| b0ec5f7459 | |||
| 71d6868512 | |||
| 605bb83eb3 | |||
| 3092e02ce9 | |||
| 5d82374975 | |||
| ab4d63e596 | |||
| f800bb8dae | |||
| 18862c0ff4 | |||
| 3765e8c69e | |||
| 70d54a9aa3 | |||
| 50c6ee69af | |||
| dd2532e269 | |||
| 16a54b3452 | |||
| 8373c1c785 | |||
| 39beda5519 | |||
| c7d1eae327 | |||
| ec81e2ff5b | |||
| 697090b695 | |||
| 8680fcc3db | |||
| 233037edd2 | |||
| 81c3251c6e | |||
| dc0fe7f4ca | |||
| 2c9c0f2e0b | |||
| 9c3fb9aadb | |||
| de37ac2c51 | |||
| d6b57702bd | |||
| d94642c29f | |||
| 75378ea88f | |||
| d539c1369b | |||
| 555ee6f333 | |||
| ad989d8a0b | |||
| aae7af4713 | |||
| df0a204767 | |||
| 5cdefc7b5e | |||
| c1bdb1fc61 | |||
| dde22a080b | |||
| 7f5ff30f97 | |||
| 38e1708e91 | |||
| fe4e755304 | |||
| 67f1c87d3a | |||
| eef88ffae2 | |||
| 319965c55c | |||
| 1f309b5c81 | |||
| 5273352ae9 | |||
| 5a48256d77 | |||
| 1d41294c1d | |||
| ff76229706 | |||
| b0f4b30ebe | |||
| 7be8f6c6a7 | |||
| b003993961 | |||
| 4878f361b5 | |||
| a82a3899c5 | |||
| ff0685e6e8 | |||
| a597489526 | |||
| 32e8890f62 | |||
| 211a43eab4 | |||
| 8c28df77cc | |||
| 4e82a12899 | |||
| 8d0e0dea89 | |||
| 5703f23b99 | |||
| 196d08b4fd | |||
| 2f9738f6fb | |||
| d4db225d1e | |||
| efff785713 | |||
| 234accad3f | |||
| 588b4b2c64 | |||
| 7de34d8478 | |||
| 741980adfc | |||
| 2690380bfd | |||
| b482b07335 | |||
| 03b4c6f941 | |||
| b7fd1b13de | |||
| 10a6f2d3c7 | |||
| ba149d3b43 | |||
| f175d071c4 | |||
| 874d0bca05 | |||
| 81ad0328b7 | |||
| 5198fa66cf | |||
| a807bdd6b6 | |||
| 2b209bb679 | |||
| 2f018dce9f | |||
| 2eb77f532a | |||
| 69063bb544 | |||
| 7fad2f8790 | |||
| 620275a1f5 | |||
| ba583fc448 | |||
| 0b96870644 | |||
| eb2b682eb5 | |||
| 577b52120a | |||
| b69ae3edae | |||
| 624b177996 | |||
| bbf01b49c0 | |||
| 86b2d8ae71 | |||
| d18af42d43 | |||
| 4f6e110bf9 | |||
| 62cf334e2f | |||
| 8bd6fd40fd | |||
| f21fe41188 | |||
| cfff23164c | |||
| a8d9233dc4 | |||
| 9c973efbbf | |||
| e2c4255920 | |||
| e01b9ff6a9 | |||
| 0084a635f1 | |||
| 0cd20b8d48 | |||
| 7c4094b4c2 | |||
| acd8430d51 | |||
| 6ae2195d10 | |||
| 6bcc0dd177 | |||
| 2de42c2010 | |||
| a231ccb492 | |||
| 35875d5837 | |||
| c36ff1edfa | |||
| ed35d5b541 | |||
| 2b2a2d84a9 | |||
| a645a4066c | |||
| 508a3beff7 | |||
| df0c133056 | |||
| 2da3942ce2 | |||
| 26547dec0d | |||
| aa4804bdd5 | |||
| eafa1f02cb | |||
| 836533a8c2 | |||
| cfeb134c20 | |||
| 35798b5568 | |||
| 7a250f0848 | |||
| 0a4e6d5142 | |||
| f4254a5ffb | |||
| 7b7718e578 | |||
| c261b2b156 | |||
| 237065553e | |||
| 6116af42df | |||
| 08b28cfde8 | |||
| b019655518 | |||
| 1264a2ebaf | |||
| 1960b4f618 | |||
| c75fbd89e6 | |||
| 3e67201665 |
@@ -0,0 +1,63 @@
|
||||
---
|
||||
description: Search git history for commits that introduce or remove an exact string, within a commit range
|
||||
argument-hint: "[search-string] [ancestor-commit]"
|
||||
allowed-tools: Bash(git *)
|
||||
---
|
||||
|
||||
Search git history using `git log -S` (pickaxe) to find commits that add or remove an exact string.
|
||||
This repo has 7000+ commits, so pickaxe searches can take 30-60+ seconds - this is expected.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `$0` - The exact string to search for in file contents (not commit messages). Examples: `getLabsSUDO`, `EXPERIMENT_ON_SUDO`, `myFunctionName`
|
||||
- `$1` - A commit hash or unique commit message substring to identify the start of the range. Examples: `5af80b96a8`, `"Sudo Mode": 10-click`
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
/code:grep-history EXPERIMENT_ON_SUDO "Sudo Mode": 10-click
|
||||
```
|
||||
|
||||
This searches all commits between the `"Sudo Mode": 10-click` commit and HEAD for any that add or remove the string `EXPERIMENT_ON_SUDO` in file contents.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1: Resolve the ancestor commit
|
||||
|
||||
If `$1` looks like a commit hash (hex string), use it directly.
|
||||
Otherwise, search for it by message, restricting to ancestors of HEAD:
|
||||
|
||||
```bash
|
||||
git log --oneline --grep='$1' HEAD | head -5
|
||||
```
|
||||
|
||||
This only walks commits reachable from HEAD, so every result is a guaranteed ancestor - no verification loop needed.
|
||||
|
||||
If multiple results, pick the oldest (last listed) since it represents the earliest matching commit.
|
||||
If none, report the error and stop.
|
||||
|
||||
### Step 2: Run pickaxe search
|
||||
|
||||
```bash
|
||||
git log -S "$0" --oneline <resolved_ancestor>..HEAD
|
||||
```
|
||||
|
||||
This finds commits where the count of `$0` in the codebase changes (i.e., it was added or removed).
|
||||
This can be slow on 7000+ commits - wait for it.
|
||||
|
||||
### Step 3: Check endpoints
|
||||
|
||||
Also check whether the string exists at HEAD and at the ancestor commit:
|
||||
|
||||
```bash
|
||||
git grep -l "$0" HEAD 2>/dev/null || echo "(not found at HEAD)"
|
||||
git grep -l "$0" <resolved_ancestor> 2>/dev/null || echo "(not found at ancestor)"
|
||||
```
|
||||
|
||||
### Step 4: Report
|
||||
|
||||
Present results concisely:
|
||||
- Number of commits found (or "none")
|
||||
- List of matching commits (hash + subject line)
|
||||
- Whether the string exists at HEAD and/or at the ancestor
|
||||
- If found, suggest next steps (e.g., `git show <hash>` to inspect specific commits)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
description: Show a hierarchical progress tree of the current conversation thread
|
||||
---
|
||||
|
||||
Analyze this conversation thread and produce a **hierarchical progress tree** - a vertical breadcrumb of the chat and actions from the very start to now.
|
||||
|
||||
**Format:**
|
||||
|
||||
A tree, where every rabbithole that was taken adds a level.
|
||||
|
||||
```
|
||||
[ ] Brief initial phase/ask/goal description
|
||||
[x] Specific thing done or decided - "user quote if relevant"
|
||||
[x] Another step
|
||||
[ ] Sub-phase/rabbithole/etc
|
||||
[x] Done step (if important)
|
||||
[ ] Sub-sub-phase
|
||||
[ ] Current step doing <-- HERE
|
||||
[ ] Next step since this sub-sub-phase was broken out
|
||||
|
||||
[ ] Remaining step
|
||||
[ ] ...
|
||||
|
||||
[ ] Missing, back to the main goal
|
||||
[ ] ...
|
||||
|
||||
### What do we rewind the rabbithole to (once the current level is complete)?
|
||||
...
|
||||
|
||||
### What's up (towards user value) and down (towards deeper code levels) the rabbithole?
|
||||
...
|
||||
|
||||
### What's a good hyphenated title for this chat?
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- `[x]` done, `[ ]` not done. Parent is done only when ALL children on the next level are `[x]`
|
||||
- Each node: a few words, specific. Quote the user briefly when it captures the intent
|
||||
- Group by logical phases or rabbitholes (when descending to a deeper level of implementation or going off for a temporary tangent or sub-quest), not by messages
|
||||
- Earlier levels that are fully completed don't need to be expanded in subtasks
|
||||
- Root nodes/completed nodes need to show what was "wanted" from them, not being checked because they are shown as earlier phases (i.e. upper hierarchy contains more)
|
||||
- Some earlier sub-phases or even levels of rabbitholes can be marked as done as indented [x] below each other (do not add non-major bullets on already completed nodes)
|
||||
- Insert newlines in between large groups of items
|
||||
- Decisions: state what was chosen, not the alternatives
|
||||
- If a former phase produced no code change or decision, omit
|
||||
- Very important to insert incomplete `[ ]` items for things that wre mentioned and are likely useful but mentioned at higher levels of the rabbithole so they must come after, when unwinding the stack
|
||||
- Keep it short, tight (min 0 max item count below *ONE QUARTER the user messages*). This is a navigation aid, not a transcript
|
||||
|
||||
It's important for this to represent a high-level sequence of important actions and turns and pivots and rabbiholes, all focuses on trying to solve something.
|
||||
|
||||
First think through it looking at all the chat from the back to the front, then front to back, user requests, and understand the main storybeats. This is useful especially to remove already done leaves that don't add much if shown.
|
||||
So think about the full list, so you have it all in front of you when you do the last pass to show it to me.
|
||||
It's important to see the progress of what we were doing (e.g. see that we set out to do something at the beginning, but a few items of those are still incomplete, also because we took 2 detours to fix more things in the meantime...).
|
||||
|
||||
At the end anser the questions in the Format, with brief bullet points.
|
||||
@@ -20,12 +20,12 @@ Reference files (for context only, do not modify):
|
||||
**Discovering feature docs:** The release notes and models overview markdown
|
||||
contain inline links to feature-specific pages (thinking modes, effort,
|
||||
context windows, what's-new pages, etc.). When a new capability is
|
||||
referenced, follow those links — append `.md` to get markdown. Examples of
|
||||
referenced, follow those links - append `.md` to get markdown. Examples of
|
||||
pages you might discover this way:
|
||||
- `about-claude/models/whats-new-claude-*` — per-generation changes
|
||||
- `build-with-claude/extended-thinking` — thinking budget configuration
|
||||
- `build-with-claude/effort` — effort parameter levels
|
||||
- `build-with-claude/adaptive-thinking` — adaptive thinking mode
|
||||
- `about-claude/models/whats-new-claude-*` - per-generation changes
|
||||
- `build-with-claude/extended-thinking` - thinking budget configuration
|
||||
- `build-with-claude/effort` - effort parameter levels
|
||||
- `build-with-claude/adaptive-thinking` - adaptive thinking mode
|
||||
|
||||
**Fallback web pages** (crawl if `.md` paths break or structure changes):
|
||||
- https://platform.claude.com/docs/en/about-claude/models/overview
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: Update MiniMax model definitions with latest pricing and capabilities
|
||||
---
|
||||
|
||||
Update `src/modules/llms/server/openai/models/minimax.models.ts` with latest model definitions.
|
||||
|
||||
Reference `src/modules/llms/server/llm.server.types.ts` and `src/modules/llms/server/models.mappings.ts` for context only. Focus on the model file, do not descend into other code.
|
||||
|
||||
**Primary Sources:**
|
||||
- Models & Changelog: https://platform.minimax.io/docs/release-notes/models.md
|
||||
- Pricing: https://platform.minimax.io/docs/guides/pricing-paygo.md
|
||||
- Pricing Overview: https://platform.minimax.io/docs/pricing/overview.md
|
||||
- Text Generation API: https://platform.minimax.io/docs/guides/text-generation.md
|
||||
|
||||
**Note:** MiniMax is a hardcoded-only vendor (no `/v1/models` API yet). All model IDs, context windows, and pricing must be manually maintained from the docs. Pay attention to new model releases (M-series), highspeed variants, and deprecated models.
|
||||
|
||||
**Fallbacks if blocked:** Search "minimax api models pricing", "minimax m2 m3 models", "minimax api changelog" or check https://openrouter.ai models list for MiniMax entries.
|
||||
|
||||
**Important:**
|
||||
- Models are `ModelDescriptionSchema[]` objects (not ManualMappings) - match existing pattern in the file
|
||||
- Review the full model list for additions, removals, and price changes
|
||||
- Check for new `-highspeed` variants and new model families
|
||||
- Verify context window sizes and max completion tokens against docs
|
||||
- Minimize whitespace/comment changes, focus on content
|
||||
- Preserve comments to make diffs easy to review
|
||||
- Flag broken links or unexpected content
|
||||
@@ -8,14 +8,11 @@ Reference `src/modules/llms/server/llm.server.types.ts` and `src/modules/llms/se
|
||||
|
||||
**Automated Workflow:**
|
||||
```bash
|
||||
# 1. Fetch the HTML (sorted by newest for stable ordering)
|
||||
curl -s "https://ollama.com/library?sort=newest" -o /tmp/ollama-newest.html
|
||||
# 1. Fetch the HTML to a cross-platform temp path (sorted by newest for stable ordering)
|
||||
curl -s "https://ollama.com/library?sort=newest" -o "$(node -p "require('os').tmpdir()")/ollama-newest.html"
|
||||
|
||||
# 2. Parse it with the script
|
||||
node .claude/scripts/parse-ollama-models.js > /tmp/ollama-parsed.txt 2>&1
|
||||
|
||||
# 3. Review the parsed output
|
||||
cat /tmp/ollama-parsed.txt
|
||||
# 2. Parse it with the script (auto-finds the file in os.tmpdir())
|
||||
node .claude/scripts/parse-ollama-models.js 2>&1
|
||||
```
|
||||
|
||||
The parser outputs: `modelName|pulls|capabilities|sizes`
|
||||
|
||||
@@ -23,11 +23,12 @@ If `$ARGUMENTS` provided, verify only that dialect, which includes reading the p
|
||||
|
||||
## Task
|
||||
|
||||
The sweep data is the source of truth for allowed model parameter values or value ranges.
|
||||
The sweep data is the source of truth for allowed model parameter values or value ranges, and for the `fn` function-calling capability probe.
|
||||
|
||||
For each model in the sweep, verify the model definition exposes exactly those capabilities - no more, no less. This includes:
|
||||
- The parameter is present in parameterSpecs
|
||||
- The paramId variant covers exactly the values from the sweep, if applicable
|
||||
- `LLM_IF_OAI_Fn` in `interfaces` matches `"roundtrip"` in the sweep's `fn` array (see below)
|
||||
- etc.
|
||||
|
||||
Report models where the definition doesn't match the sweep.
|
||||
@@ -50,6 +51,14 @@ and need to be carefully updated, otherwise thousands of clients may break.
|
||||
| Gemini | `gemini-thinking-budget` | `llmVndGeminiThinkingBudget` |
|
||||
| xAI | `xai-web-search` | `llmVndXaiWebSearch` |
|
||||
|
||||
## Function-Calling Capability (`fn`)
|
||||
|
||||
The sweep `fn` array is a capability probe (not a paramId). `"roundtrip"` is the authoritative signal - full tool-call -> response -> coherent follow-up. `LLM_IF_OAI_Fn` in the model's `interfaces` must track `"roundtrip"`: present iff present.
|
||||
|
||||
Flag:
|
||||
- `"roundtrip"` in sweep but `LLM_IF_OAI_Fn` missing (or vice versa)
|
||||
- `fn` contains `"auto"`/`"required"` without `"roundtrip"` - partial capability, call it out
|
||||
|
||||
## Output
|
||||
|
||||
Report first for every model the expected values from the sweep, then the actual values from the definition, then the mismatches.
|
||||
|
||||
@@ -9,7 +9,15 @@ Execute the release process for Big-AGI. Go step-by-step, waiting for user appro
|
||||
|
||||
If `$ARGUMENTS` provided, use it. Otherwise, read `package.json` and increment patch version.
|
||||
|
||||
## Step 2: Update Files
|
||||
## Step 2: Gather Context
|
||||
|
||||
Before drafting, gather what changed:
|
||||
1. `git log --oneline` since last release tag to see all commits
|
||||
2. Fetch https://big-agi.com/changes to see what daily entries already covered
|
||||
3. `gh issue list --state closed --search "closed:>LAST_RELEASE_DATE"` to find closed issues
|
||||
4. Check auto-generated release notes (`gh release create --generate-notes --draft`) for community PRs and new contributors
|
||||
|
||||
## Step 3: Update Files
|
||||
|
||||
1. **package.json** - Update `version` field
|
||||
2. **src/common/app.release.ts** - Increment `Monotonics.NewsVersion` (e.g., 203 → 204)
|
||||
@@ -23,12 +31,13 @@ For the news entry, ask user for release name and key highlights.
|
||||
- UX items grouped, minimal bold
|
||||
- Fixes last, brief
|
||||
- Release name stays subtle - don't oversell the theme
|
||||
- Apply the draft, then let the user edit manually and re-read after - don't over-iterate
|
||||
|
||||
Use `<B>`, `<B issue={N}>`, `<B href='url'>`. Re-read file after user edits.
|
||||
|
||||
4. User runs `npm i` to update lockfile
|
||||
|
||||
## Step 3: README
|
||||
## Step 4: README
|
||||
|
||||
Update `README.md`:
|
||||
- Line ~46: Update model examples if new flagship models
|
||||
@@ -36,7 +45,7 @@ Update `README.md`:
|
||||
|
||||
**Style:** `- Open X.Y.Z: **Name** feature1, feature2, feature3`
|
||||
|
||||
## Step 4: Git Operations
|
||||
## Step 5: Git Operations
|
||||
|
||||
User commits changes, then:
|
||||
```bash
|
||||
@@ -44,21 +53,31 @@ git tag vX.Y.Z
|
||||
git push opensource vX.Y.Z
|
||||
```
|
||||
|
||||
## Step 5: GitHub Release
|
||||
## Step 6: GitHub Release
|
||||
|
||||
Create release with `gh release create`. Structure:
|
||||
Create release with `gh release create` using `--notes` (not `--body`).
|
||||
|
||||
**Structure** - discursive intro paragraph, then themed sections, not a generic "What's New" header:
|
||||
|
||||
```
|
||||
# Big-AGI X.Y.Z - Name
|
||||
|
||||
## What's New
|
||||
### Theme tagline.
|
||||
|
||||
### **Headline Feature**
|
||||
1-2 sentences explaining the main theme. Then bullet points for specifics.
|
||||
1-2 sentence discursive paragraph setting the release theme - what it means, not a feature list.
|
||||
|
||||
### **Also New**
|
||||
- Bullet list of other features
|
||||
- Keep it scannable
|
||||
### Section Name (e.g., Models & Parameters)
|
||||
- Bullet points for specifics
|
||||
- Group by theme, not by commit order
|
||||
|
||||
### Vendor/Platform Section (when enough substance)
|
||||
- Give a vendor its own section if 3+ related changes (e.g., Anthropic, AWS Bedrock)
|
||||
|
||||
### Also New
|
||||
- Remaining features, scannable
|
||||
|
||||
## New Contributors
|
||||
* @user made their first contribution (brief description) in PR_URL
|
||||
|
||||
**Full Changelog**: https://github.com/enricoros/big-AGI/compare/vPREV...vNEW
|
||||
|
||||
@@ -66,7 +85,14 @@ Create release with `gh release create`. Structure:
|
||||
Available now at [big-agi.com](https://big-agi.com), via Docker, or self-host from source.
|
||||
```
|
||||
|
||||
## Step 6: Announcements
|
||||
## Step 7: Changelog (big-agi.com/changes)
|
||||
|
||||
The Open release entry on big-agi.com/changes is lightweight - just 1-2 bullets announcing the stable release, since daily entries already covered the individual features. Use `/rel:changelog` to generate.
|
||||
|
||||
**Style:** `- Open X.Y.Z Name stable release on GitHub and Docker`
|
||||
followed by 1 bullet summarizing what landed in the final days since the last daily entry.
|
||||
|
||||
## Step 8: Announcements
|
||||
|
||||
Draft for user to post:
|
||||
|
||||
@@ -90,6 +116,16 @@ Big-AGI Open X.Y.Z is out!
|
||||
**More:** Count of commits/fixes
|
||||
```
|
||||
|
||||
## Step 9: Cover Image Prompts
|
||||
|
||||
Offer cover image prompt alternatives for the release. Read past prompts from `news.data.tsx` comments (lines ~24-37) for the pattern.
|
||||
|
||||
**Pattern:** Always a capybara sculpture made of crystal glass, wearing rayban-like oversized black sunglasses. Each release has a unique theme/activity that symbolizes the release.
|
||||
|
||||
**Shared prefix:** `High-key white scene, very clean, hero framing. A close-up photo of a capybara sculpture made of crystal glass. The capybara wears rayban-like oversized black sunglasses.`
|
||||
|
||||
**Also offer future release concepts** tied to vision vectors from `kb/vision-inlined.md` (e.g., agency, inhabitation, sculpting, safe exploration).
|
||||
|
||||
## Tone Guide
|
||||
|
||||
**Good:**
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const htmlPath = process.argv[2] || '/tmp/ollama-newest.html';
|
||||
const htmlPath = process.argv[2] || path.join(os.tmpdir(), 'ollama-newest.html');
|
||||
const TOP_N_ALWAYS_INCLUDE = 30;
|
||||
const MIN_PULLS_THRESHOLD = 50000;
|
||||
|
||||
|
||||
@@ -17,15 +17,19 @@
|
||||
"Bash(git mv:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(tsc:*)",
|
||||
"Read(//tmp/**)",
|
||||
|
||||
@@ -57,10 +57,10 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
labels: |
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -125,17 +125,17 @@ jobs:
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance to Claude Code when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Big-AGI is a Next.js 15 application with a sophisticated modular architecture built for professional AI interactions.
|
||||
|
||||
### Development Commands
|
||||
|
||||
Dev servers may be already running on ports 3000, 3001, 3002, or 3003 (not always this app - other projects may occupy these ports). Never start or stop dev servers, let the user do it.
|
||||
|
||||
```bash
|
||||
# Validate (~5s, safe while dev server runs, do NOT use `next build` ~45s for same checks)
|
||||
tsc --noEmit --pretty && npm run lint # Type check (~3.5s) + ESLint (~2s)
|
||||
eslint src/path/to/file.ts # Lint specific file
|
||||
|
||||
# Full build (~60s+, only when suspecting runtime/bundle issues)
|
||||
npm run build # next build runs compile+lint+types but stops at first type-error file; tsc shows all at once
|
||||
# Full build (~60s+, only when suspecting runtime/bundle issues)
|
||||
npm run build # next build runs compile+lint+types but stops at first type-error file; tsc shows all at once
|
||||
|
||||
# Database & External Services
|
||||
# npm run supabase:local-update-types # Generate TypeScript types
|
||||
# npm run stripe:listen # Listen for Stripe webhooks
|
||||
```
|
||||
|
||||
## Development Environment
|
||||
### Git/GitHub remotes
|
||||
|
||||
- Dev servers may be running on ports 3000, 3001, 3002, or 3003 (not always this app - other projects may occupy these ports). Never start or stop dev servers, let the user do it.
|
||||
- For runtime debugging, use `mcp__chrome-devtools` if present to launch a controlled Chrome instance against the running dev server - useful for console errors, network inspection, and React devtree.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Big-AGI is a Next.js 15 application with a modular architecture built for advanced AI interactions. The codebase follows a three-layer structure with distinct separation of concerns.
|
||||
The `gh` command is available to interact with GitHub from the terminal, but **NEVER PUSH TO ANY BRANCH**. The user manages all 'write' git operations.
|
||||
- `opensource` -> `enricoros/big-AGI` (public, default branch: `main`, MIT) - community issues/PRs/releases
|
||||
- `private` -> `big-agi/big-agi-private` (private, default branch: `dev`) - main dev repo with `dev`->`staging`->`prod` pipeline
|
||||
- **Always use `git mv` instead of `mv`** when renaming or moving files - preserves git history tracking
|
||||
- **NEVER run `git stash`** - it causes work loss
|
||||
|
||||
### Core Directory Structure
|
||||
|
||||
You are started from the root of the repository (i.e. where the git folder is or scripts should be run from). You won't need to issue 'cd ...' commands.
|
||||
You are started from the root of the repository (i.e. where the git folder is or scripts should be run from).
|
||||
**ISSUE ALL COMMANDS FROM THE ROOT, OMITTING 'cd' COMMANDS. DO NOT CHAIN CD AND OTHER COMMANDS**
|
||||
**NEVER RUN COMPOUND `cd` COMMANDS LIKE `cd some-folder && command` - ONLY RUN `command` FROM THE ROOT, ALWAYS.**
|
||||
The directory structure is as follows:
|
||||
|
||||
```
|
||||
/app/api/ # Next.js App Router (API routes only, mostly -> /src/server/)
|
||||
@@ -44,11 +53,11 @@ You are started from the root of the repository (i.e. where the git folder is or
|
||||
### Key Technologies
|
||||
|
||||
- **Frontend**: Next.js 15, React 18, Material-UI Joy, Emotion (CSS-in-JS)
|
||||
- **State Management**: Zustand with localStorge/IndexedDB (single cell) persistence
|
||||
- **State Management**: Zustand with localStorage/IndexedDB (single cell) persistence
|
||||
- **API Layer**: tRPC with TanStack React Query for type-safe communication
|
||||
- **Runtime**: Edge Runtime for AI operations, Node.js for data processing
|
||||
|
||||
### Apps Architecture Pattern
|
||||
### "Apps" Architecture Pattern
|
||||
|
||||
Each app in `/src/apps/` is a self-contained feature module:
|
||||
- Main component (`App*.tsx`)
|
||||
@@ -64,20 +73,20 @@ Modules in `/src/modules/` provide reusable business logic:
|
||||
- **`aix/`** - AI communication framework for real-time streaming
|
||||
- **`beam/`** - Multi-model AI reasoning system (scatter/gather pattern)
|
||||
- **`blocks/`** - Content rendering (markdown, code, images, etc.)
|
||||
- **`llms/`** - Language model abstraction supporting 19 vendors
|
||||
- **`llms/`** - Language model abstraction supporting 20+ vendors
|
||||
|
||||
### Key Subsystems & Their Patterns
|
||||
|
||||
#### 1. AIX - Real-time AI Communication
|
||||
#### AIX - Real-time AI Communication
|
||||
**Location**: `/src/modules/aix/`
|
||||
**Pattern**: Client-server streaming architecture with provider abstraction
|
||||
|
||||
- **Client** → tRPC → **Server** → **AI Providers**
|
||||
- **Client** -> tRPC -> **Server** -> **AI Providers**
|
||||
- Handles streaming/non-streaming responses with batching and error recovery
|
||||
- Particle-based streaming: `AixWire_Particles` → `ContentReassembler` → `DMessage`
|
||||
- Particle-based streaming: `AixWire_Particles` -> `ContentReassembler` -> `DMessage`
|
||||
- Provider-agnostic through adapter pattern (OpenAI, Anthropic, Gemini protocols)
|
||||
|
||||
#### 3. Beam - Multi-Model Reasoning
|
||||
#### Beam - Multi-Model Reasoning
|
||||
**Location**: `/src/modules/beam/`
|
||||
**Pattern**: Scatter/Gather for parallel AI processing
|
||||
|
||||
@@ -86,15 +95,24 @@ Modules in `/src/modules/` provide reusable business logic:
|
||||
- Real-time UI updates via vanilla Zustand stores
|
||||
- BeamStore per conversation via ConversationHandler
|
||||
|
||||
#### 4. Conversation Management
|
||||
#### Conversation Management
|
||||
**Location**: `/src/common/stores/chat/` and `/src/common/chat-overlay/`
|
||||
**Pattern**: Overlay architecture with handler per conversation
|
||||
|
||||
- `ConversationHandler` orchestrates chat, beam, ephemerals
|
||||
- Per-chat stores: `PerChatOverlayStore` + `BeamStore`
|
||||
- Message structure: `DMessage` → `DMessageFragment[]`
|
||||
- Message structure: `DMessage` -> `DMessageFragment[]`
|
||||
- Supports multi-pane with independent conversation states
|
||||
|
||||
#### Layout System ("Optima")
|
||||
|
||||
The Optima layout system provides:
|
||||
- **Responsive design** adapting desktop/mobile
|
||||
- **Drawer(left)/Toolbar/Panel(right)** composition
|
||||
- **Portal-based rendering** for flexible component placement
|
||||
|
||||
Located in `/src/common/layout/optima/`
|
||||
|
||||
### Storage System
|
||||
|
||||
Big-AGI uses a local-first architecture with Zustand + IndexedDB:
|
||||
@@ -102,7 +120,6 @@ Big-AGI uses a local-first architecture with Zustand + IndexedDB:
|
||||
- **localStorage** for persistent settings/all storage (via Zustand persist middleware)
|
||||
- **IndexedDB** for persistent chat-only storage (via Zustand persist middleware) on a single key-val cell
|
||||
- **Local-first** architecture with offline capability
|
||||
- **Migration system** for upgrading data structures across versions
|
||||
|
||||
Key storage patterns:
|
||||
- Stores use `createIDBPersistStorage()` for IndexedDB persistence
|
||||
@@ -114,16 +131,6 @@ Located in `/src/common/stores/` with stores like:
|
||||
- `chat/store-chats.ts`: Conversations and messages
|
||||
- `llms/store-llms.ts`: Model configurations
|
||||
|
||||
### Layout System ("Optima")
|
||||
|
||||
The Optima layout system provides:
|
||||
- **Responsive design** adapting desktop/mobile
|
||||
- **Drawer/Panel/Toolbar** composition
|
||||
- **Split-pane support** for multi-conversation views
|
||||
- **Portal-based rendering** for flexible component placement
|
||||
|
||||
Located in `/src/common/layout/optima/`
|
||||
|
||||
### State Management Patterns
|
||||
|
||||
1. **Global Stores** (Zustand with IndexedDB persistence)
|
||||
@@ -145,51 +152,47 @@ Located in `/src/common/layout/optima/`
|
||||
### User Flows & Interdependencies
|
||||
|
||||
#### Chat Message Flow
|
||||
1. User input → `Composer` → `DMessage` creation
|
||||
2. `ConversationHandler.messageAppend()` → Store update
|
||||
3. `_handleExecute()` / `ConversationHandler.executeChatMessages()` → AIX client request
|
||||
4. AIX streaming → `ContentReassembler` → UI updates
|
||||
5. Zustand auto-persistence → IndexedDB
|
||||
1. User input -> `Composer` -> `DMessage` creation
|
||||
2. `ConversationHandler.messageAppend()` -> Store update
|
||||
3. `_handleExecute()` / `ConversationHandler.executeChatMessages()` -> AIX client request
|
||||
4. AIX streaming -> `ContentReassembler` -> UI updates
|
||||
5. Zustand auto-persistence -> IndexedDB
|
||||
|
||||
#### Beam Multi-Model Flow
|
||||
1. User triggers Beam → `BeamStore.open()` state update
|
||||
1. User triggers Beam -> `BeamStore.open()` state update
|
||||
2. Scatter: Parallel `aixChatGenerateContent()` to N models
|
||||
3. Real-time ray updates → UI progress
|
||||
4. Gather: User selects fusion → Combined output
|
||||
5. Result → New message in conversation
|
||||
3. Real-time ray updates -> UI progress
|
||||
4. Gather: User selects fusion -> Combined output
|
||||
5. Result -> New message in conversation
|
||||
|
||||
### Development Patterns
|
||||
|
||||
#### TypeScript & Code Quality
|
||||
- Type-safe through strict TypeScript interfaces
|
||||
- Clear interface-first approach for modules and components
|
||||
- Use latest TypeScript 5.9+ features
|
||||
- Use forward-looking patterns to minimize future refactors (e.g., discriminated unions, `satisfies` operator, as const assertions)
|
||||
- Type guards and exhaustiveChecks for robustness
|
||||
- Type inference where possible
|
||||
- Runtime validation with Zod schemas for API inputs/outputs (usually server-side, with the client importing as types the inferred types)
|
||||
|
||||
#### Module Integration
|
||||
- Each module exports its functionality through index files
|
||||
- Modules register with central registries (e.g., `vendors.registry.ts`)
|
||||
- Configuration objects define module behavior
|
||||
- Type-safe integration through strict TypeScript interfaces
|
||||
|
||||
#### Component Patterns
|
||||
- **Controlled components** with clear prop interfaces
|
||||
- **Hook-based logic** extraction for reusability
|
||||
- **Portal rendering** for overlays and modals
|
||||
- **Suspense boundaries** for async operations
|
||||
|
||||
#### API Patterns
|
||||
- **tRPC routers** for type-safe API endpoints
|
||||
- **Zod schemas** for runtime validation
|
||||
- **Middleware** for request/response processing
|
||||
- **Edge functions** for performance-critical AI operations
|
||||
- **tRPC procedures middleware** for authorization and logging (authorization is on a httpOnly cookie)
|
||||
- **Edge functions** for performance-critical operations
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- API keys stored client-side in localStorage (user-provided)
|
||||
- Server-side API keys in environment variables only
|
||||
#### Security Considerations
|
||||
- API keys in environment variables only (server-side); on the client they're in localStorage for now, but we want to move away from this
|
||||
- XSS protection through proper content escaping
|
||||
- No credential transmission to third parties
|
||||
|
||||
## Knowledge Base
|
||||
#### Writing Style
|
||||
- **Never use emdashes (—).** Use normal dashes (-) instead, in all generated text, code comments, and documentation.
|
||||
|
||||
Architecture and system documentation is available in the `/kb/` knowledge base:
|
||||
|
||||
@kb/KB.md
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
@@ -198,41 +201,11 @@ Architecture and system documentation is available in the `/kb/` knowledge base:
|
||||
- Type-check with `tsc --noEmit`
|
||||
- Test critical user flows manually
|
||||
|
||||
### Adding a New LLM Vendor
|
||||
1. Create vendor in `/src/modules/llms/vendors/[vendor]/`
|
||||
2. Implement `IModelVendor` interface
|
||||
3. Register in `vendors.registry.ts`
|
||||
4. Add environment variables to the vendor's server file and `/src/server/env.server.ts` (if server-side keys needed)
|
||||
|
||||
### Debugging Storage Issues
|
||||
- Check IndexedDB: DevTools → Application → IndexedDB → `app-chats`
|
||||
- Check IndexedDB: DevTools -> Application -> IndexedDB -> `app-chats`
|
||||
- Monitor Zustand state: Use Zustand DevTools
|
||||
- Check migration logs in console during rehydration
|
||||
|
||||
## Code Examples
|
||||
|
||||
### AIX Streaming Pattern
|
||||
```typescript
|
||||
// Efficient streaming with decimation
|
||||
aixChatGenerateContent_DMessage_FromConversation(
|
||||
llmId,
|
||||
chatHistory,
|
||||
{ abortSignal, throttleParallelThreads: 1 },
|
||||
async (update, isDone) => {
|
||||
// Real-time UI updates
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Model Registry Pattern
|
||||
```typescript
|
||||
// Registry pattern for extensibility
|
||||
const MODEL_VENDOR_REGISTRY: Record<ModelVendorId, IModelVendor> = {
|
||||
openai: ModelVendorOpenAI,
|
||||
anthropic: ModelVendorAnthropic,
|
||||
// ... 17 more vendors
|
||||
};
|
||||
```
|
||||
|
||||
## Server Architecture
|
||||
|
||||
@@ -240,11 +213,14 @@ The server uses a split architecture with two tRPC routers:
|
||||
|
||||
### Edge Network (`trpc.router-edge`)
|
||||
Distributed edge runtime for low-latency AI operations:
|
||||
- **AIX** - AI streaming and communication
|
||||
- **LLM Routers** - Direct vendor integrations (OpenAI, Anthropic, Gemini, Ollama)
|
||||
- **Speex** - Unified TTS router (ElevenLabs, Inworld, and other TTS vendors)
|
||||
- **AIX** [1] - AI streaming and communication
|
||||
- **LLM Routers** [1] - Vendor-specific operations such as list models (OpenAI, Anthropic, Gemini, Ollama)
|
||||
- **Speex** [1] - Unified TTS router (ElevenLabs, Inworld, and other TTS vendors)
|
||||
- **External Services** - Google Search, YouTube transcripts
|
||||
|
||||
[1]: also supports client-side fetch (CSF) via client-side inclusion (rebundling with stubs),
|
||||
for direct browser-to-API communication when possible (CORS), to reduce latency and network barriers
|
||||
|
||||
Located at `/src/server/trpc/trpc.router-edge.ts`
|
||||
|
||||
### Cloud Network (`trpc.router-cloud`)
|
||||
@@ -255,3 +231,9 @@ Centralized server for data processing operations:
|
||||
Located at `/src/server/trpc/trpc.router-cloud.ts`
|
||||
|
||||
**Key Pattern**: Edge runtime for AI (fast, distributed), Cloud runtime for data ops (centralized, Node.js)
|
||||
|
||||
@kb/KB.md
|
||||
|
||||
@kb/vision-inlined.md
|
||||
|
||||
As a side note, the product tiers (independent, non-VC-funded) are: **Open** (self-host, MIT) · **Free** (big-agi.com) · **Pro** (paid, includes Sync + backup). All tiers use the user's own API keys.
|
||||
|
||||
@@ -37,13 +37,13 @@ You need to think broader, decide faster, and build with confidence, then you ne
|
||||
It comes packed with **world-class features** like Beam, and is praised for its **best-in-class AI chat UX**.
|
||||
**As an independent, non-VC-funded project, Pro subscriptions at $10.99/mo fund development for everyone, including the free and open-source tiers.**
|
||||
|
||||

|
||||

|
||||
[](https://big-agi.com/beam)
|
||||
[](https://big-agi.com/inspector)
|
||||
|
||||
### What makes Big-AGI different:
|
||||
|
||||
**Intelligence**: with [Beam & Merge](https://big-agi.com/beam) for multi-model de-hallucination, native search, and bleeding-edge AI models like Opus 4.5, Nano Banana Pro, Kimi K2.5 or GPT 5.2 -
|
||||
**Intelligence**: with [Beam & Merge](https://big-agi.com/beam) for multi-model de-hallucination, native search, and bleeding-edge AI models like Opus 4.7, Nano Banana Pro, Kimi K2.6 or GPT 5.4 -
|
||||
**Control**: with personas, data ownership, requests inspection, unlimited usage with API keys, and *no vendor lock-in* -
|
||||
and **Speed**: with a local-first, over-powered, zero-latency, madly optimized web app.
|
||||
|
||||
@@ -74,7 +74,7 @@ Purest AI outputs
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
Flow-state interface<br/>
|
||||
Higly customizable<br/>
|
||||
Highly customizable<br/>
|
||||
Best-in-class UX
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
@@ -144,6 +144,7 @@ NOTE: this is a powerful tool - if you need a toy UI or clone, this ain't it.
|
||||
## Release Notes
|
||||
|
||||
👉 **[See the Live Release Notes](https://big-agi.com/changes)**
|
||||
- Open 2.0.4: **Hyper Params** **Opus 4.6**, **GPT-5.4**, **Gemini 3.1 Pro**, AWS Bedrock, parameter accuracy, Anthropic continuation/Fast mode
|
||||
- Open 2.0.3: **Red Carpet** **Kimi K2.5**, **Gemini 3 Flash**, **GPT 5.2**, Google Drive, Inworld, Novita.ai, Speech/UX improvements
|
||||
- Open 2.0.2: **Speex** multi-vendor speech synthesis, **Opus 4.5**, **Gemini 3 Pro**, **Nano Banana Pro**, **Grok 4.1**, **GPT-5.1**, **Kimi K2** + 280 fixes
|
||||
|
||||
@@ -182,8 +183,11 @@ The new architecture is solid and the speed improvements are real.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.16.1...1.16.10 · 2024-2025 (patch releases)</summary>
|
||||
<summary>What's New in 1.16.1...1.16.13 · (patch releases)</summary>
|
||||
|
||||
- 1.16.13: Docker fix ([#840](https://github.com/enricoros/big-AGI/issues/840))
|
||||
- 1.16.12: Dockerfile update ([#840](https://github.com/enricoros/big-AGI/issues/840))
|
||||
- 1.16.11: v1 final release, documentation updates
|
||||
- 1.16.10: OpenRouter models support
|
||||
- 1.16.9: Docker Gemini fix, R1 models support
|
||||
- 1.16.8: OpenAI ChatGPT-4o Latest, o1 models support
|
||||
@@ -245,7 +249,7 @@ The new architecture is solid and the speed improvements are real.
|
||||
- New **[Perplexity](https://www.perplexity.ai/)** and **[Groq](https://groq.com/)** integration (thanks @Penagwin). [#407](https://github.com/enricoros/big-AGI/issues/407), [#427](https://github.com/enricoros/big-AGI/issues/427)
|
||||
- **[LocalAI](https://localai.io/models/)** deep integration, including support for [model galleries](https://github.com/enricoros/big-AGI/issues/411)
|
||||
- **Mistral** Large and Google **Gemini 1.5** support
|
||||
- Performance optimizations: runs [much faster](https://twitter.com/enricoros/status/1756553038293303434?utm_source=localhost:3000&utm_medium=big-agi), saves lots of power, reduces memory usage
|
||||
- Performance optimizations: runs [much faster](https://x.com/enricoros/status/1756553038293303434?utm_source=localhost:3000&utm_medium=big-agi), saves lots of power, reduces memory usage
|
||||
- Enhanced UX with auto-sizing charts, refined search and folder functionalities, perfected scaling
|
||||
- And with more UI improvements, documentation, bug fixes (20 tickets), and developer enhancements
|
||||
|
||||
@@ -313,7 +317,7 @@ For full details and former releases, check out the [archived versions changelog
|
||||
## 👉 Supported Models & Integrations
|
||||
|
||||
Delightful UX with latest models exclusive features like Beam for **multi-model AI validation**.
|
||||
> 
|
||||
> 
|
||||
> [](https://big-agi.com/beam)
|
||||
|
||||
|  |  |  |  |  |
|
||||
@@ -324,16 +328,17 @@ Delightful UX with latest models exclusive features like Beam for **multi-model
|
||||
|
||||
### AI Models & Vendors
|
||||
|
||||
Configure 100s of AI models from 19+ providers:
|
||||
Configure 100s of AI models from 20+ providers:
|
||||
|
||||
| **AI models** | _supported vendors_ |
|
||||
|:--------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Opensource Servers | [LocalAI](https://localai.io/) · [Ollama](https://ollama.com/) |
|
||||
| Local Servers | [LM Studio](https://lmstudio.ai/) (non-open) |
|
||||
| Multimodal services | [Azure](https://azure.microsoft.com/en-us/products/ai-services/openai-service) · [Anthropic](https://anthropic.com) · [Google Gemini](https://ai.google.dev/) · [OpenAI](https://platform.openai.com/docs/overview) |
|
||||
| **AI models** | _supported vendors_ |
|
||||
|:--------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Opensource Servers | [LocalAI](https://localai.io/) · [Ollama](https://ollama.com/) |
|
||||
| Local Servers | [LM Studio](https://lmstudio.ai/) (non-open) |
|
||||
| Multimodal services | [Anthropic](https://anthropic.com) · [AWS Bedrock](https://aws.amazon.com/bedrock/) · [Azure](https://azure.microsoft.com/en-us/products/ai-services/openai-service) · [Google Gemini](https://ai.google.dev/) · [OpenAI](https://platform.openai.com/docs/overview) |
|
||||
| LLM services | [Alibaba](https://www.alibabacloud.com/en/product/modelstudio) · [DeepSeek](https://deepseek.com) · [Groq](https://wow.groq.com/) · [Mistral](https://mistral.ai/) · [Moonshot](https://www.moonshot.cn/) · [OpenPipe](https://openpipe.ai/) · [OpenRouter](https://openrouter.ai/) · [Perplexity](https://www.perplexity.ai/) · [Together AI](https://www.together.ai/) · [xAI](https://x.ai/) · [Z.ai](https://z.ai/) |
|
||||
| Image services | OpenAI · Google Gemini |
|
||||
| Speech services | [ElevenLabs](https://elevenlabs.io) · [Inworld](https://inworld.ai) · [OpenAI TTS](https://platform.openai.com/docs/guides/text-to-speech) · LocalAI · Browser (Web Speech API) |
|
||||
| OpenAI-compatible | Any OpenAI-compatible endpoint - models, pricing, and capabilities are auto-detected |
|
||||
| Image services | OpenAI · Google Gemini (Nano Banana) · LocalAI |
|
||||
| Speech services | [ElevenLabs](https://elevenlabs.io) · [Inworld](https://inworld.ai) · [OpenAI TTS](https://platform.openai.com/docs/guides/text-to-speech) · LocalAI · Browser (Web Speech API) |
|
||||
|
||||
### Additional Integrations
|
||||
|
||||
@@ -341,7 +346,6 @@ Configure 100s of AI models from 19+ providers:
|
||||
|:--------------|:---------------------------------------------------------------------------------------------------------------|
|
||||
| Web Browse | [Browserless](https://www.browserless.io/) · [Puppeteer](https://pptr.dev/)-based |
|
||||
| Web Search | [Google CSE](https://programmablesearchengine.google.com/) |
|
||||
| Code Editors | [CodePen](https://codepen.io/pen/) · [StackBlitz](https://stackblitz.com/) · [JSFiddle](https://jsfiddle.net/) |
|
||||
| Observability | [Helicone](https://www.helicone.ai) |
|
||||
|
||||
---
|
||||
@@ -389,4 +393,4 @@ When you open an issue, our custom AI triage system (powered by [Claude Code](ht
|
||||
|
||||
MIT License · [Third-Party Notices](src/modules/3rdparty/THIRD_PARTY_NOTICES.md)
|
||||
|
||||
**2023-2026** · Enrico Ros × [Big-AGI](https://big-agi.com)
|
||||
**2023-2026** · [Enrico Ros](https://www.enricoros.com) × [Token Fabrics](https://www.tokenfabrics.com)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
unlisted: true
|
||||
---
|
||||
|
||||
# AIX dispatch server - API features comparison
|
||||
|
||||
This is updated as of 2024-07-09, and includes the latest features and capabilities of the three major AI APIs: Anthropic, Gemini, and OpenAI.
|
||||
|
||||
+11
-4
@@ -10,6 +10,8 @@ Essential guides:
|
||||
|
||||
- **[FAQ](help-faq.md)**: Common questions and answers
|
||||
- **[Enabling Microphone](help-feature-microphone.md)**: Configure speech recognition in your browser
|
||||
- **[Data Ownership](help-data-ownership.md)**: How your data is stored and managed
|
||||
- **[Live File](help-feature-livefile.md)**: Live file attachment feature
|
||||
|
||||
## AI Services
|
||||
|
||||
@@ -21,19 +23,21 @@ How to set up AI models and features in big-AGI.
|
||||
- Easy API key configuration:
|
||||
[Alibaba](https://bailian.console.alibabacloud.com/?apiKey=1#/api-key),
|
||||
[Anthropic](https://console.anthropic.com/settings/keys),
|
||||
[AWS Bedrock](https://console.aws.amazon.com/bedrock/),
|
||||
[Deepseek](https://platform.deepseek.com/api_keys),
|
||||
[Google Gemini](https://aistudio.google.com/app/apikey),
|
||||
[Groq](https://console.groq.com/keys),
|
||||
[Mistral](https://console.mistral.ai/api-keys/),
|
||||
[Moonshot](https://platform.moonshot.cn/console/api-keys),
|
||||
[OpenAI](https://platform.openai.com/api-keys),
|
||||
[OpenPipe](https://app.openpipe.ai/settings),
|
||||
[Perplexity](https://www.perplexity.ai/settings/api),
|
||||
[TogetherAI](https://api.together.xyz/settings/api-keys),
|
||||
[xAI](http://x.ai/api),
|
||||
[xAI](https://x.ai/api),
|
||||
[Z.ai](https://z.ai/)
|
||||
- **[Azure OpenAI](config-azure-openai.md)** guide
|
||||
- **FireworksAI** ([API keys](https://fireworks.ai/account/api-keys), via custom OpenAI endpoint: https://api.fireworks.ai/inference)
|
||||
- **[OpenRouter](config-openrouter.md)** guide
|
||||
- **OpenAI-compatible endpoints**: Any provider with an OpenAI-compatible API works out of the box - models, pricing, and capabilities are auto-detected
|
||||
|
||||
|
||||
- **Local AI Integrations**:
|
||||
@@ -43,8 +47,9 @@ How to set up AI models and features in big-AGI.
|
||||
- **Enhanced AI Features**:
|
||||
- **[Web Browsing](config-feature-browse.md)**: Enable web page download through third-party services or your own cloud
|
||||
- **Web Search**: Google Search API (see '[Environment Variables](environment-variables.md)')
|
||||
- **Image Generation**: GPT Image (gpt-image-1), DALL·E 3 and 2
|
||||
- **Image Generation**: GPT Image (gpt-image-1), Nano Banana, DALL·E 3 and 2
|
||||
- **Voice Synthesis**: ElevenLabs, Inworld, OpenAI TTS, LocalAI, or browser Web Speech API
|
||||
- **[Google Drive](config-feature-google-drive.md)**: Attach files from Google Drive
|
||||
|
||||
## Deployment & Customization
|
||||
|
||||
@@ -61,8 +66,10 @@ For deploying a custom big-AGI instance:
|
||||
- **Advanced Setup**:
|
||||
- **[Source Code Customization](customizations.md)**: Modify the source code
|
||||
- **[Access Control](deploy-authentication.md)**: Optional, add basic user authentication
|
||||
- **[Database Setup](deploy-database.md)**: Optional, enables "Chat Link Sharing"
|
||||
- **[Reverse Proxy](deploy-reverse-proxy.md)**: Optional, enables custom domains and SSL
|
||||
- **[Docker Deployment](deploy-docker.md)**: Deploy with Docker containers
|
||||
- **[Kubernetes](deploy-k8s.md)**: Deploy on Kubernetes clusters
|
||||
- **[Analytics](deploy-analytics.md)**: Set up usage analytics
|
||||
- **[Environment Variables](environment-variables.md)**: Pre-configures models and services
|
||||
|
||||
## Community & Support
|
||||
|
||||
+5
-3
@@ -20,8 +20,11 @@ by release.
|
||||
- And all of the [Big-AGI 2 changes](https://github.com/enricoros/big-AGI/issues/567#issuecomment-2262187617) and more
|
||||
- Built for the future, madly optimized
|
||||
|
||||
### What's New in 1.16.1...1.16.9 · Jan 21, 2025 (patch releases)
|
||||
### What's New in 1.16.1...1.16.13 · (patch releases)
|
||||
|
||||
- 1.16.13: Docker fix (#840)
|
||||
- 1.16.12: Dockerfile update (#840)
|
||||
- 1.16.11: v1 final release, documentation updates
|
||||
- 1.16.10: OpenRouter models support
|
||||
- 1.16.9: Docker Gemini fix, R1 models support
|
||||
- 1.16.8: OpenAI ChatGPT-4o Latest, o1 models support
|
||||
@@ -70,7 +73,7 @@ by release.
|
||||
- New **[Perplexity](https://www.perplexity.ai/)** and **[Groq](https://groq.com/)** integration (thanks @Penagwin). [#407](https://github.com/enricoros/big-AGI/issues/407), [#427](https://github.com/enricoros/big-AGI/issues/427)
|
||||
- **[LocalAI](https://localai.io/models/)** deep integration, including support for [model galleries](https://github.com/enricoros/big-AGI/issues/411)
|
||||
- **Mistral** Large and Google **Gemini 1.5** support
|
||||
- Performance optimizations: runs [much faster](https://twitter.com/enricoros/status/1756553038293303434?utm_source=localhost:3000&utm_medium=big-agi), saves lots of power, reduces memory usage
|
||||
- Performance optimizations: runs [much faster](https://x.com/enricoros/status/1756553038293303434?utm_source=localhost:3000&utm_medium=big-agi), saves lots of power, reduces memory usage
|
||||
- Enhanced UX with auto-sizing charts, refined search and folder functionalities, perfected scaling
|
||||
- And with more UI improvements, documentation, bug fixes (20 tickets), and developer enhancements
|
||||
- [Release notes](https://github.com/enricoros/big-AGI/releases/tag/v1.14.0), and changes [v1.13.1...v1.14.0](https://github.com/enricoros/big-AGI/compare/v1.13.1...v1.14.0) (233 commits, 8,000+ lines changed)
|
||||
@@ -228,7 +231,6 @@ For Developers:
|
||||
- **[Install Mobile APP](../docs/pixels/feature_pwa.png)** 📲 looks like native (@harlanlewis)
|
||||
- **[UI language](../docs/pixels/feature_language.png)** with auto-detect, and future app language! (@tbodyston)
|
||||
- **PDF Summarization** 🧩🤯 - ask questions to a PDF! (@fredliubojin)
|
||||
- **Code Execution: [Codepen](https://codepen.io/)** 💻 (@harlanlewis)
|
||||
- **[SVG Drawing](../docs/pixels/feature_svg_drawing.png)** - draw with AI 🎨
|
||||
- Chats: multiple chats, AI titles, Import/Export, Selection mode
|
||||
- Rendering: Markdown, SVG, improved Code blocks
|
||||
|
||||
@@ -41,6 +41,8 @@ In addition to using the UI, configuration can also be done using
|
||||
|
||||
### Integration: Models Gallery
|
||||
|
||||
> Note: The Gallery Admin feature described below may have been removed or renamed in recent versions of big-AGI.
|
||||
|
||||
If the running LocalAI instance is configured with a [Model Gallery](https://localai.io/models/):
|
||||
|
||||
- Go to Models > LocalAI
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# OpenRouter Configuration
|
||||
|
||||
[OpenRouter](https://openrouter.ai) is a standalone, premium service
|
||||
that provides access to <Link href='https://openrouter.ai/docs#models' target='_blank'>exclusive AI models</Link>
|
||||
such as GPT-4 32k, Claude, and more. These models are typically not available to the public.
|
||||
that provides access to a wide range of AI models from multiple providers through a single API.
|
||||
This document details the process of integrating OpenRouter with big-AGI.
|
||||
|
||||
### 1. OpenRouter Account Setup and API Key Generation
|
||||
@@ -20,7 +19,7 @@ This document details the process of integrating OpenRouter with big-AGI.
|
||||

|
||||
3. Input the API key into the **OpenRouter API Key** field, and load the Models.
|
||||

|
||||
4. OpenAI GPT4-32k and other models will now be accessible and selectable in the application.
|
||||
4. Models from all supported providers will now be accessible and selectable in the application.
|
||||
|
||||
In addition to using the UI, configuration can also be done using
|
||||
[environment variables](environment-variables.md).
|
||||
@@ -30,5 +29,5 @@ In addition to using the UI, configuration can also be done using
|
||||
OpenRouter independently manages its service and pricing and is not affiliated with big-AGI.
|
||||
For more detailed information, please visit [this page](https://openrouter.ai/docs#models).
|
||||
|
||||
Please note that running large models such as GPT-4 32k can be costly and may rapidly consume
|
||||
credits - a single prompt may cost $1 or more, at the time of writing.
|
||||
Please note that running large models can be costly and may rapidly consume credits.
|
||||
Check model pricing on the OpenRouter website before use.
|
||||
@@ -49,8 +49,8 @@ Edit the `src/data.ts` file to customize personas. This file houses the default
|
||||
Adapt the UI to match your project's aesthetic, incorporate new features, or exclude unnecessary ones.
|
||||
|
||||
- [ ] Adjust `src/common/app.theme.ts` for theme changes: colors, spacing, button appearance, animations, etc
|
||||
- [ ] Modify `src/common/app.config.tsx` to alter the application's name
|
||||
- [ ] Update `src/common/app.nav.tsx` to revise the navigation bar
|
||||
- [ ] Modify `src/common/app.release.ts` to alter the application's name
|
||||
- [ ] Update `src/common/app.nav.ts` to revise the navigation bar
|
||||
|
||||
### Add a Message of the Day
|
||||
|
||||
@@ -71,7 +71,7 @@ Example: `NEXT_PUBLIC_MOTD=🚀 New features available in {{app_build_pkgver}}!
|
||||
|
||||
Test your application thoroughly using local development (refer to README.md for local build instructions). Deploy using your preferred hosting service. big-AGI supports deployment on platforms like Vercel, Docker, or any Node.js-compatible service, especially those supporting NextJS's "Edge Runtime."
|
||||
|
||||
- [deploy-cloudflare.md](deploy-cloudflare.md): for Cloudflare Workers deployment
|
||||
- [deploy-cloudflare.md](deploy-cloudflare.md): for Cloudflare Pages deployment (limited support)
|
||||
- [deploy-docker.md](deploy-docker.md): for Docker deployment instructions and examples
|
||||
- [deploy-k8s.md](deploy-k8s.md): for Kubernetes deployment instructions and examples
|
||||
|
||||
|
||||
@@ -51,13 +51,13 @@ Vercel Analytics and Speed Insights are local API endpoints deployed to your dom
|
||||
domain. Furthermore, the Vercel Analytics service is privacy-friendly, and does not track individual users.
|
||||
|
||||
This service is avaialble to system administrators when deploying to Vercel. It is automatically enabled when deploying to Vercel.
|
||||
The code that activates Vercel Analytics is located in the `src/pages/_app.tsx` file:
|
||||
The code that activates Vercel Analytics is located in the `pages/_app.tsx` file:
|
||||
|
||||
```tsx
|
||||
const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) => <>
|
||||
...
|
||||
{isVercelFromFrontend && <VercelAnalytics debug={false} />}
|
||||
{isVercelFromFrontend && <VercelSpeedInsights debug={false} sampleRate={1 / 2} />}
|
||||
{Is.Deployment.VercelFromFrontend && <VercelAnalytics debug={false} />}
|
||||
{Is.Deployment.VercelFromFrontend && <VercelSpeedInsights debug={false} sampleRate={1 / 2} />}
|
||||
...
|
||||
</>;
|
||||
```
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
---
|
||||
unlisted: true
|
||||
---
|
||||
|
||||
# Deploying a Next.js App on Cloudflare Pages
|
||||
|
||||
> WARNING: Cloudflare Pages does not support traditional NodeJS runtimes, but only Edge Runtime functions.
|
||||
> WARNING: Cloudflare Pages only supports Edge Runtime functions, not the full Node.js runtime.
|
||||
>
|
||||
> In this project we use Prisma connected to serverless Postgres, which at the moment cannot run on
|
||||
> edge functions, so we cannot deploy this project on Cloudflare Pages.
|
||||
> The cloud router in this project requires a Node.js runtime for Supabase SDK, authentication,
|
||||
> sync, and other server-side features that cannot run on Cloudflare's edge runtime.
|
||||
>
|
||||
> Workaround: Step 3.4. has been added below, to DELETE the NodeJS traditional runtime - which means that some
|
||||
> Workaround: Step 3.4. has been added below, to DELETE the Node.js cloud router - which means that some
|
||||
> parts of this application will not work.
|
||||
> - [Side effects](https://github.com/enricoros/big-agi/blob/main/src/apps/chat/trade/server/trade.router.ts#L19):
|
||||
> Sharing functionality to DB, and import from ChatGPT share, and post to Paste.GG will not work
|
||||
> - [Side effects](https://github.com/enricoros/big-agi/blob/main/src/modules/trade/server/trade.router.ts):
|
||||
> Sharing functionality, import from ChatGPT share, and post to Paste.GG will not work
|
||||
> - Cloud features (sync, auth, payments) will not be available
|
||||
> - See [Issue 174](https://github.com/enricoros/big-agi/issues/174).
|
||||
>
|
||||
> Longer term: follow [prisma/prisma: Support Edge Function deployments](https://github.com/prisma/prisma/issues/21394)
|
||||
> and convert the Node runtime to Edge runtime once Prisma supports it.
|
||||
|
||||
This guide provides steps to deploy your Next.js app on Cloudflare Pages.
|
||||
It is based on the [official Cloudflare developer documentation](https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Why big-AGI?
|
||||
Placeholder for a document that demonstrates the productivity and unique features of Big-AGI.
|
||||
|
||||
## Exclusive features
|
||||
- [x] Call AGI
|
||||
- [x] Continuous Voice mode
|
||||
- [x] Diagram generation
|
||||
- [ ] ...
|
||||
|
||||
## Productivity Features
|
||||
- [x] Multi-window to never wait
|
||||
- [x] Multi-Chat to explore different solutions
|
||||
- [x] Rendering of graphs, charts, mindmaps
|
||||
- [ ] ...
|
||||
@@ -3,7 +3,7 @@
|
||||
This document provides an explanation of the environment variables used in the big-AGI application.
|
||||
|
||||
**All variables are optional**; and _UI options_ take precedence over _backend environment variables_,
|
||||
which take place over _defaults_. This file is kept in sync with [`../src/server/env.ts`](../src/server/env.ts).
|
||||
which take place over _defaults_. This file is kept in sync with [`../src/server/env.server.ts`](../src/server/env.server.ts).
|
||||
|
||||
### Setting Environment Variables
|
||||
|
||||
@@ -29,6 +29,11 @@ AZURE_OPENAI_API_ENDPOINT=
|
||||
AZURE_OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
ANTHROPIC_API_HOST=
|
||||
BEDROCK_BEARER_TOKEN=
|
||||
BEDROCK_ACCESS_KEY_ID=
|
||||
BEDROCK_SECRET_ACCESS_KEY=
|
||||
BEDROCK_SESSION_TOKEN=
|
||||
BEDROCK_REGION=
|
||||
DEEPSEEK_API_KEY=
|
||||
GEMINI_API_KEY=
|
||||
GROQ_API_KEY=
|
||||
@@ -100,7 +105,12 @@ requiring the user to enter an API key
|
||||
| `AZURE_OPENAI_API_VERSION` | API version for traditional deployment-based endpoints | Optional, defaults to '2025-04-01-preview' |
|
||||
| `AZURE_DEPLOYMENTS_API_VERSION` | API version for the deployments listing endpoint | Optional, defaults to '2023-03-15-preview' |
|
||||
| `ANTHROPIC_API_KEY` | The API key for Anthropic | Optional |
|
||||
| `ANTHROPIC_API_HOST` | Changes the backend host for the Anthropic vendor, to enable platforms such as AWS Bedrock | Optional |
|
||||
| `ANTHROPIC_API_HOST` | Changes the backend host for the Anthropic vendor, for proxies or custom endpoints | Optional |
|
||||
| `BEDROCK_BEARER_TOKEN` | Bedrock long-term API key (`ABSK...`). Takes priority over IAM credentials. Short-term keys only work for runtime, not model listing | Optional |
|
||||
| `BEDROCK_ACCESS_KEY_ID` | AWS IAM Access Key ID for Bedrock (Claude models via AWS) | Optional, but if set `BEDROCK_SECRET_ACCESS_KEY` must also be set |
|
||||
| `BEDROCK_SECRET_ACCESS_KEY` | AWS IAM Secret Access Key for Bedrock | Optional, but if set `BEDROCK_ACCESS_KEY_ID` must also be set |
|
||||
| `BEDROCK_SESSION_TOKEN` | AWS Session Token for temporary/STS credentials | Optional |
|
||||
| `BEDROCK_REGION` | AWS region for Bedrock (e.g., `us-east-1`, `us-west-2`, `eu-west-1`) | Optional, defaults to `us-east-1` |
|
||||
| `DEEPSEEK_API_KEY` | The API key for Deepseek AI | Optional |
|
||||
| `GEMINI_API_KEY` | The API key for Google AI's Gemini | Optional |
|
||||
| `GROQ_API_KEY` | The API key for Groq Cloud | Optional |
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
unlisted: true
|
||||
---
|
||||
|
||||
# Big-AGI Advanced Tips & Tricks
|
||||
|
||||
> 🚨 This file is not meant for publication, and it's just been created as a handbook with tips
|
||||
|
||||
@@ -30,6 +30,12 @@ You can see your data in your browser's local storage and IndexedDB - try it you
|
||||
|
||||

|
||||
|
||||
### Sync for Authenticated Users
|
||||
|
||||
Users with accounts on big-agi.com who opt into Sync (a Pro feature) have their entity data - such as conversations and personas - replicated to the server for multi-device access.
|
||||
Server-side data is isolated per-user using Row Level Security (RLS), ensuring that no other user can access your synced data.
|
||||
Sync is entirely optional; without it, all data remains local to your browser.
|
||||
|
||||
### What This Means For You
|
||||
|
||||
Storing data in your browser means:
|
||||
@@ -43,7 +49,7 @@ Storing data in your browser means:
|
||||
|
||||
Big-AGI generates a _device identifier_ that combines timestamp and random components, stored only on your device. This identifier:
|
||||
|
||||
- Is used only for the **optional sync functionality** between your devices (not yet ready)
|
||||
- Is used only for the **optional sync functionality** between your devices
|
||||
- Helps maintain data consistency when using Big-AGI across multiple devices
|
||||
- Remains completely local unless you explicitly enable sync
|
||||
- Is not used for tracking, analytics, or telemetry
|
||||
@@ -74,6 +80,27 @@ and then are send to the upstream AI services.
|
||||
|
||||

|
||||
|
||||
### Direct Connection (Browser → AI Service)
|
||||
|
||||
Most AI services offer a **Direct Connection** toggle (under a service's Advanced settings). When enabled, the browser calls the AI provider's API directly, skipping the Big-AGI server entirely.
|
||||
|
||||
Benefits:
|
||||
|
||||
- **No 4.5 MB upload limit** - the Vercel body-size cap does not apply, so larger attachments and long prompts go through.
|
||||
- **No 300-second timeout** - the Vercel function timeout does not apply, so long-running generations keep streaming.
|
||||
- **More privacy** - connection metadata (IP, timestamp, edge region, Vercel telemetry) is not observable by the Big-AGI edge server.
|
||||
|
||||
Tradeoff:
|
||||
|
||||
- **Slightly more downlink bandwidth**: when traffic passes through the Big-AGI edge, repetitive streaming frames are compacted; direct streams arrive verbatim from the provider.
|
||||
|
||||
Availability requires both:
|
||||
|
||||
1. The API key is set in your browser (client-side), not via server environment variables. Server-key deployments cannot use Direct Connection because the browser has no credential to send.
|
||||
2. The AI service allows CORS (browser-origin requests). Most major providers do; Big-AGI sets any extra headers they require.
|
||||
|
||||
Direct Connection is a net win on speed, limits, and privacy whenever the provider permits it.
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
**Basic Security**:
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
Quick answers to common questions about Big-AGI. For detailed documentation, see our [Website Docs](https://big-agi.com/docs).
|
||||
|
||||
### Connectivity
|
||||
|
||||
<details open>
|
||||
<summary><b>What is "Direct Connection" and should I enable it?</b></summary>
|
||||
|
||||
Direct Connection lets the browser call the AI provider's API directly, skipping the Big-AGI edge server. It appears as a toggle in each AI service's Advanced settings when your API key is set client-side.
|
||||
|
||||
**When available, it is a net win**: faster, fewer restrictions, more privacy.
|
||||
|
||||
- **No 4.5 MB upload limit** (Vercel body-size cap does not apply).
|
||||
- **No 300-second timeout** (Vercel function timeout does not apply; call length is bound only by the AI service).
|
||||
- **More privacy** - connection metadata (IP, timestamp, edge region, Vercel telemetry) is not observable by the Big-AGI edge server.
|
||||
- **Slightly more downlink bandwidth** - when passing through the edge, Big-AGI sheds repetitive streaming frames; direct streams arrive verbatim.
|
||||
|
||||
**When it is unavailable**:
|
||||
|
||||
1. **Server-side keys** - if the deployment stores API keys in server environment variables, the browser has no credential to send directly.
|
||||
2. **Provider does not allow CORS** - browsers cannot call APIs that block cross-origin requests. Most major providers permit it; Big-AGI sets any required headers.
|
||||
</details>
|
||||
|
||||
### Versions
|
||||
|
||||
<details open>
|
||||
|
||||
@@ -7,7 +7,7 @@ process for your own instance of big-AGI and related products.
|
||||
|
||||
**Try big-AGI** - You don't need to install anything if you want to play with big-AGI
|
||||
and have your API keys to various model services. You can access our free instance on [big-AGI.com](https://big-agi.com).
|
||||
The free instance runs the latest `main-stable` branch from this repository.
|
||||
The free instance runs the latest `main` branch from this repository.
|
||||
|
||||
## 🧩 Build-your-own
|
||||
|
||||
@@ -72,9 +72,8 @@ Create your GitHub fork, create a Vercel project over that fork, and deploy it.
|
||||
|
||||
### Deploy on Cloudflare
|
||||
|
||||
Deploy on Cloudflare's global network by installing big-AGI on
|
||||
Cloudflare Pages. Check out the [Cloudflare Installation Guide](deploy-cloudflare.md)
|
||||
for step-by-step instructions.
|
||||
> Note: Cloudflare Pages deployment has limitations due to Edge Runtime constraints.
|
||||
> See the [Cloudflare guide](deploy-cloudflare.md) for details and known issues.
|
||||
|
||||
### Docker Deployments
|
||||
|
||||
@@ -146,6 +145,6 @@ Enjoy all the features of big-AGI without the hassle of infrastructure managemen
|
||||
Join our vibrant community of developers, researchers, and AI enthusiasts. Share your projects, get help, and collaborate with others.
|
||||
|
||||
- [Discord Community](https://discord.gg/MkH4qj2Jp9)
|
||||
- [Twitter](https://twitter.com/enricoros)
|
||||
- [X (Twitter)](https://x.com/enricoros)
|
||||
|
||||
For any questions or inquiries, please don't hesitate to [reach out to our team](mailto:hello@big-agi.com).
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
unlisted: true
|
||||
---
|
||||
|
||||
# ReAct: question answering with Reasoning and Actions
|
||||
|
||||
## What is ReAct?
|
||||
|
||||
@@ -14,4 +14,10 @@ const compat = new FlatCompat({
|
||||
|
||||
export default defineConfig([{
|
||||
extends: compat.extends("next/core-web-vitals"),
|
||||
rules: {
|
||||
//
|
||||
"react-hooks/exhaustive-deps": ["warn", {
|
||||
additionalHooks: "(useMemoShallowStable)",
|
||||
}],
|
||||
},
|
||||
}]);
|
||||
@@ -1,13 +1,13 @@
|
||||
# Knowledge Base
|
||||
## Knowledge Base
|
||||
|
||||
Internal documentation for Big-AGI architecture and systems, for use by AI agents and developers.
|
||||
Architecture and system documentation is available in the `/kb/` knowledge base, for use by AI agents and developers.
|
||||
|
||||
**Structure:**
|
||||
- `/kb/KB.md` - Already in context: this text
|
||||
- `/kb/vision-inlined.md` - Already in context (next section): long-term vision and north stars
|
||||
- `/kb/modules/` - Core business logic (e.g. AIX)
|
||||
- `/kb/systems/` - Infrastructure (routing, startup)
|
||||
|
||||
## Index
|
||||
|
||||
### Modules Documentation
|
||||
|
||||
#### AIX - AI Communication Framework
|
||||
@@ -22,17 +22,18 @@ Internal documentation for Big-AGI architecture and systems, for use by AI agent
|
||||
#### Core Platform Systems
|
||||
- **[app-routing.md](systems/app-routing.md)** - Next.js routing, provider stack, and display state hierarchy
|
||||
- **[LLM-parameters-system.md](systems/LLM-parameters-system.md)** - Language model parameter flow across the system
|
||||
- **[LLM-vendor-integration.md](modules/LLM-vendor-integration.md)** - Adding new LLM providers
|
||||
|
||||
## Guidelines
|
||||
### KB Guidelines
|
||||
|
||||
### Writing Style
|
||||
#### Writing Style
|
||||
|
||||
- **Direct and factual** - No marketing language
|
||||
- **Present tense** - "AIX handles streaming" not "AIX will handle"
|
||||
- **Active voice** - "The system processes" not "Processing is done by"
|
||||
- **Concrete examples** - Show actual code/config when helpful, briefly
|
||||
|
||||
### Maintenance
|
||||
#### Maintenance
|
||||
|
||||
- Remove outdated information when detected!
|
||||
- Remove outdated knowledge base information when detected
|
||||
- Keep cross-references current when files move
|
||||
|
||||
@@ -7,8 +7,8 @@ This document analyzes all AIX function callers and their patterns for message r
|
||||
### Three-Tier Call Hierarchy
|
||||
|
||||
**Core AIX Functions** (Direct tRPC API callers):
|
||||
- `aixChatGenerateContent_DMessage_FromConversation` - 8 callers (conversation streaming)
|
||||
- `aixChatGenerateContent_DMessage` - 6 callers (direct request/response)
|
||||
- `aixChatGenerateContent_DMessage_FromConversation` - 9 callers (conversation streaming)
|
||||
- `aixChatGenerateContent_DMessage_orThrow` - 6 callers (direct request/response)
|
||||
- `aixChatGenerateText_Simple` - 12 callers (text-only utilities)
|
||||
|
||||
**Utility Layer** (Hooks & Functions):
|
||||
@@ -24,6 +24,7 @@ This document analyzes all AIX function callers and their patterns for message r
|
||||
| **Caller** | **Context** | **Message Removal** | **Placeholder** | **Error Handling** |
|
||||
|------------|-------------|-------------------|----------------|-------------------|
|
||||
| **Chat Persona** | `'conversation'` | `messageWasInterruptedAtStart()` → `removeMessage()` | None | Error fragments |
|
||||
| **XE Chat Generate** | `'conversation'` | `messageWasInterruptedAtStart()` → `removeMessage()` | `'...'` placeholder | Error fragments via messageEditor |
|
||||
| **Beam Scatter** | `'beam-scatter'` | `messageWasInterruptedAtStart()` → empty message | `SCATTER_PLACEHOLDER` | Ray status update |
|
||||
| **Beam Gather** | `'beam-gather'` | `messageWasInterruptedAtStart()` → clear fragments | `GATHER_PLACEHOLDER` | Re-throw errors |
|
||||
| **Beam Follow-up** | `'beam-followup'` | `messageWasInterruptedAtStart()` → remove message | `FOLLOWUP_PLACEHOLDER` | Status updates |
|
||||
|
||||
+4
-4
@@ -92,12 +92,12 @@ AIX is organized into the following files and folders:
|
||||
|
||||
- Dispatch (`/server/dispatch/`) - Server to AI Provider communication:
|
||||
- `/server/dispatch/chatGenerate/`: Content Generation with chat-style inputs:
|
||||
- `./adapters/`: Adapters for creating API requests for different AI protocols (Anthropic, Gemini, OpenAI).
|
||||
- `./parsers/`: Parsers for parsing streaming/non-streamin responses from different AI protocols (same 3).
|
||||
- `./adapters/`: Adapters for creating API requests for different AI protocols (Anthropic, Bedrock, Gemini, OpenAI Chat Completions, OpenAI Responses, xAI Responses).
|
||||
- `./parsers/`: Parsers for parsing streaming/non-streaming responses from different AI protocols (Anthropic, Bedrock Converse, Gemini, OpenAI, OpenAI Responses).
|
||||
- `chatGenerate.dispatch.ts`: Creates a pipeline to execute Chat Generation to a specific provider.
|
||||
- `ChatGenerateTransmitter.ts`: Used to serialize and transmit AixWire_Particles to the client.
|
||||
- `/server/dispatch/wiretypes/`: AI provider Wire Types:
|
||||
- Type definitions for different AI providers/protocols (Anthropic, Gemini, OpenAI).
|
||||
- Type definitions for different AI providers/protocols (Anthropic, Bedrock Converse, Gemini, OpenAI, xAI).
|
||||
- `stream.demuxers.ts`: Handles demuxing of different stream formats.
|
||||
|
||||
## 3. Architecture Diagram
|
||||
@@ -160,7 +160,7 @@ sequenceDiagram
|
||||
AIX Client ->> AIX Client: Display error message
|
||||
else DMessageDocPart
|
||||
AIX Client ->> AIX Client: Process and display document
|
||||
else DMetaPlaceholderPart
|
||||
else DVoidPlaceholderPart
|
||||
AIX Client ->> AIX Client: Handle placeholder (non-submitted)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# LLM Vendor Integration Guide
|
||||
|
||||
How to add support for new LLM providers in Big-AGI. There are two integration paths, and
|
||||
the dynamic backend path is strongly preferred for new vendors.
|
||||
|
||||
## Integration Paths
|
||||
|
||||
### Path 1: Dynamic Backend (preferred)
|
||||
|
||||
For any provider with an **OpenAI-compatible API** (which is nearly all new providers).
|
||||
|
||||
**Surface area**: 1-2 files, no UI changes, no registry changes.
|
||||
|
||||
A dynamic backend provides:
|
||||
- Hostname-based auto-detection when the user adds the provider's API URL
|
||||
- Automatic model list parsing with vendor-specific metadata (pricing, context windows, capabilities)
|
||||
- Zero UI code - uses the existing "Custom OpenAI-compatible" service setup
|
||||
|
||||
**Files touched**:
|
||||
- `src/modules/llms/server/openai/models/{vendor}.models.ts` (required) - model definitions + hostname heuristic
|
||||
- `src/modules/llms/server/openai/wiretypes/{vendor}.wiretypes.ts` (optional) - Zod schemas for vendor-specific wire format
|
||||
- `src/modules/llms/server/listModels.dispatch.ts` - add heuristic to the detection chain (2 lines)
|
||||
|
||||
**What the model file must export**:
|
||||
```typescript
|
||||
// 1. Hostname heuristic - returns true when the user's API URL matches this vendor
|
||||
export function vendorHeuristic(hostname: string): boolean {
|
||||
return hostname.includes('.vendor-domain.com');
|
||||
}
|
||||
|
||||
// 2. Model converter - transforms vendor's /v1/models response to ModelDescriptionSchema[]
|
||||
export function vendorModelsToModelDescriptions(wireModels: unknown): ModelDescriptionSchema[] {
|
||||
// Parse wire format, map to ModelDescriptionSchema with:
|
||||
// - id, label, description
|
||||
// - contextWindow, maxCompletionTokens
|
||||
// - interfaces (Chat, Vision, Fn, Reasoning, etc.)
|
||||
// - chatPrice (input/output per token)
|
||||
// - parameterSpecs (temperature, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
**Existing examples**: `novita.models.ts`, `chutesai.models.ts`, `fireworksai.models.ts`
|
||||
|
||||
MUST also provide the updated vendor icon like other icons in `src/common/components/icons/vendors/`.
|
||||
Make sure all the information is available if in the future we want to promote those to full registered vendors.
|
||||
|
||||
### Path 2: Registered Vendor (heavyweight, discouraged for new providers)
|
||||
|
||||
Full first-class integration with dedicated UI, own dialect, and registry entry. Reserved for
|
||||
providers with **non-OpenAI protocols** (Anthropic, Gemini, Ollama) or providers with enough
|
||||
user demand to warrant a dedicated setup flow.
|
||||
|
||||
**Surface area**: 5+ files across 3 directories.
|
||||
|
||||
**Files touched**:
|
||||
- `src/modules/llms/vendors/{vendor}/{vendor}.vendor.ts` - IModelVendor implementation
|
||||
- `src/modules/llms/vendors/{vendor}/{VendorName}ServiceSetup.tsx` - React UI setup component
|
||||
- `src/modules/llms/vendors/vendors.registry.ts` - registry entry + ModelVendorId union
|
||||
- `src/modules/llms/server/openai/models/{vendor}.models.ts` - model definitions
|
||||
- `src/modules/llms/server/listModels.dispatch.ts` - dispatch case
|
||||
- Possibly server protocol adapter if not OpenAI-compatible
|
||||
- Possibly more files, e.g. wires, etc.
|
||||
- See existing providers and commits that added them for full scope
|
||||
|
||||
**When to use this path**: Only when the provider has a meaningfully different API protocol
|
||||
(not OpenAI-compatible), or when there is significant user demand AND the provider offers
|
||||
unique capabilities that benefit from dedicated UI (e.g., Ollama's local model management).
|
||||
|
||||
When using this path, please add links to upstream documentation. Make sure all constants
|
||||
are correctly handled everywhere, especially for provider-based switches.
|
||||
|
||||
## Decision Criteria
|
||||
|
||||
| Question | Dynamic | Registered |
|
||||
|----------|---------|------------|
|
||||
| OpenAI-compatible API? | Yes - use dynamic | Only if not OAI-compatible |
|
||||
| Needs custom auth UI? | No - uses generic fields | Yes - custom setup form |
|
||||
| Unique protocol? | No | Yes (Anthropic, Gemini, Ollama) |
|
||||
| User demand level | Any | High + sustained |
|
||||
| Maintenance burden | Minimal | Significant (5+ files) |
|
||||
|
||||
## For External Contributors / Vendor Requests
|
||||
|
||||
When vendors or community members request integration via GitHub issues:
|
||||
|
||||
1. **Point them to the dynamic backend path** - it's faster to implement, review, and maintain
|
||||
2. **Requirements for a dynamic backend PR**:
|
||||
- Model file with heuristic + converter exporting `ModelDescriptionSchema[]`
|
||||
- Wire types if the vendor's `/v1/models` response has non-standard fields
|
||||
- Vendor icon (SVG preferred) in `src/common/components/icons/vendors/`
|
||||
- Two-line addition to the heuristic chain in `listModels.dispatch.ts`
|
||||
3. **Do not accept**: New registered vendors for OpenAI-compatible providers. The maintenance
|
||||
cost of a full vendor (UI component, registry entry, dispatch case) is not justified when
|
||||
dynamic detection achieves the same result with a fraction of the code.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### How Dynamic Detection Works
|
||||
|
||||
In `listModels.dispatch.ts`, the `case 'openai':` handler:
|
||||
1. Fetches `/v1/models` from the user-provided API host
|
||||
2. Runs the hostname through a chain of heuristics (in order)
|
||||
3. First matching heuristic's converter is used to parse models
|
||||
4. Falls back to stock OpenAI parsing if no heuristic matches
|
||||
|
||||
### Hostname Security
|
||||
|
||||
Hostname matching uses `llmsHostnameMatches()` from `openai.access.ts` which parses the
|
||||
URL properly to prevent DNS spoofing. Always use `.includes()` on the parsed hostname,
|
||||
never on the raw URL string.
|
||||
|
||||
### Key Types
|
||||
|
||||
- `ModelDescriptionSchema` (`llm.server.types.ts`) - output type for all model converters
|
||||
- `DModelInterfaceV1` (`llms.types.ts`) - capability flags (Chat, Vision, Fn, Reasoning, etc.)
|
||||
- `IModelVendor` (`vendors/IModelVendor.ts`) - interface for registered vendors only
|
||||
- `ManualMappings` / `KnownModel` (`models.mappings.ts`) - server-side model patches
|
||||
|
||||
### File Locations
|
||||
|
||||
- Dynamic backends: `src/modules/llms/server/openai/models/`
|
||||
- Wire types: `src/modules/llms/server/openai/wiretypes/`
|
||||
- Dispatch: `src/modules/llms/server/listModels.dispatch.ts`
|
||||
- Registered vendors: `src/modules/llms/vendors/*/`
|
||||
- Vendor icons: `src/common/components/icons/vendors/`
|
||||
- Type definitions: `src/modules/llms/server/llm.server.types.ts`
|
||||
@@ -47,7 +47,7 @@ Shows only parameters that are:
|
||||
- Not marked as `hidden`
|
||||
|
||||
**Value Resolution**: Both UIs use `getAllModelParameterValues()` to merge:
|
||||
1. **Fallback values** - Implicit parameters get their `runtimeFallback` values
|
||||
1. **Fallback values** - Implicit parameters get their `LLMImplicitParametersRuntimeFallback` values
|
||||
2. **Initial values** - Model's `initialParameters` (populated during model creation)
|
||||
3. **User values** - User's `userParameters` (highest priority)
|
||||
|
||||
@@ -63,7 +63,7 @@ The AIX client transforms DLLM parameters to wire protocol format. This layer ha
|
||||
|
||||
Server-side adapters translate AIX parameters to vendor APIs. Each vendor may interpret parameters differently:
|
||||
|
||||
- **OpenAI**: `vndEffort` → `reasoning_effort`
|
||||
- **OpenAI**: `vndEffort` -> `reasoning_effort`
|
||||
- **Perplexity**: Reuses OpenAI parameter format
|
||||
- **OpenAI Responses API**: Maps to structured reasoning config with additional logic
|
||||
|
||||
@@ -71,8 +71,8 @@ Server-side adapters translate AIX parameters to vendor APIs. Each vendor may in
|
||||
|
||||
When a model is loaded:
|
||||
|
||||
1. **Model Creation**: `modelDescriptionToDLLM()` creates the DLLM with empty `initialParameters`
|
||||
2. **Initial Value Application**: `applyModelParameterInitialValues()` populates initial values from:
|
||||
1. **Model Creation**: `_createDLLMFromModelDescription()` creates the DLLM with empty `initialParameters`
|
||||
2. **Initial Value Application**: `applyModelParameterSpecsInitialValues()` populates initial values from:
|
||||
- Model spec `initialValue` (highest priority)
|
||||
- Registry `initialValue` (fallback)
|
||||
3. **Runtime Resolution**: `getAllModelParameterValues()` creates final parameter set:
|
||||
@@ -117,4 +117,4 @@ The architecture supports parameter evolution:
|
||||
- **UI Controls**: `src/modules/llms/models-modal/LLMParametersEditor.tsx`
|
||||
- **AIX Translation**: `src/modules/aix/client/aix.client.ts`
|
||||
- **Wire Types**: `src/modules/aix/server/api/aix.wiretypes.ts`
|
||||
- **Vendor Adapters**: `src/modules/aix/server/dispatch/chatGenerate/adapters/*.ts`
|
||||
- **Vendor Adapters**: `src/modules/aix/server/dispatch/chatGenerate/adapters/*.ts`
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
# CSF - Client-Side Fetch
|
||||
|
||||
Client-Side Fetch (CSF) enables direct browser-to-API communication, bypassing the server for LLM requests. When enabled, the browser makes requests directly to vendor APIs (e.g., `api.openai.com`, `api.groq.com`) instead of routing through the Next.js server. This reduces latency, decreases server load, and is particularly useful for local models where the browser can communicate directly with Ollama or LM Studio.
|
||||
Client-Side Fetch (CSF), surfaced to users as **"Direct Connection"**, enables direct browser-to-API communication, bypassing the server for LLM requests. When enabled, the browser makes requests directly to vendor APIs (e.g., `api.openai.com`, `api.groq.com`) instead of routing through the Next.js server. This reduces latency, decreases server load, and is particularly useful for local models where the browser can communicate directly with Ollama or LM Studio.
|
||||
|
||||
## User-facing tradeoffs (Direct Connection vs via-server)
|
||||
|
||||
Wins when Direct Connection is on:
|
||||
- **No 4.5MB upload limit** (Vercel body-size cap does not apply to direct browser-to-API requests).
|
||||
- **No 300s function timeout** (Vercel serverless/edge timeout does not apply; call duration is bound only by the AI service).
|
||||
- **More privacy**: connection metadata (IP, timestamp, edge region, Vercel telemetry) is not observable by the Big-AGI edge server.
|
||||
|
||||
Costs:
|
||||
- **Slightly more downlink bandwidth**: when traffic passes through the Big-AGI server, repetitive streaming frames are shed/compacted; direct streams arrive verbatim.
|
||||
|
||||
Availability requires both:
|
||||
1. The API key is on the **client** (localStorage), not a server-side env var. Server-key deployments cannot use CSF because the browser has no credential to send.
|
||||
2. The AI service **allows CORS** from browsers. Most major providers do; some require specific headers which Big-AGI sets.
|
||||
|
||||
Net: Direct Connection is a win on speed, limits, and privacy whenever the provider permits it. It is unavailable when keys are server-side or the provider blocks browser-origin requests.
|
||||
|
||||
## Implementation
|
||||
|
||||
CSF is implemented as an opt-in setting stored as `csf: boolean` in each vendor's service settings. The vendor interface exposes `csfAvailable?: (setup) => boolean` to determine if CSF can be enabled (typically checking if an API key or host is configured). The actual execution happens in `aix.client.direct-chatGenerate.ts` which dynamically imports when CSF is active, making direct fetch calls using the same wire protocols as the server.
|
||||
|
||||
All 17 supported vendors (OpenAI, Anthropic, Gemini, Ollama, LocalAI, Deepseek, Groq, Mistral, xAI, OpenRouter, Perplexity, Together AI, Alibaba, Moonshot, OpenPipe, LM Studio, Z.ai) support CSF. Cloud vendors require CORS support from the API provider (all tested vendors return `access-control-allow-origin: *`). Local vendors (Ollama, LocalAI, LM Studio) require CORS to be enabled on the local server.
|
||||
All 20+ supported vendors (OpenAI, Anthropic, Gemini, Ollama, LocalAI, Deepseek, Groq, Mistral, xAI, OpenRouter, Perplexity, Together AI, Alibaba, Moonshot, OpenPipe, LM Studio, Z.ai, Azure, Bedrock) support CSF. Cloud vendors require CORS support from the API provider (all tested vendors return `access-control-allow-origin: *`). Local vendors (Ollama, LocalAI, LM Studio) require CORS to be enabled on the local server.
|
||||
|
||||
## UI
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
## Strategic Vision
|
||||
|
||||
If provided, the following influences the long-term vision, product and architectural goals/north stars for Big-AGI.
|
||||
+1
-1
@@ -18,7 +18,7 @@ process.env.NEXT_PUBLIC_BUILD_HASH = (buildHash || '').slice(0, 10);
|
||||
process.env.NEXT_PUBLIC_BUILD_PKGVER = JSON.parse('' + readFileSync(new URL('./package.json', import.meta.url))).version;
|
||||
process.env.NEXT_PUBLIC_BUILD_TIMESTAMP = new Date().toISOString();
|
||||
process.env.NEXT_PUBLIC_DEPLOYMENT_TYPE = process.env.NEXT_PUBLIC_DEPLOYMENT_TYPE || (process.env.VERCEL_ENV ? `vercel-${process.env.VERCEL_ENV}` : 'local'); // Docker or custom, Vercel
|
||||
console.log(` 🧠 \x1b[1mbig-AGI\x1b[0m v${process.env.NEXT_PUBLIC_BUILD_PKGVER} (@${process.env.NEXT_PUBLIC_BUILD_HASH})`);
|
||||
console.log(` 🧠 \x1b[1mbig-AGI\x1b[0m v${process.env.NEXT_PUBLIC_BUILD_PKGVER} (@${process.env.NEXT_PUBLIC_BUILD_HASH}${process.env.VERCEL_ENV ? `, \x1b[2mV:\x1b[0m${process.env.VERCEL_ENV}` : ''}, \x1b[2mN:\x1b[0m${process.env.NODE_ENV})`);
|
||||
|
||||
// Non-default build types
|
||||
const buildType =
|
||||
|
||||
Generated
+304
-277
File diff suppressed because it is too large
Load Diff
+17
-14
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.4",
|
||||
"private": true,
|
||||
"author": "Enrico Ros <enrico.ros@gmail.com>",
|
||||
"author": "Enrico Ros <enrico@big-agi.com> (https://www.enricoros.com)",
|
||||
"homepage": "https://big-agi.com",
|
||||
"repository": "https://github.com/enricoros/big-agi",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -11,6 +12,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"tsclint": "tsc --noEmit --pretty",
|
||||
"postinstall": "prisma generate --no-hints",
|
||||
"gen:icon-sprites": "node tools/develop/gen-icon-sprites/generate-llm-sprites.ts",
|
||||
"db:push": "prisma db push",
|
||||
@@ -35,14 +37,15 @@
|
||||
"@mui/joy": "^5.0.0-beta.52",
|
||||
"@next/bundle-analyzer": "~15.1.12",
|
||||
"@prisma/client": "~5.22.0",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-virtual": "^3.13.22",
|
||||
"@trpc/client": "11.5.1",
|
||||
"@trpc/next": "11.5.1",
|
||||
"@trpc/react-query": "11.5.1",
|
||||
"@trpc/server": "11.5.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"browser-fs-access": "^0.38.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"csv-stringify": "^6.6.0",
|
||||
@@ -56,13 +59,13 @@
|
||||
"next": "~15.1.12",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "5.4.54",
|
||||
"posthog-js": "^1.341.0",
|
||||
"posthog-node": "^5.24.10",
|
||||
"posthog-js": "^1.369.0",
|
||||
"posthog-node": "^5.29.2",
|
||||
"prismjs": "^1.30.0",
|
||||
"puppeteer-core": "^24.36.1",
|
||||
"puppeteer-core": "^24.40.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^3.4.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
@@ -81,20 +84,20 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@posthog/nextjs-config": "~1.6.4",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.2.11",
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "~15.1.12",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier": "^3.8.2",
|
||||
"prisma": "~5.22.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.0.0 || ^22.0.0 || ^20.0.0"
|
||||
|
||||
+20
-3
@@ -37,14 +37,31 @@ export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
|
||||
<meta property='og:site_name' content={Brand.Meta.SiteName} />
|
||||
<meta property='og:type' content='website' />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
{/* Twitter / X */}
|
||||
<meta name='twitter:card' content='summary_large_image' />
|
||||
<meta property='twitter:url' content={Brand.URIs.Home} />
|
||||
<meta property='twitter:title' content={Brand.Title.Common} />
|
||||
<meta property='twitter:description' content={Brand.Meta.Description} />
|
||||
{Brand.URIs.CardImage && <meta property='twitter:image' content={Brand.URIs.CardImage} />}
|
||||
<meta name='twitter:site' content={Brand.Meta.TwitterSite} />
|
||||
<meta name='twitter:card' content='summary_large_image' />
|
||||
<meta name='twitter:creator' content='@enricoros' />
|
||||
<link rel='canonical' href={Brand.URIs.Home} />
|
||||
|
||||
{/* Author & Structured Data */}
|
||||
<meta name='author' content='Enrico Ros' />
|
||||
<link rel='author' href='https://www.enricoros.com' />
|
||||
<script type='application/ld+json' dangerouslySetInnerHTML={{ __html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
'name': 'Big-AGI',
|
||||
'url': 'https://big-agi.com',
|
||||
'applicationCategory': 'ProductivityApplication',
|
||||
'operatingSystem': 'All, Web',
|
||||
'description': Brand.Meta.Description,
|
||||
'sameAs': ['https://github.com/enricoros/big-agi', 'https://discord.gg/MkH4qj2Jp9',],
|
||||
'author': { '@type': 'Person', 'name': 'Enrico Ros', 'url': 'https://www.enricoros.com' },
|
||||
'publisher': { '@type': 'Organization', 'name': 'Token Fabrics LLC', 'url': 'https://www.tokenfabrics.com' },
|
||||
}) }} />
|
||||
|
||||
{/* Style Sheets (injected and server-side) */}
|
||||
<meta name='emotion-insertion-point' content='' />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "big-AGI",
|
||||
"theme_color": "#32383E",
|
||||
"background_color": "#9FA6AD",
|
||||
"description": "Your Generative AI Suite",
|
||||
"description": "Open-source AI workspace. Multi-model reasoning and personas for maximum control.",
|
||||
"categories": [
|
||||
"productivity",
|
||||
"AI",
|
||||
|
||||
@@ -22,7 +22,6 @@ import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { OptimaPanelGroupedList } from '~/common/layout/optima/panel/OptimaPanelGroupedList';
|
||||
import { OptimaPanelIn, OptimaToolbarIn } from '~/common/layout/optima/portals/OptimaPortalsIn';
|
||||
import { PhVoice } from '~/common/components/icons/phosphor/PhVoice';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/speechrecognition/useSpeechRecognition';
|
||||
import { clipboardInterceptCtrlCForCleanup } from '~/common/util/clipboardUtils';
|
||||
import { conversationTitle, remapMessagesSysToUsr } from '~/common/stores/chat/chat.conversation';
|
||||
@@ -31,7 +30,7 @@ import { createErrorContentFragment } from '~/common/stores/chat/chat.fragments'
|
||||
import { launchAppChat, navigateToIndex } from '~/common/app.routes';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts';
|
||||
import { usePlayUrl } from '~/common/util/audio/usePlayUrl';
|
||||
import { usePlayUrlInterval } from './state/usePlayUrlInterval';
|
||||
|
||||
import type { AppCallIntent } from './AppCall';
|
||||
import { CallAvatar } from './components/CallAvatar';
|
||||
@@ -128,11 +127,11 @@ export function Telephone(props: {
|
||||
|
||||
// pickup / hangup
|
||||
React.useEffect(() => {
|
||||
!isRinging && AudioPlayer.playUrl(isConnected ? '/sounds/chat-begin.mp3' : '/sounds/chat-end.mp3');
|
||||
!isRinging && void AudioPlayer.playUrl(isConnected ? '/sounds/chat-begin.mp3' : '/sounds/chat-end.mp3').catch(() => {/* autoplay may be blocked */});
|
||||
}, [isRinging, isConnected]);
|
||||
|
||||
// ringtone
|
||||
usePlayUrl(isRinging ? '/sounds/chat-ringtone.mp3' : null, 300, 2800 * 2);
|
||||
usePlayUrlInterval(isRinging ? '/sounds/chat-ringtone.mp3' : null, 300, 2800 * 2);
|
||||
|
||||
|
||||
/// Shortcuts
|
||||
@@ -251,13 +250,13 @@ export function Telephone(props: {
|
||||
if (messageWasInterruptedAtStart(status.lastDMessage))
|
||||
return;
|
||||
|
||||
// whether status.outcome === 'success' or not, we get a valid DMessage, eventually with Error Fragments inside
|
||||
// whether status.outcome === 'completed' or not, we get a valid DMessage, eventually with Error Fragments inside
|
||||
const fullMessage = createDMessageFromFragments('assistant', status.lastDMessage.fragments);
|
||||
fullMessage.generator = status.lastDMessage.generator;
|
||||
setCallMessages(messages => [...messages, fullMessage]); // [state] append assistant:call_response
|
||||
|
||||
// fire/forget - use 'fast' priority for real-time conversation
|
||||
if (status.outcome === 'success' && finalText?.length >= 1)
|
||||
if (status.outcome === 'completed' && finalText?.length >= 1)
|
||||
void speakText(finalText,
|
||||
undefined,
|
||||
{ label: 'Call', priority: 'fast' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
|
||||
|
||||
@@ -8,15 +9,16 @@ import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
* @param firstDelay The delay before the first play, in milliseconds.
|
||||
* @param repeatMs The delay between each repeat, in milliseconds. If 0, the sound will only play once.
|
||||
*/
|
||||
export function usePlayUrl(url: string | null, firstDelay: number = 0, repeatMs: number = 0) {
|
||||
export function usePlayUrlInterval(url: string | null, firstDelay: number = 0, repeatMs: number = 0) {
|
||||
React.useEffect(() => {
|
||||
if (!url) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
let timer2: any = null;
|
||||
|
||||
const playFirstTime = () => {
|
||||
const playAudio = () => AudioPlayer.playUrl(url);
|
||||
void playAudio();
|
||||
const playAudio = () => void AudioPlayer.playUrl(url, abortController.signal).catch(() => {/* autoplay may be blocked */});
|
||||
playAudio();
|
||||
timer2 = repeatMs > 0 ? setInterval(playAudio, repeatMs) : null;
|
||||
};
|
||||
|
||||
@@ -24,8 +26,8 @@ export function usePlayUrl(url: string | null, firstDelay: number = 0, repeatMs:
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1);
|
||||
if (timer2)
|
||||
clearInterval(timer2);
|
||||
timer2 && clearInterval(timer2);
|
||||
abortController?.abort();
|
||||
};
|
||||
}, [firstDelay, repeatMs, url]);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import { createErrorContentFragment, createTextContentFragment, DMessageAttachme
|
||||
import { gcChatImageAssets } from '~/common/stores/chat/chat.gc';
|
||||
import { getChatLLMId } from '~/common/stores/llms/store-llms';
|
||||
import { getConversation, getConversationSystemPurposeId, useConversation } from '~/common/stores/chat/store-chats';
|
||||
import { optimaActions, optimaOpenModels, optimaOpenPreferences } from '~/common/layout/optima/useOptima';
|
||||
import { optimaActions, optimaOpenModels, optimaOpenPreferences, useOptimaChromeless } from '~/common/layout/optima/useOptima';
|
||||
import { useFolderStore } from '~/common/stores/folders/store-chat-folders';
|
||||
import { useIsMobile, useIsTallScreen } from '~/common/components/useMatchMedia';
|
||||
import { useLLM } from '~/common/stores/llms/llms.hooks';
|
||||
@@ -209,7 +209,8 @@ export function AppChat() {
|
||||
});
|
||||
|
||||
// Composer Auto-hiding
|
||||
const forceComposerHide = !!beamOpenStoreInFocusedPane /* || !focusedPaneConversationId */; // auto-hide when no chat (the 'please select a conversation...' state) doesn't feel good
|
||||
const isChromeless = useOptimaChromeless() && isMobile; // auto-hide on Chromeless too
|
||||
const forceComposerHide = isChromeless || !!beamOpenStoreInFocusedPane /* || !focusedPaneConversationId */; // auto-hide when no chat (the 'please select a conversation...' state) doesn't feel good
|
||||
const composerAutoHide = useComposerAutoHide(forceComposerHide, composerHasContent);
|
||||
|
||||
// Window actions
|
||||
@@ -492,6 +493,7 @@ export function AppChat() {
|
||||
|
||||
const focusedChatPanelContent = React.useMemo(() => !focusedPaneConversationId ? null :
|
||||
<ChatPane
|
||||
isMobile={isMobile}
|
||||
conversationId={focusedPaneConversationId}
|
||||
disableItems={!focusedPaneConversationId || isFocusedChatEmpty}
|
||||
hasConversations={hasConversations}
|
||||
@@ -581,9 +583,11 @@ export function AppChat() {
|
||||
}, []);
|
||||
|
||||
useGlobalShortcuts('AppChat', React.useMemo(() => [
|
||||
// focused conversation
|
||||
{ key: 'z', ctrl: true, shift: true, disabled: isFocusedChatEmpty, action: handleMessageRegenerateLastInFocusedPane, description: 'Retry' },
|
||||
{ key: 'b', ctrl: true, shift: true, disabled: isFocusedChatEmpty, action: handleMessageBeamLastInFocusedPane, description: 'Beam Edit' },
|
||||
// focused conversation (excluded when Beam is open so the keystroke passes through to the browser)
|
||||
...(beamOpenStoreInFocusedPane ? [] : [
|
||||
{ key: 'z', ctrl: true, shift: true, disabled: isFocusedChatEmpty, action: handleMessageRegenerateLastInFocusedPane, description: 'Retry' },
|
||||
{ key: 'b', ctrl: true, shift: true, disabled: isFocusedChatEmpty, action: handleMessageBeamLastInFocusedPane, description: 'Beam Edit' },
|
||||
]),
|
||||
{ key: 'o', ctrl: true, action: handleConversationsImportFormFilePicker },
|
||||
{ key: 's', ctrl: true, action: () => handleFileSaveConversation(focusedPaneConversationId) },
|
||||
{ key: 'n', ctrl: true, shift: true, action: () => handleConversationNewInFocusedPane(false, false) },
|
||||
@@ -601,7 +605,7 @@ export function AppChat() {
|
||||
{ key: 'p', ctrl: true, action: () => personaDropdownRef.current?.openListbox() /*, description: 'Open Persona Dropdown'*/ },
|
||||
// focused conversation llm
|
||||
{ key: 'o', ctrl: true, shift: true, action: handleOpenChatLlmOptions },
|
||||
], [focusedPaneConversationId, handleConversationNewInFocusedPane, handleConversationReset, handleConversationsImportFormFilePicker, handleDeleteConversations, handleFileSaveConversation, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleMoveFocus, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty]));
|
||||
], [beamOpenStoreInFocusedPane, focusedPaneConversationId, handleConversationNewInFocusedPane, handleConversationReset, handleConversationsImportFormFilePicker, handleDeleteConversations, handleFileSaveConversation, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleMoveFocus, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty]));
|
||||
|
||||
|
||||
return <>
|
||||
@@ -768,7 +772,7 @@ export function AppChat() {
|
||||
</Box>
|
||||
|
||||
{/* Hover zone for auto-hide */}
|
||||
{!forceComposerHide && composerAutoHide.isHidden && <Box {...composerAutoHide.detectorProps} />}
|
||||
{!isChromeless && !forceComposerHide && composerAutoHide.isHidden && <Box {...composerAutoHide.detectorProps} />}
|
||||
|
||||
{/* Diagrams */}
|
||||
{!!diagramConfig && (
|
||||
|
||||
@@ -15,7 +15,7 @@ import { DConversationId, excludeSystemMessages } from '~/common/stores/chat/cha
|
||||
import { ShortcutKey, useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts';
|
||||
import { clipboardInterceptCtrlCForCleanup } from '~/common/util/clipboardUtils';
|
||||
import { convertFilesToDAttachmentFragments } from '~/common/attachment-drafts/attachment.pipeline';
|
||||
import { createDMessageFromFragments, createDMessageTextContent, DMessage, DMessageId, DMessageUserFlag, DMetaReferenceItem, MESSAGE_FLAG_AIX_SKIP, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { createDMessageFromFragments, createDMessageTextContent, DMessage, DMessageGenerator, DMessageId, DMessageUserFlag, DMetaReferenceItem, MESSAGE_FLAG_AIX_SKIP, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { createTextContentFragment, DMessageFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import { openFileForAttaching } from '~/common/components/ButtonAttachFiles';
|
||||
import { optimaOpenPreferences } from '~/common/layout/optima/useOptima';
|
||||
@@ -123,6 +123,61 @@ export function ChatMessageList(props: {
|
||||
}
|
||||
}, [conversationHandler, conversationId, onConversationExecuteHistory]);
|
||||
|
||||
const handleMessageUpstreamResume = React.useCallback(async (generator: DMessageGenerator, messageId: DMessageId) => {
|
||||
if (!conversationId || !conversationHandler) return;
|
||||
if (!generator.upstreamHandle) throw new Error('No upstream handle on generator');
|
||||
|
||||
// For AIX generators the DLLMId is at .aix.mId
|
||||
const llmId = generator.mgt === 'aix' ? generator.aix.mId : undefined;
|
||||
if (!llmId) throw new Error('No model id on generator');
|
||||
|
||||
const { aixCreateChatGenerateContext, aixReattachContent_DMessage_orThrow } = await import('~/modules/aix/client/aix.client');
|
||||
const result = await aixReattachContent_DMessage_orThrow(
|
||||
llmId,
|
||||
generator,
|
||||
aixCreateChatGenerateContext('conversation', conversationId),
|
||||
{ abortSignal: 'NON_ABORTABLE', throttleParallelThreads: 0 },
|
||||
async (update, isDone) => {
|
||||
conversationHandler.messageEdit(messageId, {
|
||||
fragments: update.fragments,
|
||||
generator: update.generator,
|
||||
pendingIncomplete: update.pendingIncomplete,
|
||||
}, isDone, isDone); // remove the pending state and updte only when done
|
||||
},
|
||||
);
|
||||
|
||||
// Manual reattach is one-shot: on failure (e.g. upstream 404 from expired or already-consumed handle),
|
||||
// drop the upstreamHandle so the Resume button doesn't keep luring the user into the same error.
|
||||
// On 'aborted' we keep it so the user can try again later; on 'completed' the reassembler already cleared it.
|
||||
// 2026-04-22: disabled; it was removing the connect button on a connection error (e.g. wifi drop)
|
||||
// if (result.outcome === 'failed' && result.generator?.upstreamHandle)
|
||||
// conversationHandler.messageEdit(messageId, {
|
||||
// generator: { ...result.generator, upstreamHandle: undefined },
|
||||
// }, false /* messageComplete */, true /* touch */);
|
||||
}, [conversationHandler, conversationId]);
|
||||
|
||||
const handleMessageUpstreamDelete = React.useCallback(async (generator: DMessageGenerator, messageId: DMessageId) => {
|
||||
if (!conversationId || !conversationHandler) return;
|
||||
if (!generator.upstreamHandle) throw new Error('No upstream handle on generator');
|
||||
|
||||
// For AIX generators the DLLMId is at .aix.mId
|
||||
const llmId = generator.mgt === 'aix' ? generator.aix.mId : undefined;
|
||||
if (!llmId) throw new Error('No model id on generator');
|
||||
|
||||
const { aixDeleteUpstreamContent_orThrow } = await import('~/modules/aix/client/aix.client');
|
||||
const result = await aixDeleteUpstreamContent_orThrow(llmId, generator);
|
||||
|
||||
// On success (or 404 already-gone), clear the handle locally so the buttons disappear
|
||||
if (result.ok) {
|
||||
conversationHandler.messageEdit(messageId, {
|
||||
generator: { ...generator, upstreamHandle: undefined },
|
||||
}, false /* messageComplete */, true /* touch */);
|
||||
return;
|
||||
}
|
||||
// On failure: surface to the button's error UI
|
||||
throw new Error(result.message || `Delete failed${result.httpStatus ? ` (HTTP ${result.httpStatus})` : ''}`);
|
||||
}, [conversationHandler, conversationId]);
|
||||
|
||||
|
||||
// message menu methods proxy
|
||||
|
||||
@@ -371,6 +426,8 @@ export function ChatMessageList(props: {
|
||||
onMessageBeam={handleMessageBeam}
|
||||
onMessageBranch={handleMessageBranch}
|
||||
onMessageContinue={handleMessageContinue}
|
||||
onMessageUpstreamResume={handleMessageUpstreamResume}
|
||||
onMessageUpstreamDelete={handleMessageUpstreamDelete}
|
||||
onMessageDelete={handleMessageDelete}
|
||||
onMessageFragmentAppend={handleMessageAppendFragment}
|
||||
onMessageFragmentDelete={handleMessageDeleteFragment}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Textarea, Typography } from '@mui/joy';
|
||||
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import type { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
|
||||
import { Box, Button, ButtonGroup, Card, Grid, IconButton, Textarea, Typography } from '@mui/joy';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
@@ -26,6 +24,7 @@ import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal';
|
||||
import { ConversationsManager } from '~/common/chat-overlay/ConversationsManager';
|
||||
import { DMessageId, DMessageMetadata, DMetaReferenceItem, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
|
||||
import { PhPaintBrush } from '~/common/components/icons/phosphor/PhPaintBrush';
|
||||
import { ShortcutKey, ShortcutObject, useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts';
|
||||
import { addSnackbar } from '~/common/components/snackbar/useSnackbarsStore';
|
||||
import { animationEnterBelow } from '~/common/util/animUtils';
|
||||
@@ -39,8 +38,9 @@ import { getModelParameterValueWithFallback } from '~/common/stores/llms/llms.pa
|
||||
import { launchAppCall, removeQueryParam, useRouterQuery } from '~/common/app.routes';
|
||||
import { lineHeightTextareaMd, themeBgAppChatComposer } from '~/common/app.theme';
|
||||
import { optimaOpenPreferences } from '~/common/layout/optima/useOptima';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
import { supportsCameraCapture } from '~/common/components/camera/useCameraCapture';
|
||||
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
|
||||
import { useAttachHandler_CameraOpen, useAttachHandler_Files, useAttachHandler_PasteIntercept, useAttachHandler_ScreenCapture, useAttachHandler_UrlWebLinks } from '~/common/attachment-drafts/attachment-sources/useAttachmentSourceHandlers';
|
||||
import { useChatComposerOverlayStore } from '~/common/chat-overlay/store-perchat_vanilla';
|
||||
import { useComposerStartupText, useLogicSherpaStore } from '~/common/logic/store-logic-sherpa';
|
||||
import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents';
|
||||
@@ -53,21 +53,15 @@ import { providerCommands } from './actile/providerCommands';
|
||||
import { providerStarredMessages, StarredMessageItem } from './actile/providerStarredMessage';
|
||||
import { useActileManager } from './actile/useActileManager';
|
||||
|
||||
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import { LLMAttachmentDraftsAction, LLMAttachmentsList } from './llmattachments/LLMAttachmentsList';
|
||||
import { PhPaintBrush } from '~/common/components/icons/phosphor/PhPaintBrush';
|
||||
import type { AttachmentDraftId, AttachmentDraftsAction } from '~/common/attachment-drafts/attachment.types';
|
||||
import { AttachmentSourcesMemo } from '~/common/attachment-drafts/attachment-sources/AttachmentSources';
|
||||
import { useAttachmentDrafts } from '~/common/attachment-drafts/useAttachmentDrafts';
|
||||
import { useLLMAttachmentDrafts } from './llmattachments/useLLMAttachmentDrafts';
|
||||
import { useAttachmentDraftsEnrichment } from '~/common/attachment-drafts/llm-enrichment/useAttachmentDraftsEnrichment';
|
||||
import { useGoogleDrivePicker } from '~/common/attachment-drafts/attachment-sources/useGoogleDrivePicker';
|
||||
|
||||
import type { ChatExecuteMode } from '../../execute-mode/execute-mode.types';
|
||||
import { chatExecuteModeCanAttach, useChatExecuteMode } from '../../execute-mode/useChatExecuteMode';
|
||||
|
||||
import { ButtonAttachCameraMemo, useCameraCaptureModalDialog } from './buttons/ButtonAttachCamera';
|
||||
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
|
||||
import { ButtonAttachGoogleDriveMemo } from './buttons/ButtonAttachGoogleDrive';
|
||||
import { ButtonAttachScreenCaptureMemo } from './buttons/ButtonAttachScreenCapture';
|
||||
import { ButtonAttachWebMemo } from './buttons/ButtonAttachWeb';
|
||||
import { hasGoogleDriveCapability, useGoogleDrivePicker } from '~/common/attachment-drafts/useGoogleDrivePicker';
|
||||
import { ButtonBeamMemo } from './buttons/ButtonBeam';
|
||||
import { ButtonCallMemo } from './buttons/ButtonCall';
|
||||
import { ButtonGroupDrawRepeat } from './buttons/ButtonGroupDrawRepeat';
|
||||
@@ -75,6 +69,7 @@ import { ButtonMicContinuationMemo } from './buttons/ButtonMicContinuation';
|
||||
import { ButtonMicMemo } from './buttons/ButtonMic';
|
||||
import { ButtonMultiChatMemo } from './buttons/ButtonMultiChat';
|
||||
import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
|
||||
import { ComposerAttachmentDraftsList } from './llmattachments/ComposerAttachmentDraftsList';
|
||||
import { ComposerTextAreaActions } from './textarea/ComposerTextAreaActions';
|
||||
import { ComposerTextAreaDrawActions } from './textarea/ComposerTextAreaDrawActions';
|
||||
import { StatusBarMemo } from '../StatusBar';
|
||||
@@ -82,7 +77,6 @@ import { TokenBadgeMemo } from './tokens/TokenBadge';
|
||||
import { TokenProgressbarMemo } from './tokens/TokenProgressbar';
|
||||
import { useComposerDragDrop } from './useComposerDragDrop';
|
||||
import { useTextTokenCount } from './tokens/useTextTokenCounter';
|
||||
import { useWebInputModal } from './WebInputModal';
|
||||
|
||||
|
||||
// configuration
|
||||
@@ -139,16 +133,13 @@ export function Composer(props: {
|
||||
// external state
|
||||
const { showPromisedOverlay } = useOverlayComponents();
|
||||
const { newChat: appChatNewChatIntent } = useRouterQuery<Partial<AppChatIntent>>();
|
||||
const { labsAttachScreenCapture, labsCameraDesktop, labsShowCost, labsShowShortcutBar } = useUXLabsStore(useShallow(state => ({
|
||||
labsAttachScreenCapture: state.labsAttachScreenCapture,
|
||||
labsCameraDesktop: state.labsCameraDesktop,
|
||||
labsShowCost: state.labsShowCost,
|
||||
const { labsComposerAttachmentsInline, labsShowShortcutBar } = useUXLabsStore(useShallow(state => ({
|
||||
labsComposerAttachmentsInline: state.labsComposerAttachmentsInline,
|
||||
labsShowShortcutBar: state.labsShowShortcutBar,
|
||||
})));
|
||||
const timeToShowTips = useLogicSherpaStore(state => state.usageCount >= SHOW_TIPS_AFTER_RELOADS);
|
||||
const { novel: explainShiftEnter, touch: touchShiftEnter } = useUICounter('composer-shift-enter');
|
||||
const { novel: explainAltEnter, touch: touchAltEnter } = useUICounter('composer-alt-enter');
|
||||
const { novel: explainCtrlEnter, touch: touchCtrlEnter } = useUICounter('composer-ctrl-enter');
|
||||
|
||||
const [startupText, setStartupText] = useComposerStartupText();
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
const composerQuickButton = useUIPreferencesStore(state => state.composerQuickButton);
|
||||
@@ -177,8 +168,8 @@ export function Composer(props: {
|
||||
const chatLLMSupportsImages = !!props.chatLLM?.interfaces?.includes(LLM_IF_OAI_Vision);
|
||||
|
||||
// don't load URLs if the user is typing a command or there's no capability
|
||||
const hasComposerBrowseCapability = useBrowseCapability().inComposer;
|
||||
const enableLoadURLsInComposer = hasComposerBrowseCapability && !composeText.startsWith('/');
|
||||
const browseCapability = useBrowseCapability();
|
||||
const enableLoadURLsInComposer = browseCapability.inComposer && !composeText.startsWith('/');
|
||||
|
||||
// user message for attachments
|
||||
const { onConversationBeamEdit, onConversationsImportFromFiles } = props;
|
||||
@@ -205,7 +196,7 @@ export function Composer(props: {
|
||||
} = useAttachmentDrafts(conversationOverlayStore, enableLoadURLsInComposer, chatLLMSupportsImages, handleFilterAGIFile, showChatAttachments === 'only-images');
|
||||
|
||||
// attachments derived state
|
||||
const llmAttachmentDraftsCollection = useLLMAttachmentDrafts(attachmentDrafts, props.chatLLM, chatLLMSupportsImages);
|
||||
const { enrichment: attEnrichment, summary: attEnrichSummary } = useAttachmentDraftsEnrichment(attachmentDrafts, props.chatLLM, chatLLMSupportsImages);
|
||||
|
||||
// drag/drop
|
||||
const { dragContainerSx, dropComponent, handleContainerDragEnter, handleContainerDragStart } = useComposerDragDrop(!props.isMobile, attachAppendDataTransfer);
|
||||
@@ -230,7 +221,7 @@ export function Composer(props: {
|
||||
// tokens derived state
|
||||
|
||||
const tokensComposerTextDebounced = useTextTokenCount(composeText, props.chatLLM, 800, 1600);
|
||||
let tokensComposer = (tokensComposerTextDebounced ?? 0) + (llmAttachmentDraftsCollection.llmTokenCountApprox || 0);
|
||||
let tokensComposer = (tokensComposerTextDebounced ?? 0) + (attEnrichSummary.totalTokensApprox || 0);
|
||||
if (props.chatLLM && tokensComposer > 0)
|
||||
tokensComposer += glueForMessageTokens(props.chatLLM);
|
||||
const tokensHistory = _historyTokenCount;
|
||||
@@ -274,7 +265,7 @@ export function Composer(props: {
|
||||
// Confirmation Modals
|
||||
|
||||
const confirmProceedIfAttachmentsNotSupported = React.useCallback(async (): Promise<boolean> => {
|
||||
if (llmAttachmentDraftsCollection.canAttachAllFragments) return true;
|
||||
if (attEnrichSummary.allCompatible) return true;
|
||||
return await showPromisedOverlay('composer-unsupported-attachments', { rejectWithValue: false }, ({ onResolve, onUserReject }) => (
|
||||
<ConfirmationModal
|
||||
open
|
||||
@@ -286,7 +277,7 @@ export function Composer(props: {
|
||||
title='Attachment Compatibility Notice'
|
||||
/>
|
||||
));
|
||||
}, [llmAttachmentDraftsCollection.canAttachAllFragments, showPromisedOverlay]);
|
||||
}, [attEnrichSummary.allCompatible, showPromisedOverlay]);
|
||||
|
||||
|
||||
// Primary button
|
||||
@@ -555,16 +546,14 @@ export function Composer(props: {
|
||||
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
|
||||
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write
|
||||
touchAltEnter();
|
||||
e.stopPropagation();
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
// Ctrl (Windows) or Command (Mac) + Enter: send for beaming
|
||||
if (e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
if (await handleSendAction('beam-content', composeText)) { // 'ctrl+enter' -> beam
|
||||
touchCtrlEnter();
|
||||
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
|
||||
e.stopPropagation();
|
||||
}
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -578,7 +567,7 @@ export function Composer(props: {
|
||||
}
|
||||
}
|
||||
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchShiftEnter]);
|
||||
|
||||
|
||||
// Focus mode
|
||||
@@ -595,43 +584,19 @@ export function Composer(props: {
|
||||
const handleToggleMinimized = React.useCallback(() => setIsMinimized(hide => !hide), []);
|
||||
|
||||
|
||||
// Attachment Up
|
||||
|
||||
const handleAttachCtrlV = React.useCallback(async (event: React.ClipboardEvent) => {
|
||||
if (await attachAppendDataTransfer(event.clipboardData, 'paste', false) === 'as_files')
|
||||
event.preventDefault();
|
||||
}, [attachAppendDataTransfer]);
|
||||
|
||||
const handleAttachCameraImage = React.useCallback((file: FileWithHandle) => {
|
||||
void attachAppendFile('camera', file);
|
||||
}, [attachAppendFile]);
|
||||
|
||||
const { openCamera, cameraCaptureComponent } = useCameraCaptureModalDialog(handleAttachCameraImage);
|
||||
|
||||
const handleAttachScreenCapture = React.useCallback((file: File) => {
|
||||
void attachAppendFile('screencapture', file);
|
||||
}, [attachAppendFile]);
|
||||
|
||||
const handleAttachFiles = React.useCallback(async (files: FileWithHandle[], errorMessage: string | null) => {
|
||||
if (errorMessage)
|
||||
addSnackbar({ key: 'attach-files-open-fail', message: `Unable to open files: ${errorMessage}`, type: 'issue' });
|
||||
for (let file of files)
|
||||
await attachAppendFile('file-open', file)
|
||||
.catch((error: any) => addSnackbar({ key: 'attach-file-open-fail', message: `Unable to attach the file "${file.name}" (${error?.message || error?.toString() || 'unknown error'})`, type: 'issue' }));
|
||||
}, [attachAppendFile]);
|
||||
|
||||
const handleAttachWebLinks = React.useCallback(async (links: { url: string }[]) => {
|
||||
links.forEach(link => void attachAppendUrl('input-link', link.url));
|
||||
}, [attachAppendUrl]);
|
||||
|
||||
const { openWebInputDialog, webInputDialogComponent } = useWebInputModal(handleAttachWebLinks, composeText);
|
||||
// Attachments Up
|
||||
|
||||
const handleAttachCtrlV = useAttachHandler_PasteIntercept(attachAppendDataTransfer);
|
||||
const handleAttachFiles = useAttachHandler_Files(attachAppendFile);
|
||||
const handleOpenCamera = useAttachHandler_CameraOpen(attachAppendFile);
|
||||
const handleAttachScreenCapture = useAttachHandler_ScreenCapture(attachAppendFile);
|
||||
const { openWebInputDialog, webInputDialogComponent } = useAttachHandler_UrlWebLinks(attachAppendUrl, composeText);
|
||||
const { openGoogleDrivePicker, googleDrivePickerComponent } = useGoogleDrivePicker(attachAppendCloudFile, isMobile);
|
||||
|
||||
|
||||
// Attachments Down
|
||||
|
||||
const handleAttachmentDraftsAction = React.useCallback((attachmentDraftIdOrAll: AttachmentDraftId | null, action: LLMAttachmentDraftsAction) => {
|
||||
const handleAttachmentDraftsAction = React.useCallback((attachmentDraftIdOrAll: AttachmentDraftId | null, action: AttachmentDraftsAction) => {
|
||||
switch (action) {
|
||||
case 'copy-text':
|
||||
const copyFragments = attachmentsTakeFragmentsByType('doc', attachmentDraftIdOrAll, false);
|
||||
@@ -660,7 +625,7 @@ export function Composer(props: {
|
||||
if (supportsClipboardRead())
|
||||
composerShortcuts.push({ key: 'v', ctrl: true, shift: true, action: attachAppendClipboardItems, description: 'Attach Clipboard' });
|
||||
// Future: keep reactive state here to support Live Screen Capture and more
|
||||
// if (labsAttachScreenCapture && supportsScreenCapture)
|
||||
// if (supportsScreenCapture)
|
||||
// composerShortcuts.push({ key: 's', ctrl: true, shift: true, action: openScreenCaptureDialog, description: 'Attach Screen Capture' });
|
||||
}
|
||||
if (recognitionState.isActive) {
|
||||
@@ -693,12 +658,13 @@ export function Composer(props: {
|
||||
|
||||
const showChatInReferenceTo = !!inReferenceTo?.length;
|
||||
const showChatExtras = isText && !showChatInReferenceTo && !assistantAbortible && composerQuickButton !== 'off';
|
||||
const speechMayWork = browserSpeechRecognitionCapability().mayWork;
|
||||
|
||||
const sendButtonVariant: VariantProp = (isAppend || (isMobile && isTextBeam)) ? 'outlined' : 'solid';
|
||||
|
||||
const sendButtonColor: ColorPaletteProp =
|
||||
assistantAbortible ? 'warning'
|
||||
: !llmAttachmentDraftsCollection.canAttachAllFragments ? 'warning'
|
||||
: !attEnrichSummary.allCompatible ? 'warning'
|
||||
: chatExecuteModeSendColor;
|
||||
|
||||
const sendButtonLabel = chatExecuteModeSendLabel;
|
||||
@@ -712,7 +678,7 @@ export function Composer(props: {
|
||||
: <TelegramIcon />;
|
||||
|
||||
const beamButtonColor: ColorPaletteProp | undefined =
|
||||
!llmAttachmentDraftsCollection.canAttachAllFragments ? 'warning'
|
||||
!attEnrichSummary.allCompatible ? 'warning'
|
||||
: undefined;
|
||||
|
||||
const showTint: ColorPaletteProp | undefined = isDraw ? 'warning' : isReAct ? 'success' : undefined;
|
||||
@@ -739,10 +705,6 @@ export function Composer(props: {
|
||||
if (isDesktop && timeToShowTips && !isDraw) {
|
||||
if (explainShiftEnter)
|
||||
textPlaceholder += !enterIsNewline ? '\n\n⏎ Shift + Enter to add a new line' : '\n\n➤ Shift + Enter to send';
|
||||
// else if (explainAltEnter)
|
||||
// textPlaceholder += platformAwareKeystrokes('\n\n⭳ Tip: Alt + Enter to just append the message');
|
||||
else if (explainCtrlEnter)
|
||||
textPlaceholder += platformAwareKeystrokes('\n\n⫷ Tip: Ctrl + Enter to beam');
|
||||
}
|
||||
|
||||
const stableGridSx: SxProps = React.useMemo(() => ({
|
||||
@@ -783,42 +745,24 @@ export function Composer(props: {
|
||||
{/* [mobile] Mic button */}
|
||||
{recognitionState.isAvailable && <ButtonMicMemo variant={micVariant} color={micColor === 'danger' ? 'danger' : showTint || micColor} errorMessage={recognitionState.errorMessage} onClick={handleToggleMic} />}
|
||||
|
||||
{/* Responsive Camera OCR button */}
|
||||
{showChatAttachments && <ButtonAttachCameraMemo color={showTint} isMobile onOpenCamera={openCamera} />}
|
||||
|
||||
{/* [mobile] Attach file button (in draw with image mode) */}
|
||||
{showChatAttachments === 'only-images' && <ButtonAttachFilesMemo color={showTint} isMobile onAttachFiles={handleAttachFiles} fullWidth multiple />}
|
||||
{showChatAttachments === 'only-images' && <ButtonAttachFilesMemo color={showTint} isMobile onAttachFiles={handleAttachFiles} multiple />}
|
||||
|
||||
{/* [mobile] [+] button */}
|
||||
{/* [mobile] [+] attachment sources menu */}
|
||||
{showChatAttachments === true && (
|
||||
<Dropdown>
|
||||
<MenuButton slots={{ root: IconButton }}>
|
||||
<AddCircleOutlineIcon />
|
||||
</MenuButton>
|
||||
<Menu>
|
||||
|
||||
{/* Responsive Open Files button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachFilesMemo onAttachFiles={handleAttachFiles} fullWidth multiple />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Web button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachWebMemo disabled={!hasComposerBrowseCapability} onOpenWebInput={openWebInputDialog} />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Google Drive button */}
|
||||
{hasGoogleDriveCapability && <MenuItem>
|
||||
<ButtonAttachGoogleDriveMemo onOpenGoogleDrivePicker={openGoogleDrivePicker} fullWidth />
|
||||
</MenuItem>}
|
||||
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead() && <MenuItem>
|
||||
<ButtonAttachClipboardMemo onAttachClipboard={attachAppendClipboardItems} />
|
||||
</MenuItem>}
|
||||
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
<AttachmentSourcesMemo
|
||||
mode='menu-compact'
|
||||
canBrowse={browseCapability.mayWork}
|
||||
hasScreenCapture={supportsScreenCapture}
|
||||
hasCamera={supportsCameraCapture()}
|
||||
onlyImages={false /* because if yes, we only show the attach files above */}
|
||||
onAttachClipboard={attachAppendClipboardItems}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onAttachScreenCapture={handleAttachScreenCapture}
|
||||
onOpenCamera={handleOpenCamera}
|
||||
onOpenGoogleDrivePicker={openGoogleDrivePicker}
|
||||
onOpenWebInput={openWebInputDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* [Mobile] MultiChat button */}
|
||||
@@ -829,31 +773,27 @@ export function Composer(props: {
|
||||
|
||||
{/* [Desktop, Col1] Insert Multi-modal content buttons */}
|
||||
{isDesktop && showChatAttachments && (
|
||||
<Box sx={{ flexGrow: 0, display: 'grid', gap: (labsAttachScreenCapture && labsCameraDesktop) ? 0.5 : 1, alignSelf: 'flex-start' }}>
|
||||
<Box sx={{ flexGrow: 0, display: 'grid', gap: 0.5, alignSelf: 'flex-start' }}>
|
||||
|
||||
{/*<FormHelperText sx={{ mx: 'auto' }}>*/}
|
||||
{/* Attach*/}
|
||||
{/*</FormHelperText>*/}
|
||||
{/* [desktop] Attachment Sources: dropdown menu or inline buttons */}
|
||||
<AttachmentSourcesMemo
|
||||
mode={!labsComposerAttachmentsInline ? 'menu-rich' : 'inline-buttons'}
|
||||
color={!labsComposerAttachmentsInline ? (showTint || 'neutral') : showTint}
|
||||
richButtonStandOut={!isText && !isAppend}
|
||||
canBrowse={browseCapability.mayWork}
|
||||
hasScreenCapture={supportsScreenCapture}
|
||||
hasCamera={supportsCameraCapture()}
|
||||
onlyImages={showChatAttachments === 'only-images'}
|
||||
onAttachClipboard={attachAppendClipboardItems}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onAttachScreenCapture={handleAttachScreenCapture}
|
||||
onOpenCamera={handleOpenCamera}
|
||||
onOpenGoogleDrivePicker={openGoogleDrivePicker}
|
||||
onOpenWebInput={openWebInputDialog}
|
||||
/>
|
||||
|
||||
{/* Responsive Open Files button */}
|
||||
<ButtonAttachFilesMemo color={showTint} onAttachFiles={handleAttachFiles} fullWidth multiple />
|
||||
|
||||
{/* Responsive Web button */}
|
||||
{showChatAttachments !== 'only-images' && <ButtonAttachWebMemo color={showTint} disabled={!hasComposerBrowseCapability} onOpenWebInput={openWebInputDialog} />}
|
||||
|
||||
{/* Responsive Google Drive button */}
|
||||
{hasGoogleDriveCapability && showChatAttachments !== 'only-images' && <ButtonAttachGoogleDriveMemo color={showTint} onOpenGoogleDrivePicker={openGoogleDrivePicker} />}
|
||||
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead() && showChatAttachments !== 'only-images' && <ButtonAttachClipboardMemo color={showTint} onAttachClipboard={attachAppendClipboardItems} />}
|
||||
|
||||
{/* Responsive Screen Capture button */}
|
||||
{labsAttachScreenCapture && supportsScreenCapture && <ButtonAttachScreenCaptureMemo color={showTint} onAttachScreenCapture={handleAttachScreenCapture} />}
|
||||
|
||||
{/* Responsive Camera OCR button */}
|
||||
{labsCameraDesktop && <ButtonAttachCameraMemo color={showTint} onOpenCamera={openCamera} />}
|
||||
|
||||
</Box>)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
{/* Top: Textarea & Mic & Overlays, Bottom, Attachment Drafts */}
|
||||
@@ -921,7 +861,7 @@ export function Composer(props: {
|
||||
)}
|
||||
|
||||
{!showChatInReferenceTo && !isDraw && tokenLimit > 0 && (
|
||||
<TokenBadgeMemo hideBelowDollars={0.01} chatPricing={tokenChatPricing} direct={tokensComposer} history={tokensHistory} responseMax={tokensResponseMax} limit={tokenLimit} showCost={labsShowCost} enableHover={!isMobile} showExcess absoluteBottomRight />
|
||||
<TokenBadgeMemo showCost hideBelowDollars={0.01} chatPricing={tokenChatPricing} direct={tokensComposer} history={tokensHistory} responseMax={tokensResponseMax} limit={tokenLimit} enableHover={!isMobile} showExcess absoluteBottomRight />
|
||||
)}
|
||||
|
||||
</Box>
|
||||
@@ -1000,11 +940,12 @@ export function Composer(props: {
|
||||
|
||||
{/* Render any Attachments & menu items */}
|
||||
{!!conversationOverlayStore && showChatAttachments && (
|
||||
<LLMAttachmentsList
|
||||
agiAttachmentPrompts={agiAttachmentPrompts}
|
||||
<ComposerAttachmentDraftsList
|
||||
attachmentDraftsStoreApi={conversationOverlayStore}
|
||||
canInlineSomeFragments={llmAttachmentDraftsCollection.canInlineSomeFragments}
|
||||
llmAttachmentDrafts={llmAttachmentDraftsCollection.llmAttachmentDrafts}
|
||||
attachmentDrafts={attachmentDrafts}
|
||||
enrichment={attEnrichment}
|
||||
enrichmentSummary={attEnrichSummary}
|
||||
agiAttachmentPrompts={agiAttachmentPrompts}
|
||||
onAttachmentDraftsAction={handleAttachmentDraftsAction}
|
||||
/>
|
||||
)}
|
||||
@@ -1024,7 +965,7 @@ export function Composer(props: {
|
||||
|
||||
{/* [mobile] bottom-corner secondary button */}
|
||||
{isMobile && (showChatExtras
|
||||
? (composerQuickButton === 'call'
|
||||
? (composerQuickButton === 'call' && speechMayWork
|
||||
? <ButtonCallMemo isMobile disabled={noConversation || noLLM} onClick={handleCallClicked} />
|
||||
: <ButtonBeamMemo isMobile disabled={noConversation /*|| noLLM*/} color={beamButtonColor} hasContent={!!composeText} onClick={handleSendTextBeamClicked} />)
|
||||
: isDraw
|
||||
@@ -1115,8 +1056,8 @@ export function Composer(props: {
|
||||
{/* [desktop] secondary bottom-buttons (aligned to bottom for now, and mutually exclusive) */}
|
||||
{isDesktop && <Box sx={{ mt: 'auto', display: 'grid', gap: 1 }}>
|
||||
|
||||
{/* [desktop] Call secondary button */}
|
||||
{showChatExtras && <ButtonCallMemo disabled={noConversation || noLLM || assistantAbortible} onClick={handleCallClicked} />}
|
||||
{/* [desktop] Call secondary button - hidden when speech recognition is not available */}
|
||||
{showChatExtras && speechMayWork && <ButtonCallMemo disabled={noConversation || noLLM || assistantAbortible} onClick={handleCallClicked} />}
|
||||
|
||||
{/* [desktop] Draw Options secondary button */}
|
||||
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
|
||||
@@ -1136,9 +1077,6 @@ export function Composer(props: {
|
||||
{/* Execution Mode Menu */}
|
||||
{chatExecuteMenuComponent}
|
||||
|
||||
{/* Camera (when open) */}
|
||||
{cameraCaptureComponent}
|
||||
|
||||
{/* Google Drive Picker (when open) */}
|
||||
{googleDrivePickerComponent}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { CircularProgress, ListDivider, ListItemDecorator, MenuItem } from '@mui/joy';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
|
||||
import type { AgiAttachmentPromptsData } from '~/modules/aifn/agiattachmentprompts/useAgiAttachmentPrompts';
|
||||
|
||||
import type { AttachmentDraft, AttachmentDraftId, AttachmentDraftsAction } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts_slice';
|
||||
import type { AttachmentEnrichmentSummary, IAttachmentEnrichment } from '~/common/attachment-drafts/llm-enrichment/attachment.enrichment';
|
||||
import { AttachmentDraftsList } from '~/common/attachment-drafts/attachment-drafts-ui/AttachmentDraftsList';
|
||||
|
||||
import { LLMAttachmentsPromptsButtonMemo } from './LLMAttachmentsPromptsButton';
|
||||
import { ViewDocPartModal } from '../../message/fragments-content/ViewDocPartModal';
|
||||
import { ViewImageRefPartModal } from '../../message/fragments-content/ViewImageRefPartModal';
|
||||
|
||||
|
||||
/**
|
||||
* Composer-specific wrapper around the generic AttachmentDraftsList.
|
||||
* Provides: viewer modals, AI prompts button, "What can I do?" menu item.
|
||||
*/
|
||||
export function ComposerAttachmentDraftsList(props: {
|
||||
attachmentDrafts: AttachmentDraft[],
|
||||
attachmentDraftsStoreApi: AttachmentDraftsStoreApi,
|
||||
enrichment: IAttachmentEnrichment,
|
||||
enrichmentSummary: AttachmentEnrichmentSummary,
|
||||
agiAttachmentPrompts: AgiAttachmentPromptsData,
|
||||
onAttachmentDraftsAction: (attachmentDraftId: AttachmentDraftId | null, actionId: AttachmentDraftsAction) => void,
|
||||
}) {
|
||||
|
||||
const { agiAttachmentPrompts, attachmentDrafts } = props;
|
||||
|
||||
|
||||
// memo components
|
||||
|
||||
const startDecorator = React.useMemo(() =>
|
||||
!agiAttachmentPrompts.isVisible && !agiAttachmentPrompts.hasData ? undefined
|
||||
: <LLMAttachmentsPromptsButtonMemo data={agiAttachmentPrompts} />
|
||||
, [agiAttachmentPrompts]);
|
||||
|
||||
|
||||
// memo rendering functions
|
||||
|
||||
const renderDocViewer = React.useCallback(
|
||||
(part: React.ComponentProps<typeof ViewDocPartModal>['docPart'], onClose: () => void) =>
|
||||
<ViewDocPartModal docPart={part} onClose={onClose} />
|
||||
, []);
|
||||
|
||||
const renderImageViewer = React.useCallback(
|
||||
(part: React.ComponentProps<typeof ViewImageRefPartModal>['imageRefPart'], onClose: () => void) =>
|
||||
<ViewImageRefPartModal imageRefPart={part} onClose={onClose} />
|
||||
, []);
|
||||
|
||||
const renderOverallMenuExtra = React.useCallback(() => <>
|
||||
<MenuItem color='primary' variant='soft' onClick={agiAttachmentPrompts.refetch} disabled={!attachmentDrafts.length || agiAttachmentPrompts.isFetching}>
|
||||
<ListItemDecorator>{agiAttachmentPrompts.isFetching ? <CircularProgress size='sm' /> : <AutoFixHighIcon />}</ListItemDecorator>
|
||||
What can I do?
|
||||
</MenuItem>
|
||||
<ListDivider />
|
||||
</>, [agiAttachmentPrompts.isFetching, agiAttachmentPrompts.refetch, attachmentDrafts.length]);
|
||||
|
||||
|
||||
return (
|
||||
<AttachmentDraftsList
|
||||
attachmentDraftsStoreApi={props.attachmentDraftsStoreApi}
|
||||
attachmentDrafts={attachmentDrafts}
|
||||
enrichment={props.enrichment}
|
||||
enrichmentSummary={props.enrichmentSummary}
|
||||
onAttachmentDraftsAction={props.onAttachmentDraftsAction}
|
||||
startDecorator={startDecorator}
|
||||
renderDocViewer={renderDocViewer}
|
||||
renderImageViewer={renderImageViewer}
|
||||
renderOverallMenuExtra={renderOverallMenuExtra}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { AttachmentDraft } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { DLLM } from '~/common/stores/llms/llms.types';
|
||||
import type { DMessageAttachmentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import { estimateTokensForFragments } from '~/common/stores/chat/chat.tokens';
|
||||
|
||||
|
||||
export interface LLMAttachmentDraftsCollection {
|
||||
llmAttachmentDrafts: LLMAttachmentDraft[];
|
||||
canAttachAllFragments: boolean;
|
||||
canInlineSomeFragments: boolean;
|
||||
llmTokenCountApprox: number | null;
|
||||
hasImageFragments: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface LLMAttachmentDraft {
|
||||
attachmentDraft: AttachmentDraft;
|
||||
llmSupportsAllFragments: boolean;
|
||||
llmSupportsTextFragments: boolean;
|
||||
llmTokenCountApprox: number | null;
|
||||
hasImageFragments: boolean;
|
||||
}
|
||||
|
||||
|
||||
export function useLLMAttachmentDrafts(attachmentDrafts: AttachmentDraft[], chatLLM: DLLM | null, chatLLMSupportsImages: boolean): LLMAttachmentDraftsCollection {
|
||||
|
||||
/* [Optimization] Use a Ref to store the previous state of llmAttachmentDrafts and chatLLM
|
||||
*
|
||||
* Note that this works on 2 levels:
|
||||
* - 1. avoids recomputation, but more importantly,
|
||||
* - 2. avoids re-rendering by keeping those llmAttachmentDrafts objects stable.
|
||||
*
|
||||
* Important to notice that the attachmentDraft objects[] are stable to start with, so we can
|
||||
* safely use reference equality to check if internal properties (or order) have changed.
|
||||
*/
|
||||
const prevStateRef = React.useRef<{
|
||||
chatLLM: DLLM | null;
|
||||
llmAttachmentDrafts: LLMAttachmentDraft[];
|
||||
}>({ llmAttachmentDrafts: [], chatLLM: null });
|
||||
|
||||
return React.useMemo(() => {
|
||||
|
||||
// [Optimization]
|
||||
const equalChatLLM = chatLLM === prevStateRef.current.chatLLM;
|
||||
|
||||
// LLM-dependent multi-modal enablement
|
||||
// TODO: consider also Audio inputs, maybe PDF binary inputs
|
||||
// FIXME: reference fragments could refer to non-image as well
|
||||
const imageTypes: DMessageAttachmentFragment['part']['pt'][] = ['reference', 'image_ref'];
|
||||
const supportedTypes: DMessageAttachmentFragment['part']['pt'][] = chatLLMSupportsImages ? [...imageTypes, 'doc'] : ['doc'];
|
||||
const supportedTextTypes: DMessageAttachmentFragment['part']['pt'][] = supportedTypes.filter(pt => pt === 'doc');
|
||||
|
||||
// Add LLM-specific properties to each attachment draft
|
||||
const llmAttachmentDrafts = attachmentDrafts.map((a, index) => {
|
||||
|
||||
// [Optimization] If not change in LLM and the attachmentDraft is the same object reference, reuse the previous LLMAttachmentDraft
|
||||
let prevDraft: LLMAttachmentDraft | undefined = prevStateRef.current.llmAttachmentDrafts[index];
|
||||
// if not found, search by id
|
||||
if (!prevDraft)
|
||||
prevDraft = prevStateRef.current.llmAttachmentDrafts.find(_pd => _pd.attachmentDraft.id === a.id);
|
||||
if (equalChatLLM && prevDraft && prevDraft.attachmentDraft === a)
|
||||
return prevDraft;
|
||||
|
||||
// Otherwise, create a new LLMAttachmentDraft
|
||||
return {
|
||||
attachmentDraft: a,
|
||||
llmSupportsAllFragments: !a.outputFragments ? false : a.outputFragments.every(op => supportedTypes.includes(op.part.pt)),
|
||||
llmSupportsTextFragments: !a.outputFragments ? false : a.outputFragments.some(op => supportedTextTypes.includes(op.part.pt)),
|
||||
llmTokenCountApprox: chatLLM
|
||||
? estimateTokensForFragments(chatLLM, 'user', a.outputFragments, true, 'useLLMAttachmentDrafts')
|
||||
: null,
|
||||
hasImageFragments: !a.outputFragments ? false : a.outputFragments.some(op => imageTypes.includes(op.part.pt)),
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate the overall properties
|
||||
const canAttachAllFragments = llmAttachmentDrafts.every(a => a.llmSupportsAllFragments);
|
||||
const canInlineSomeFragments = llmAttachmentDrafts.some(a => a.llmSupportsTextFragments);
|
||||
const llmTokenCountApprox = chatLLM
|
||||
? llmAttachmentDrafts.reduce((acc, a) => acc + (a.llmTokenCountApprox || 0), 0)
|
||||
: null;
|
||||
const hasImageFragments = llmAttachmentDrafts.some(a => a.hasImageFragments);
|
||||
|
||||
// [Optimization] Update the ref with the new state
|
||||
prevStateRef.current = { llmAttachmentDrafts, chatLLM };
|
||||
|
||||
return {
|
||||
llmAttachmentDrafts,
|
||||
canAttachAllFragments,
|
||||
canInlineSomeFragments,
|
||||
llmTokenCountApprox,
|
||||
hasImageFragments,
|
||||
};
|
||||
|
||||
}, [attachmentDrafts, chatLLM, chatLLMSupportsImages]); // Dependencies for the outer useMemo
|
||||
}
|
||||
@@ -33,7 +33,10 @@ const _styles = {
|
||||
} as const,
|
||||
'& nav > ol > li:first-of-type': {
|
||||
overflow: 'hidden',
|
||||
maxWidth: { xs: '110px', md: '140px' },
|
||||
// allow the chat title to use available space, shrinking gracefully when the bar is narrow
|
||||
// NOTE: already performed by virtue of the breadcrumb having agi-ellipsize on the crumbs
|
||||
// flexShrink: 1,
|
||||
// minWidth: '60px',
|
||||
} as const,
|
||||
|
||||
} as const,
|
||||
|
||||
@@ -292,6 +292,17 @@ function ChatDrawer(props: {
|
||||
toggleFilterHasDocFragments, toggleFilterHasImageAssets, toggleFilterHasStars, toggleFilterIsArchived, toggleShowPersonaIcons, toggleShowRelativeSize,
|
||||
]);
|
||||
|
||||
const displayNavItems = React.useMemo(() => {
|
||||
if (renderLimit === Infinity || renderLimit >= renderNavItems.length) return renderNavItems;
|
||||
|
||||
// return sliced if it contains the active conversation
|
||||
const sliced = renderNavItems.slice(0, renderLimit);
|
||||
if (!props.activeConversationId || sliced.some(i => i.type === 'nav-item-chat-data' && i.conversationId === props.activeConversationId)) return sliced;
|
||||
|
||||
// include the active conversation if it's beyond the fold
|
||||
const activeItem = renderNavItems.find((i, idx) => idx >= renderLimit && i.type === 'nav-item-chat-data' && i.conversationId === props.activeConversationId);
|
||||
return activeItem ? [...sliced, activeItem] : sliced;
|
||||
}, [renderNavItems, renderLimit, props.activeConversationId]);
|
||||
|
||||
return <>
|
||||
|
||||
@@ -380,7 +391,7 @@ function ChatDrawer(props: {
|
||||
|
||||
{/* Chat Titles List (shrink as half the rate as the Folders List) */}
|
||||
<Box sx={{ flexGrow: 1, flexShrink: 1, flexBasis: '20rem', overflowY: 'auto', ...themeScalingMap[contentScaling].chatDrawerItemSx }}>
|
||||
{renderNavItems.slice(0, renderLimit).map((item, idx) => item.type === 'nav-item-chat-data' ? (
|
||||
{displayNavItems.map((item, idx) => item.type === 'nav-item-chat-data' ? (
|
||||
<ChatDrawerItemMemo
|
||||
key={'nav-chat-' + item.conversationId}
|
||||
item={item}
|
||||
|
||||
@@ -282,7 +282,7 @@ function ChatDrawerItem(props: {
|
||||
{searchFrequency > 0 ? (
|
||||
// Display search frequency if it exists and is greater than 0
|
||||
<Typography level='body-sm'>
|
||||
{searchFrequency}
|
||||
{Math.round(searchFrequency * 10) / 10}
|
||||
</Typography>
|
||||
) : (props.showSymbols && (userFlagsSummary || containsDocAttachments || containsImageAssets)) ? (
|
||||
<Box sx={{
|
||||
|
||||
@@ -13,6 +13,7 @@ import SettingsSuggestOutlinedIcon from '@mui/icons-material/SettingsSuggestOutl
|
||||
import UnarchiveOutlinedIcon from '@mui/icons-material/UnarchiveOutlined';
|
||||
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { ChromelessItemButton } from '~/common/layout/optima/ChromelessItemButton';
|
||||
import { CodiconSplitHorizontal } from '~/common/components/icons/CodiconSplitHorizontal';
|
||||
import { CodiconSplitHorizontalRemove } from '~/common/components/icons/CodiconSplitHorizontalRemove';
|
||||
import { CodiconSplitVertical } from '~/common/components/icons/CodiconSplitVertical';
|
||||
@@ -37,6 +38,7 @@ function VariformPaneFrame() {
|
||||
|
||||
|
||||
export function ChatPane(props: {
|
||||
isMobile: boolean,
|
||||
conversationId: DConversationId | null,
|
||||
disableItems: boolean,
|
||||
hasConversations: boolean,
|
||||
@@ -143,6 +145,8 @@ export function ChatPane(props: {
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{props.isMobile && <ChromelessItemButton />}
|
||||
|
||||
</OptimaPanelGroupedList>
|
||||
|
||||
{/* Chat Actions group */}
|
||||
|
||||
@@ -36,7 +36,7 @@ const optionGroupSx: SxProps = {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 0,
|
||||
};
|
||||
} as const;
|
||||
|
||||
const optionSx: SxProps = {
|
||||
// style
|
||||
@@ -52,7 +52,19 @@ const optionSx: SxProps = {
|
||||
|
||||
// layout
|
||||
justifyContent: 'flex-start',
|
||||
};
|
||||
} as const;
|
||||
|
||||
const optionBoldSx: SxProps = {
|
||||
...optionSx,
|
||||
fontWeight: 'lg',
|
||||
} as const;
|
||||
|
||||
|
||||
// '1. **text**' -> '1. text', or: **text** -> text
|
||||
function _stripMarkdownBold(text: string): { text: string; isBold: boolean } {
|
||||
const stripped = text.replace(/(\*{2,})(.+)\1\s*$/, '$2').trimEnd();
|
||||
return { text: stripped, isBold: stripped !== text };
|
||||
}
|
||||
|
||||
|
||||
export function optionsExtractFromFragments_dangerModifyFragment(enabled: boolean, fragments: InterleavedFragment[]): { fragments: InterleavedFragment[], options: string[] } {
|
||||
@@ -164,21 +176,25 @@ export function BlockOpOptions(props: {
|
||||
options: string[],
|
||||
onContinue: (continueText: null | string) => void,
|
||||
}) {
|
||||
const buttonSx = React.useMemo(() => ({ ...optionSx, fontSize: props.contentScaling }), [props.contentScaling]);
|
||||
const normalSx = React.useMemo(() => ({ ...optionSx, fontSize: props.contentScaling }), [props.contentScaling]);
|
||||
const boldSx = React.useMemo(() => ({ ...optionBoldSx, fontSize: props.contentScaling }), [props.contentScaling]);
|
||||
return (
|
||||
<Box sx={optionGroupSx}>
|
||||
{props.options.map((option, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
color={OPTION_ACTIVE_COLOR}
|
||||
variant='soft'
|
||||
size={props.contentScaling === 'md' ? 'md' : 'sm'}
|
||||
onClick={() => props.onContinue(option.endsWith('?') ? option.slice(0, -1) : option)}
|
||||
sx={buttonSx}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
{props.options.map((option, index) => {
|
||||
const { text, isBold } = _stripMarkdownBold(option);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
color={OPTION_ACTIVE_COLOR}
|
||||
variant='soft'
|
||||
size={props.contentScaling === 'md' ? 'md' : 'sm'}
|
||||
onClick={() => props.onContinue(text.endsWith('?') ? text.slice(0, -1) : text)}
|
||||
sx={isBold ? boldSx : normalSx}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Box, Button, ButtonGroup, Tooltip, Typography } from '@mui/joy';
|
||||
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
||||
import StopRoundedIcon from '@mui/icons-material/StopRounded';
|
||||
|
||||
import type { DMessageGenerator } from '~/common/stores/chat/chat.message';
|
||||
|
||||
|
||||
const ARM_TIMEOUT_MS = 4000;
|
||||
|
||||
|
||||
/**
|
||||
* FIXME: COMPLETE THIS
|
||||
*/
|
||||
export function BlockOpUpstreamResume(props: {
|
||||
upstreamHandle: Exclude<DMessageGenerator['upstreamHandle'], undefined>,
|
||||
pending?: boolean; // true while the message is actively streaming; labels the Delete button as "Stop"
|
||||
onResume?: () => void | Promise<void>;
|
||||
onCancel?: () => void | Promise<void>;
|
||||
onDelete?: () => void | Promise<void>;
|
||||
@@ -20,8 +26,14 @@ export function BlockOpUpstreamResume(props: {
|
||||
const [isResuming, setIsResuming] = React.useState(false);
|
||||
const [isCancelling, setIsCancelling] = React.useState(false);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// expiration: boolean is evaluated at render (may lag briefly if nothing re-renders past expiry).
|
||||
// TimeAgo handles its own tick for the label; the button's disabled state is the only consumer of this flag.
|
||||
const { expiresAt /*, runId = ''*/ } = props.upstreamHandle;
|
||||
// const isExpired = expiresAt != null && Date.now() > expiresAt;
|
||||
|
||||
// handlers
|
||||
|
||||
const handleResume = React.useCallback(async () => {
|
||||
@@ -50,8 +62,14 @@ export function BlockOpUpstreamResume(props: {
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
// Two-click arm: first click arms (visible red "Confirm?"), second click (within ARM_TIMEOUT_MS) executes.
|
||||
const handleDelete = React.useCallback(async () => {
|
||||
if (!props.onDelete) return;
|
||||
if (!deleteArmed) {
|
||||
setDeleteArmed(true);
|
||||
return;
|
||||
}
|
||||
setDeleteArmed(false);
|
||||
setError(null);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
@@ -61,7 +79,15 @@ export function BlockOpUpstreamResume(props: {
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [props]);
|
||||
}, [deleteArmed, props]);
|
||||
|
||||
// Auto-disarm after ARM_TIMEOUT_MS so the armed state can't leak into a later session
|
||||
React.useEffect(() => {
|
||||
if (!deleteArmed) return;
|
||||
const t = setTimeout(() => setDeleteArmed(false), ARM_TIMEOUT_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [deleteArmed]);
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -79,7 +105,7 @@ export function BlockOpUpstreamResume(props: {
|
||||
<Button
|
||||
disabled={isResuming || isCancelling || isDeleting}
|
||||
loading={isResuming}
|
||||
startDecorator={<PlayArrowRoundedIcon sx={{ color: 'success.solidBg' }} />}
|
||||
startDecorator={<PlayArrowRoundedIcon color='success' />}
|
||||
onClick={handleResume}
|
||||
>
|
||||
Resume
|
||||
@@ -101,14 +127,16 @@ export function BlockOpUpstreamResume(props: {
|
||||
)}
|
||||
|
||||
{props.onDelete && (
|
||||
<Tooltip title='Delete the stored response'>
|
||||
<Tooltip title={deleteArmed ? 'Click again to confirm - cancels the run upstream (no resume after)' : (props.pending ? 'Stop this response and cancel the upstream run' : 'Cancel the upstream run')}>
|
||||
<Button
|
||||
loading={isDeleting}
|
||||
// startDecorator={<DeleteIcon />}
|
||||
color={deleteArmed ? 'danger' : 'neutral'}
|
||||
variant={deleteArmed ? 'solid' : 'outlined'}
|
||||
startDecorator={<StopRoundedIcon />}
|
||||
onClick={handleDelete}
|
||||
disabled={isResuming || isCancelling || isDeleting}
|
||||
>
|
||||
Delete
|
||||
{deleteArmed ? 'Confirm?' : (props.pending ? 'Stop' : 'Cancel')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -120,9 +148,11 @@ export function BlockOpUpstreamResume(props: {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography level='body-xs' sx={{ fontSize: '0.65rem', opacity: 0.6 }}>
|
||||
Response ID: {props.upstreamHandle.responseId.slice(0, 12)}...
|
||||
</Typography>
|
||||
{!!expiresAt && <Typography level='body-xs' sx={{ fontSize: '0.65rem', opacity: 0.6 }}>
|
||||
{/*Run ID: {runId.slice(0, 12)}...*/}
|
||||
{/*{!!expiresAt && <> · Expires <TimeAgo date={expiresAt} /></>}*/}
|
||||
Expires <TimeAgo date={expiresAt} />
|
||||
</Typography>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import TimeAgo from 'react-timeago';
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, ButtonGroup, CircularProgress, Divider, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
|
||||
import { ClickAwayListener, Popper } from '@mui/base';
|
||||
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
|
||||
import AlternateEmailIcon from '@mui/icons-material/AlternateEmail';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
@@ -17,7 +15,7 @@ import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import FormatBoldIcon from '@mui/icons-material/FormatBold';
|
||||
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
|
||||
import InsertLinkIcon from '@mui/icons-material/InsertLink';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||
import NotificationsOutlinedIcon from '@mui/icons-material/NotificationsOutlined';
|
||||
@@ -36,11 +34,13 @@ import { ModelVendorAnthropic } from '~/modules/llms/vendors/anthropic/anthropic
|
||||
import { AnthropicIcon } from '~/common/components/icons/vendors/AnthropicIcon';
|
||||
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { CloseablePopup } from '~/common/components/CloseablePopup';
|
||||
import { DMessage, DMessageId, DMessageUserFlag, DMetaReferenceItem, MESSAGE_FLAG_AIX_SKIP, MESSAGE_FLAG_NOTIFY_COMPLETE, MESSAGE_FLAG_STARRED, MESSAGE_FLAG_VND_ANT_CACHE_AUTO, MESSAGE_FLAG_VND_ANT_CACHE_USER, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { DMessage, DMessageGenerator, DMessageId, DMessageUserFlag, DMetaReferenceItem, MESSAGE_FLAG_AIX_SKIP, MESSAGE_FLAG_NOTIFY_COMPLETE, MESSAGE_FLAG_STARRED, MESSAGE_FLAG_VND_ANT_CACHE_AUTO, MESSAGE_FLAG_VND_ANT_CACHE_USER, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { MarkHighlightIcon } from '~/common/components/icons/MarkHighlightIcon';
|
||||
import { PhTreeStructure } from '~/common/components/icons/phosphor/PhTreeStructure';
|
||||
import { PhVoice } from '~/common/components/icons/phosphor/PhVoice';
|
||||
import { Release } from '~/common/app.release';
|
||||
import { StarredState } from '~/common/components/StarIcons';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
import { adjustContentScaling, themeScalingMap, themeZIndexChatBubble } from '~/common/app.theme';
|
||||
import { avatarIconSx, makeMessageAvatarIcon, messageBackground, useMessageAvatarLabel } from '~/common/util/dMessageUtils';
|
||||
@@ -48,11 +48,12 @@ import { clipboardCopyDOMSelectionOrFallback, copyToClipboard } from '~/common/u
|
||||
import { createTextContentFragment, DMessageFragment, DMessageFragmentId, updateFragmentWithEditedText } from '~/common/stores/chat/chat.fragments';
|
||||
import { useFragmentBuckets } from '~/common/stores/chat/hooks/useFragmentBuckets';
|
||||
import { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
import { useUXLabsStore } from '~/common/stores/store-ux-labs';
|
||||
|
||||
import { BlockOpContinue } from './BlockOpContinue';
|
||||
import { BlockOpOptions, optionsExtractFromFragments_dangerModifyFragment } from './BlockOpOptions';
|
||||
import { BlockOpUpstreamResume } from './BlockOpUpstreamResume';
|
||||
import { ChatMessageEditAttachments, type EditModeAttachmentsHandle } from './ChatMessageEditAttachments';
|
||||
import { ChatMessageInfoPopup } from './ChatMessageInfoPopup';
|
||||
import { ContentFragments } from './fragments-content/ContentFragments';
|
||||
import { DocumentAttachmentFragments } from './fragments-attachment-doc/DocumentAttachmentFragments';
|
||||
import { ImageAttachmentFragments } from './fragments-attachment-image/ImageAttachmentFragments';
|
||||
@@ -160,6 +161,8 @@ export function ChatMessage(props: {
|
||||
onMessageBeam?: (messageId: string) => Promise<void>,
|
||||
onMessageBranch?: (messageId: string) => void,
|
||||
onMessageContinue?: (messageId: string, continueText: null | string) => void,
|
||||
onMessageUpstreamResume?: (generator: DMessageGenerator, messageId: string) => Promise<void>,
|
||||
onMessageUpstreamDelete?: (generator: DMessageGenerator, messageId: string) => Promise<void>,
|
||||
onMessageDelete?: (messageId: string) => void,
|
||||
onMessageFragmentAppend?: (messageId: DMessageId, fragment: DMessageFragment) => void
|
||||
onMessageFragmentDelete?: (messageId: DMessageId, fragmentId: DMessageFragmentId) => void,
|
||||
@@ -180,6 +183,8 @@ export function ChatMessage(props: {
|
||||
const [contextMenuAnchor, setContextMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [opsMenuAnchor, setOpsMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [textContentEditState, setTextContentEditState] = React.useState<ChatMessageTextPartEditState | null>(null);
|
||||
const [showInfoModal, setShowInfoModal] = React.useState(false);
|
||||
const attachmentsEditRef = React.useRef<EditModeAttachmentsHandle>(null);
|
||||
|
||||
// external state
|
||||
const { adjContentScaling, disableMarkdown, doubleClickToEdit, uiComplexityMode } = useUIPreferencesStore(useShallow(state => ({
|
||||
@@ -188,7 +193,6 @@ export function ChatMessage(props: {
|
||||
doubleClickToEdit: state.doubleClickToEdit,
|
||||
uiComplexityMode: state.complexityMode,
|
||||
})));
|
||||
const labsEnhanceCodeBlocks = useUXLabsStore(state => state.labsEnhanceCodeBlocks);
|
||||
const [showDiff, setShowDiff] = useChatShowTextDiff();
|
||||
|
||||
|
||||
@@ -243,7 +247,7 @@ export function ChatMessage(props: {
|
||||
// const wordsDiff = useWordsDifference(textSubject, props.diffPreviousText, showDiff);
|
||||
|
||||
|
||||
const { onMessageAssistantFrom, onMessageDelete, onMessageFragmentAppend, onMessageFragmentDelete, onMessageFragmentReplace, onMessageContinue } = props;
|
||||
const { onMessageAssistantFrom, onMessageDelete, onMessageFragmentAppend, onMessageFragmentDelete, onMessageFragmentReplace, onMessageContinue, onMessageUpstreamResume, onMessageUpstreamDelete } = props;
|
||||
|
||||
const handleFragmentNew = React.useCallback(() => {
|
||||
onMessageFragmentAppend?.(messageId, createTextContentFragment(''));
|
||||
@@ -261,6 +265,16 @@ export function ChatMessage(props: {
|
||||
onMessageContinue?.(messageId, continueText);
|
||||
}, [messageId, onMessageContinue]);
|
||||
|
||||
const handleUpstreamResume = React.useCallback(() => {
|
||||
if (!messageGenerator) return;
|
||||
return onMessageUpstreamResume?.(messageGenerator, messageId);
|
||||
}, [messageGenerator, messageId, onMessageUpstreamResume]);
|
||||
|
||||
const handleUpstreamDelete = React.useCallback(() => {
|
||||
if (!messageGenerator) return;
|
||||
return onMessageUpstreamDelete?.(messageGenerator, messageId);
|
||||
}, [messageGenerator, messageId, onMessageUpstreamDelete]);
|
||||
|
||||
|
||||
// Text Editing
|
||||
|
||||
@@ -280,14 +294,25 @@ export function ChatMessage(props: {
|
||||
}, [handleFragmentDelete, handleFragmentReplace, messageFragments]);
|
||||
|
||||
const handleApplyAllEdits = React.useCallback(async (withControl: boolean) => {
|
||||
const state = textContentEditState || {};
|
||||
// 0. take state, including new attachment drafts BEFORE clearing state
|
||||
const fragmentsEdits = textContentEditState || {};
|
||||
const newFragments = await attachmentsEditRef.current?.takeAllFragments() ?? [];
|
||||
|
||||
// 1. clear edit state (unmounts EditModeAttachments, triggers cleanup)
|
||||
setTextContentEditState(null);
|
||||
for (const [fragmentId, editedText] of Object.entries(state))
|
||||
|
||||
// 2A. apply text fragment edits
|
||||
for (const [fragmentId, editedText] of Object.entries(fragmentsEdits))
|
||||
handleApplyEdit(fragmentId, editedText);
|
||||
// if the user pressed Ctrl, we begin a regeneration from here
|
||||
|
||||
// 2B. append new attachment fragments
|
||||
for (const fragment of newFragments)
|
||||
onMessageFragmentAppend?.(messageId, fragment);
|
||||
|
||||
// 3. if the user pressed Ctrl, we begin a regeneration from here
|
||||
if (withControl && onMessageAssistantFrom)
|
||||
await onMessageAssistantFrom(messageId, 0);
|
||||
}, [handleApplyEdit, messageId, onMessageAssistantFrom, textContentEditState]);
|
||||
}, [handleApplyEdit, messageId, onMessageAssistantFrom, onMessageFragmentAppend, textContentEditState]);
|
||||
|
||||
const handleEditsApplyClicked = React.useCallback(() => handleApplyAllEdits(false), [handleApplyAllEdits]);
|
||||
|
||||
@@ -348,6 +373,13 @@ export function ChatMessage(props: {
|
||||
onMessageToggleUserFlag?.(messageId, MESSAGE_FLAG_STARRED);
|
||||
}, [messageId, onMessageToggleUserFlag]);
|
||||
|
||||
const handleOpsShowInfo = React.useCallback(() => {
|
||||
setOpsMenuAnchor(null);
|
||||
setShowInfoModal(true);
|
||||
}, []);
|
||||
|
||||
const handleInfoClose = React.useCallback(() => setShowInfoModal(false), []);
|
||||
|
||||
const handleOpsToggleNotifyComplete = React.useCallback(() => {
|
||||
// also remember the preference, for auto-setting flags by the persona
|
||||
setIsNotificationEnabledForModel(messageId, !isUserNotifyComplete);
|
||||
@@ -808,7 +840,6 @@ export function ChatMessage(props: {
|
||||
optiAllowSubBlocksMemo={!!messagePendingIncomplete}
|
||||
disableMarkdownText={disableMarkdown || fromUser /* User messages are edited as text. Try to have them in plain text. NOTE: This may bite. */}
|
||||
showUnsafeHtmlCode={props.showUnsafeHtmlCode}
|
||||
enhanceCodeBlocks={labsEnhanceCodeBlocks}
|
||||
|
||||
textEditsState={textContentEditState}
|
||||
setEditedText={(!props.onMessageFragmentReplace || messagePendingIncomplete) ? undefined : handleEditSetText}
|
||||
@@ -839,6 +870,14 @@ export function ChatMessage(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* [Edit Mode] Add new attachments (right below the Document Fragments) */}
|
||||
{isEditingText && !fromAssistant && !!onMessageFragmentAppend && (
|
||||
<ChatMessageEditAttachments
|
||||
ref={attachmentsEditRef}
|
||||
isMobile={props.isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* [SYSTEM, REAL] Image Attachment Fragments - just for a realistic display below the system instruction text/docs */}
|
||||
{fromSystem && imageAttachments.length >= 1 && (
|
||||
<ImageAttachmentFragments
|
||||
@@ -859,13 +898,13 @@ export function ChatMessage(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upstream Resume... */}
|
||||
{props.isBottom && fromAssistant && lastFragmentIsError && messageGenerator?.upstreamHandle?.responseId && (
|
||||
{/* Upstream Resume - shows whenever there's a stored handle (incl. post-reload, and while streaming so Stop can cancel the upstream run) */}
|
||||
{props.isBottom && fromAssistant && messageGenerator?.upstreamHandle && (!!onMessageUpstreamResume || !!onMessageUpstreamDelete) && (
|
||||
<BlockOpUpstreamResume
|
||||
upstreamHandle={messageGenerator.upstreamHandle}
|
||||
onResume={console.error}
|
||||
onCancel={console.error}
|
||||
onDelete={console.error}
|
||||
pending={messagePendingIncomplete}
|
||||
onResume={(!messagePendingIncomplete && onMessageUpstreamResume) ? handleUpstreamResume : undefined}
|
||||
onDelete={onMessageUpstreamDelete ? handleUpstreamDelete : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -878,6 +917,13 @@ export function ChatMessage(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Char & Word count */}
|
||||
{/*{!zenMode && !isEditingText && !messagePendingIncomplete && fragmentFlattenedText.length > 0 && (*/}
|
||||
{/* <Typography level='body-xs' sx={{ mx: 1.5, mt: 0.5, textAlign: fromAssistant ? 'left' : 'right', opacity: 0.5 }}>*/}
|
||||
{/* {fragmentFlattenedText.length.toLocaleString()} chars · {(fragmentFlattenedText.match(/\S+/g) || []).length.toLocaleString()} words*/}
|
||||
{/* </Typography>*/}
|
||||
{/*)}*/}
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -947,18 +993,15 @@ export function ChatMessage(props: {
|
||||
{/* Starred */}
|
||||
{!!onMessageToggleUserFlag && (
|
||||
<MenuItem onClick={handleOpsToggleStarred} sx={{ flexGrow: 0, px: 1 }}>
|
||||
<Tooltip disableInteractive title={!isUserStarred ? 'Link message - use @ to refer to it from another chat' : 'Remove link'}>
|
||||
{isUserStarred
|
||||
? <AlternateEmailIcon color='primary' sx={{ fontSize: 'xl' }} />
|
||||
: <InsertLinkIcon sx={{ rotate: '45deg' }} />
|
||||
}
|
||||
{/*{isUserStarred*/}
|
||||
{/* ? <StarRoundedIcon color='primary' sx={{ fontSize: 'xl2' }} />*/}
|
||||
{/* : <StarOutlineRoundedIcon sx={{ fontSize: 'xl2' }} />*/}
|
||||
{/*}*/}
|
||||
<Tooltip disableInteractive title={!isUserStarred ? 'Star message - use @ to refer to it from another chat' : 'Remove star'}>
|
||||
<StarredState isStarred={isUserStarred} />
|
||||
</Tooltip>
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* Info */}
|
||||
<MenuItem onClick={handleOpsShowInfo} sx={{ flexGrow: 0, px: 1 }}>
|
||||
<InfoOutlinedIcon sx={{ fontSize: 'xl' }} />
|
||||
</MenuItem>
|
||||
</Box>
|
||||
|
||||
{/* Notify Complete */}
|
||||
@@ -1021,7 +1064,7 @@ export function ChatMessage(props: {
|
||||
{!!props.onTextDiagram && <ListDivider />}
|
||||
{!!props.onTextDiagram && (
|
||||
<MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
|
||||
<ListItemDecorator><AccountTreeOutlinedIcon /></ListItemDecorator>
|
||||
<ListItemDecorator><PhTreeStructure /></ListItemDecorator>
|
||||
Auto-Diagram ...
|
||||
</MenuItem>
|
||||
)}
|
||||
@@ -1151,7 +1194,7 @@ export function ChatMessage(props: {
|
||||
{/* Intelligent functions */}
|
||||
{!!props.onTextDiagram && <Tooltip disableInteractive arrow placement='top' title={couldDiagram ? 'Auto-Diagram...' : 'Too short to Auto-Diagram'}>
|
||||
<IconButton color='success' onClick={couldDiagram ? handleOpsDiagram : undefined}>
|
||||
<AccountTreeOutlinedIcon sx={{ color: couldDiagram ? 'primary' : 'neutral.plainDisabledColor' }} />
|
||||
<PhTreeStructure sx={{ color: couldDiagram ? 'primary' : 'neutral.plainDisabledColor' }} />
|
||||
</IconButton>
|
||||
</Tooltip>}
|
||||
{!!props.onTextImagine && <Tooltip disableInteractive arrow placement='top' title='Auto-Draw'>
|
||||
@@ -1173,6 +1216,14 @@ export function ChatMessage(props: {
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Selection char & word count */}
|
||||
{!!selText && <Divider />}
|
||||
{!!selText && (
|
||||
<Typography level='body-xs' sx={{ px: 1, whiteSpace: 'nowrap' }}>
|
||||
{selText.length.toLocaleString()}c · {(selText.match(/\S+/g) || []).length.toLocaleString()}w
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
</ButtonGroup>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
@@ -1193,7 +1244,7 @@ export function ChatMessage(props: {
|
||||
</MenuItem>
|
||||
{!!props.onTextDiagram && <ListDivider />}
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
|
||||
<ListItemDecorator><AccountTreeOutlinedIcon /></ListItemDecorator>
|
||||
<ListItemDecorator><PhTreeStructure /></ListItemDecorator>
|
||||
Auto-Diagram ...
|
||||
</MenuItem>}
|
||||
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
@@ -1207,6 +1258,16 @@ export function ChatMessage(props: {
|
||||
</CloseablePopup>
|
||||
)}
|
||||
|
||||
|
||||
{/* Message Info Modal */}
|
||||
{showInfoModal && (
|
||||
<ChatMessageInfoPopup
|
||||
open
|
||||
onClose={handleInfoClose}
|
||||
message={props.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Sheet } from '@mui/joy';
|
||||
|
||||
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts_slice';
|
||||
import type { DMessageAttachmentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import { AttachmentDraftsList } from '~/common/attachment-drafts/attachment-drafts-ui/AttachmentDraftsList';
|
||||
import { AttachmentSourcesMemo } from '~/common/attachment-drafts/attachment-sources/AttachmentSources';
|
||||
import { useAttachHandler_CameraOpen, useAttachHandler_Files, useAttachHandler_ScreenCapture, useAttachHandler_UrlWebLinks } from '~/common/attachment-drafts/attachment-sources/useAttachmentSourceHandlers';
|
||||
import { createAttachmentDraftsVanillaStore } from '~/common/attachment-drafts/store-attachment-drafts_vanilla';
|
||||
import { supportsCameraCapture } from '~/common/components/camera/useCameraCapture';
|
||||
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
|
||||
import { useAttachmentDrafts } from '~/common/attachment-drafts/useAttachmentDrafts';
|
||||
import { useGoogleDrivePicker } from '~/common/attachment-drafts/attachment-sources/useGoogleDrivePicker';
|
||||
|
||||
import { ViewDocPartModal } from './fragments-content/ViewDocPartModal';
|
||||
import { ViewImageRefPartModal } from './fragments-content/ViewImageRefPartModal';
|
||||
|
||||
|
||||
/**
|
||||
* Imperative interface used outside
|
||||
*/
|
||||
export interface EditModeAttachmentsHandle {
|
||||
takeAllFragments: () => Promise<DMessageAttachmentFragment[]>;
|
||||
}
|
||||
|
||||
|
||||
const _styles = {
|
||||
box: {
|
||||
overflow: 'hidden',
|
||||
p: 0.5,
|
||||
|
||||
// looks - exactly from BoxTextArea - the Text editor
|
||||
boxShadow: 'inset 1px 0px 3px -2px var(--joy-palette-warning-softColor)',
|
||||
outline: '1px solid',
|
||||
outlineColor: 'var(--joy-palette-warning-solidBg)',
|
||||
borderRadius: 'sm',
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
|
||||
// shade to the buttons inside this > div > div > button
|
||||
'& > div > div > button': {
|
||||
// backgroundColor: 'warning.softActiveBg',
|
||||
borderColor: 'warning.outlinedBorder',
|
||||
borderRadius: 'sm',
|
||||
boxShadow: 'sm',
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, SxProps>;
|
||||
|
||||
|
||||
/**
|
||||
* Encapsulates all attachment wiring for ChatMessage edit mode.
|
||||
* Owns a standalone attachment drafts store (one per edit session).
|
||||
* Exposes an imperative handle for the parent to "take" fragments on save.
|
||||
*/
|
||||
export const ChatMessageEditAttachments = React.forwardRef<EditModeAttachmentsHandle, { isMobile: boolean }>(
|
||||
function EditModeAttachments(props, ref) {
|
||||
|
||||
// state
|
||||
const storeApiRef = React.useRef<AttachmentDraftsStoreApi | null>(null);
|
||||
if (!storeApiRef.current) storeApiRef.current = createAttachmentDraftsVanillaStore(); // created only on mount
|
||||
|
||||
// external state
|
||||
const {
|
||||
attachmentDrafts,
|
||||
attachAppendClipboardItems, attachAppendCloudFile, attachAppendFile, attachAppendUrl, // attachAppendDataTransfer
|
||||
attachmentsTakeAllFragments,
|
||||
} = useAttachmentDrafts(storeApiRef.current, false, false, undefined, false);
|
||||
const browseCapability = useBrowseCapability();
|
||||
|
||||
|
||||
// imperative handle for parent to take fragments on save
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
takeAllFragments: () => attachmentsTakeAllFragments('global', 'app-chat'),
|
||||
}), [attachmentsTakeAllFragments]);
|
||||
|
||||
|
||||
// [effect] cleanup on unmount - remove all drafts (deleted their DBlob assets, except for 'taken' ones)
|
||||
React.useEffect(() => {
|
||||
const store = storeApiRef.current;
|
||||
return () => {
|
||||
store?.getState().removeAllAttachmentDrafts();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
// handlers - composed from shared attachment source hooks
|
||||
|
||||
const handleAttachFiles = useAttachHandler_Files(attachAppendFile);
|
||||
const handleOpenCamera = useAttachHandler_CameraOpen(attachAppendFile);
|
||||
const handleAttachScreenCapture = useAttachHandler_ScreenCapture(attachAppendFile);
|
||||
const { openWebInputDialog, webInputDialogComponent } = useAttachHandler_UrlWebLinks(attachAppendUrl);
|
||||
const { openGoogleDrivePicker, googleDrivePickerComponent } = useGoogleDrivePicker(attachAppendCloudFile, props.isMobile);
|
||||
|
||||
// viewer render props - same pattern as ComposerAttachmentDraftsList.tsx:44-52
|
||||
const renderDocViewer = React.useCallback(
|
||||
(part: React.ComponentProps<typeof ViewDocPartModal>['docPart'], onClose: () => void) =>
|
||||
<ViewDocPartModal docPart={part} onClose={onClose} />,
|
||||
[],
|
||||
);
|
||||
|
||||
const renderImageViewer = React.useCallback(
|
||||
(part: React.ComponentProps<typeof ViewImageRefPartModal>['imageRefPart'], onClose: () => void) =>
|
||||
<ViewImageRefPartModal imageRefPart={part} onClose={onClose} />,
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
<Sheet color='warning' variant='soft' sx={_styles.box}>
|
||||
|
||||
{/* [+] Attachment Sources menu */}
|
||||
<AttachmentSourcesMemo
|
||||
mode='menu-message'
|
||||
canBrowse={browseCapability.mayWork}
|
||||
hasScreenCapture={supportsScreenCapture}
|
||||
hasCamera={supportsCameraCapture()}
|
||||
// onlyImages={showAttachOnlyImages}
|
||||
onAttachClipboard={attachAppendClipboardItems}
|
||||
onAttachFiles={handleAttachFiles}
|
||||
onAttachScreenCapture={handleAttachScreenCapture}
|
||||
onOpenCamera={handleOpenCamera}
|
||||
onOpenGoogleDrivePicker={openGoogleDrivePicker}
|
||||
onOpenWebInput={openWebInputDialog}
|
||||
/>
|
||||
|
||||
{/* Attachment Drafts list */}
|
||||
{attachmentDrafts.length > 0 ? (
|
||||
<AttachmentDraftsList
|
||||
attachmentDraftsStoreApi={storeApiRef.current!}
|
||||
attachmentDrafts={attachmentDrafts}
|
||||
buttonsCanWrap
|
||||
renderDocViewer={renderDocViewer}
|
||||
renderImageViewer={renderImageViewer}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
</Sheet>
|
||||
|
||||
{/* Modal portals */}
|
||||
{webInputDialogComponent}
|
||||
{googleDrivePickerComponent}
|
||||
|
||||
</>;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as React from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { llmsGetVendorIcon } from '~/modules/llms/components/LLMVendorIcon';
|
||||
|
||||
import type { DMessage } from '~/common/stores/chat/chat.message';
|
||||
import type { Immutable } from '~/common/types/immutable.types';
|
||||
import { GoodModal } from '~/common/components/modals/GoodModal';
|
||||
import { tooltipMetricsGridSx, prettyMessageMetrics, prettyShortChatModelName, prettyTokenStopReason } from '~/common/util/dMessageUtils';
|
||||
|
||||
|
||||
const contentSx: SxProps = {
|
||||
fontSize: 'sm',
|
||||
display: 'grid',
|
||||
gap: 1.5,
|
||||
};
|
||||
|
||||
const vendorIconContainerSx: SxProps = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
};
|
||||
|
||||
const timestampSx: SxProps = {
|
||||
fontSize: 'xs',
|
||||
color: 'text.tertiary',
|
||||
};
|
||||
|
||||
|
||||
export function ChatMessageInfoPopup(props: {
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
message: Immutable<DMessage>,
|
||||
}) {
|
||||
|
||||
const { message } = props;
|
||||
const { generator, created, updated, tokenCount, role } = message;
|
||||
|
||||
const isAix = generator?.mgt === 'aix';
|
||||
const vendorId = isAix ? generator.aix?.vId ?? null : null;
|
||||
const VendorIcon = vendorId ? llmsGetVendorIcon(vendorId) : null;
|
||||
const metrics = generator?.metrics ? prettyMessageMetrics(generator.metrics, 'extra') : null;
|
||||
const stopReason = generator?.tokenStopReason ? prettyTokenStopReason(generator.tokenStopReason, 'extra') : null;
|
||||
|
||||
return (
|
||||
<GoodModal
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
title='Message Info'
|
||||
hideBottomClose
|
||||
sx={{ minWidth: { xs: 300, sm: 400 }, maxWidth: 480 }}
|
||||
>
|
||||
<Box sx={contentSx}>
|
||||
|
||||
{/* Model / Generator */}
|
||||
{generator && (
|
||||
<Box sx={tooltipMetricsGridSx}>
|
||||
<div>Model:</div>
|
||||
<div>
|
||||
{VendorIcon
|
||||
? <Box sx={vendorIconContainerSx}><VendorIcon />{prettyShortChatModelName(generator.name)}</Box>
|
||||
: prettyShortChatModelName(generator.name)}
|
||||
</div>
|
||||
{isAix && generator.aix?.mId && <>
|
||||
<div>ID:</div>
|
||||
<div style={{ opacity: 0.75 }}>{generator.aix.mId}</div>
|
||||
</>}
|
||||
{generator.providerInfraLabel && <>
|
||||
<div>Provider:</div>
|
||||
<div>{generator.providerInfraLabel}</div>
|
||||
</>}
|
||||
{stopReason && <>
|
||||
<div>Status:</div>
|
||||
<div>{stopReason}</div>
|
||||
</>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Metrics (tokens, speed, cost, time) */}
|
||||
{metrics}
|
||||
|
||||
{/* Message metadata */}
|
||||
<Box sx={tooltipMetricsGridSx}>
|
||||
<div>Role:</div>
|
||||
<div>{role}</div>
|
||||
{tokenCount > 0 && <>
|
||||
<div>Tokens:</div>
|
||||
<div>{tokenCount.toLocaleString()} (visible text ~approx)</div>
|
||||
</>}
|
||||
</Box>
|
||||
|
||||
{/* Timestamps */}
|
||||
<Box sx={timestampSx}>
|
||||
{!!created && <div>Created <TimeAgo date={created} /> - {new Date(created).toLocaleString()}</div>}
|
||||
{!!updated && <div>Updated <TimeAgo date={updated} /> - {new Date(updated).toLocaleString()}</div>}
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
</GoodModal>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import ErrorIcon from '@mui/icons-material/ErrorRounded';
|
||||
import ImageIcon from '@mui/icons-material/ImageRounded';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFieldsRounded';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
|
||||
import { DMessage, MESSAGE_FLAG_AIX_SKIP, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { DMessageAttachmentFragment, DMessageFragment, isAttachmentFragment, isContentFragment, isImageRefPart, isZyncAssetImageReferencePart } from '~/common/stores/chat/chat.fragments';
|
||||
import { PhImageSquare } from '~/common/components/icons/phosphor/PhImageSquare';
|
||||
import { makeMessageAvatarIcon, messageBackground } from '~/common/util/dMessageUtils';
|
||||
|
||||
import { TokenBadgeMemo } from '../composer/tokens/TokenBadge';
|
||||
@@ -273,7 +273,7 @@ export function CleanerMessage(props: { message: DMessage, selected: boolean, re
|
||||
</Chip>
|
||||
)}
|
||||
{analysis.imageCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='success' startDecorator={<ImageIcon />} sx={{ px: 1 }}>
|
||||
<Chip size='sm' variant='solid' color='success' startDecorator={<PhImageSquare />} sx={{ px: 1 }}>
|
||||
{analysis.imageCount} image{analysis.imageCount > 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
+3
-3
@@ -5,7 +5,6 @@ import { Box, Button, ColorPaletteProp } from '@mui/joy';
|
||||
import AbcIcon from '@mui/icons-material/Abc';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import TextureIcon from '@mui/icons-material/Texture';
|
||||
@@ -13,6 +12,7 @@ import TextureIcon from '@mui/icons-material/Texture';
|
||||
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { DMessageAttachmentFragment, DMessageFragmentId, DVMimeType, isDocPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { LiveFileIcon } from '~/common/livefile/liveFile.icons';
|
||||
import { PhImageSquare } from '~/common/components/icons/phosphor/PhImageSquare';
|
||||
import { PhVoice } from '~/common/components/icons/phosphor/PhVoice';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
import { ellipsizeMiddle } from '~/common/util/textUtils';
|
||||
@@ -48,7 +48,7 @@ export function buttonIconForFragment(part: DMessageAttachmentFragment['part']):
|
||||
const assetType = part.assetType;
|
||||
switch (assetType) {
|
||||
case 'image':
|
||||
return ImageOutlinedIcon;
|
||||
return PhImageSquare;
|
||||
case 'audio':
|
||||
return PhVoice;
|
||||
default:
|
||||
@@ -93,7 +93,7 @@ export function buttonIconForFragment(part: DMessageAttachmentFragment['part']):
|
||||
|
||||
// [OLD-style] Image Attachment Fragment
|
||||
case 'image_ref':
|
||||
return ImageOutlinedIcon;
|
||||
return PhImageSquare;
|
||||
|
||||
case '_pt_sentinel':
|
||||
return TextureIcon; // nothing to do here - this is a sentinel type
|
||||
|
||||
@@ -21,11 +21,15 @@ export function BlockPartError(props: {
|
||||
// special error presentation, based on hints
|
||||
switch (props.errorHint) {
|
||||
case 'aix-net-disconnected':
|
||||
// determine the 2 'kinds' of disconnection errors in aix.client.ts
|
||||
// determine the 'kinds' of disconnection errors in aix.client.ts
|
||||
// - 'network error' (browser) -> client side
|
||||
// - 'connection terminated' (tRPC 'Stream closed' wrapper) -> server/edge side (CSF recovery)
|
||||
// - 'upstream dropped' (undici TypeError 'terminated') -> upstream provider socket drop (CSF recovery applies)
|
||||
const kind =
|
||||
props.errorText.includes('**network error**') ? 'net-client-closed'
|
||||
: props.errorText.includes('**connection terminated**') ? 'net-server-closed'
|
||||
: 'net-unknown-closed';
|
||||
: props.errorText.includes('**upstream dropped**') ? 'net-server-closed'
|
||||
: 'net-unknown-closed';
|
||||
|
||||
// For client-side error, we don't show the _NetDisconnected component
|
||||
if (kind === 'net-client-closed')
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ export function BlockPartError_RequestExceeded(props: {
|
||||
Request Too Large
|
||||
</Box>
|
||||
<div>
|
||||
Your message or attachments exceed the limit of the Vercel edge network
|
||||
Your message or attachments exceed the limit
|
||||
of the Vercel edge network
|
||||
{/* Note: Assumption here - since explaing to any 413, it could be any network */}
|
||||
</div>
|
||||
|
||||
{/* Recovery options */}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import * as React from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Box, Checkbox, CircularProgress, Dropdown, IconButton, ListDivider, ListItemDecorator, Menu, MenuButton, MenuItem, Sheet, Typography } from '@mui/joy';
|
||||
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import type { AnthropicAccessSchema } from '~/modules/llms/server/anthropic/anthropic.access';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { apiAsync, apiQuery } from '~/common/util/trpc.client';
|
||||
import { convert_Base64_To_UInt8Array } from '~/common/util/blobUtils';
|
||||
import { createTextContentFragment, DMessageContentFragment, DMessageFragmentId, DMessageHostedResourcePart } from '~/common/stores/chat/chat.fragments';
|
||||
import { copyBlobPromiseToClipboard, copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { downloadBlob } from '~/common/util/downloadUtils';
|
||||
import { humanReadableBytes } from '~/common/util/textUtils';
|
||||
import { mimeTypeIsPlainText, mimeTypeIsSupportedImage } from '~/common/attachment-drafts/attachment.mimetypes';
|
||||
import { useAIPreferencesStore } from '~/common/stores/store-ai';
|
||||
import { useLlmServiceAccess } from '~/common/stores/llms/hooks/useLlmServiceAccess';
|
||||
import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents';
|
||||
|
||||
|
||||
// -- react-query enrichers - stable select functions --
|
||||
|
||||
function _enrichMetadataWithMimeFlags<T extends { mime_type: string }>(meta: T) {
|
||||
return {
|
||||
...meta,
|
||||
mimeIsText: mimeTypeIsPlainText(meta.mime_type),
|
||||
mimeIsImage: mimeTypeIsSupportedImage(meta.mime_type),
|
||||
};
|
||||
}
|
||||
|
||||
function _base64ResponseToBlob({ base64Data, mimeType }: { base64Data: string; mimeType: string }) {
|
||||
const bytes = convert_Base64_To_UInt8Array(base64Data, 'hosted-resource-ant-file');
|
||||
return {
|
||||
blob: new Blob([bytes], { type: mimeType }),
|
||||
httpMimeType: mimeType,
|
||||
httpMimeIsText: mimeTypeIsPlainText(mimeType),
|
||||
httpMimeIsImage: mimeTypeIsSupportedImage(mimeType),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function AnthropicFileChip(props: {
|
||||
access: AnthropicAccessSchema,
|
||||
fileId: string,
|
||||
contentScaling: ContentScaling,
|
||||
onFragmentDelete?: () => void,
|
||||
onFragmentReplace?: (newFragment: DMessageContentFragment) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [busy, setBusy] = React.useState<false | 'download' | 'copy' | 'delete' | 'inline'>(false);
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
const { showPromisedOverlay } = useOverlayComponents();
|
||||
|
||||
// props
|
||||
const { access, fileId, onFragmentDelete, onFragmentReplace } = props;
|
||||
|
||||
// external state
|
||||
const autoEmbedEnabled = useAIPreferencesStore(state => state.vndAntInlineFiles !== 'off');
|
||||
const { data: metadata, isLoading: metaLoading, error: metaError } = apiQuery.llmAnthropic.fileApiGetMetadata.useQuery({ access, fileId }, {
|
||||
staleTime: Infinity,
|
||||
select: _enrichMetadataWithMimeFlags,
|
||||
});
|
||||
const { data: fileContent, refetch: refetchFileContent } = apiQuery.llmAnthropic.fileApiDownload.useQuery({ access, fileId }, {
|
||||
enabled: false, // on-demand only
|
||||
select: _base64ResponseToBlob,
|
||||
});
|
||||
|
||||
|
||||
// derive display info from typed metadata
|
||||
const fileName = metadata?.filename || fileId;
|
||||
const displayName = fileName.length > 40 ? fileName.slice(0, 20) + '...' + fileName.slice(-15) : fileName;
|
||||
|
||||
|
||||
// handlers
|
||||
|
||||
const handleDownload = React.useCallback(async () => {
|
||||
setBusy('download');
|
||||
setActionError(null);
|
||||
try {
|
||||
const data = fileContent || (await refetchFileContent({ cancelRefetch: false, throwOnError: true })).data;
|
||||
data && downloadBlob(data.blob, fileName);
|
||||
} catch (error: any) {
|
||||
setActionError(error?.message || 'Download failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [fileContent, refetchFileContent, fileName]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
setBusy('copy');
|
||||
setActionError(null);
|
||||
try {
|
||||
const data = fileContent || (await refetchFileContent({ cancelRefetch: false, throwOnError: true })).data;
|
||||
if (!data) return;
|
||||
if (data.httpMimeIsText)
|
||||
copyToClipboard(await data.blob.text(), fileName);
|
||||
else
|
||||
copyBlobPromiseToClipboard(data.httpMimeType, Promise.resolve(data.blob), fileName);
|
||||
} catch (error: any) {
|
||||
setActionError(error?.message || 'Copy failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [fileContent, refetchFileContent, fileName]);
|
||||
|
||||
const handleDelete = React.useCallback(async (event: React.MouseEvent) => {
|
||||
if (!onFragmentDelete) return;
|
||||
if (!event.shiftKey && !await showPromisedOverlay('chat-message-delete-hosted-resource', { rejectWithValue: false }, ({ onResolve, onUserReject }) =>
|
||||
<ConfirmationModal
|
||||
open onClose={onUserReject} onPositive={() => onResolve(true)}
|
||||
confirmationText={<>Delete "{fileName}" from Anthropic servers?<br />This action cannot be undone.</>}
|
||||
positiveActionText='Delete'
|
||||
/>,
|
||||
)) return;
|
||||
setBusy('delete');
|
||||
setActionError(null);
|
||||
try {
|
||||
// remote deletion
|
||||
await apiAsync.llmAnthropic.fileApiDelete.mutate({ access, fileId });
|
||||
// fragment removal
|
||||
onFragmentDelete();
|
||||
} catch (error: any) {
|
||||
setActionError(error?.message || 'Delete failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [access, fileId, fileName, onFragmentDelete, showPromisedOverlay]);
|
||||
|
||||
|
||||
const handleInline = React.useCallback(async () => {
|
||||
if (!onFragmentReplace) return;
|
||||
setBusy('inline');
|
||||
setActionError(null);
|
||||
try {
|
||||
const data = fileContent || (await refetchFileContent({ cancelRefetch: false, throwOnError: true })).data;
|
||||
if (!data) return;
|
||||
|
||||
// text: inline as fenced code block
|
||||
if (data.httpMimeIsText) {
|
||||
const text = await data.blob.text();
|
||||
|
||||
// fence with adaptive depth (extra backticks if content contains ```)
|
||||
let fence = '```';
|
||||
while (text.includes(fence) && fence.length < 10)
|
||||
fence += '`';
|
||||
onFragmentReplace(createTextContentFragment(`${fence}${fileName}\n${text}\n${fence}\n`));
|
||||
}
|
||||
// image: get dimensions, store in DBlob, and create a Zync asset reference
|
||||
// else if (data.httpMimeIsImage) {
|
||||
//
|
||||
// const { width, height } = await imageBlobGetDimensions(data.blob).catch(() => ({ width: 0, height: 0 }));
|
||||
//
|
||||
// const dblobAssetId = await addDBImageAsset('app-chat', data.blob, {
|
||||
// label: fileName,
|
||||
// origin: { ot: 'generated', source: 'ai-text-to-image', generatorName: 'anthropic-code-execution', prompt: '', parameters: {}, generatedAt: new Date().toISOString() },
|
||||
// metadata: { width, height },
|
||||
// });
|
||||
//
|
||||
// onFragmentReplace(createZyncAssetReferenceContentFragment(
|
||||
// nanoidToUuidV4(dblobAssetId, 'convert-dblob-to-dasset'),
|
||||
// fileName,
|
||||
// 'image',
|
||||
// {
|
||||
// pt: 'image_ref',
|
||||
// dataRef: createDMessageDataRefDBlob(dblobAssetId, data.httpMimeType, data.blob.size),
|
||||
// ...(fileName ? { altText: fileName } : {}),
|
||||
// ...(width ? { width } : {}),
|
||||
// ...(height ? { height } : {}),
|
||||
// },
|
||||
// ));
|
||||
// }
|
||||
else
|
||||
return setActionError('Cannot inline this file type');
|
||||
|
||||
// fire-and-forget: delete from provider
|
||||
apiAsync.llmAnthropic.fileApiDelete.mutate({ access, fileId }).catch(console.error);
|
||||
} catch (error: any) {
|
||||
setActionError(error?.message || 'Inline failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [fileContent, refetchFileContent, access, fileId, fileName, onFragmentReplace]);
|
||||
|
||||
|
||||
const handleToggleAutoEmbed = React.useCallback(async () => {
|
||||
if (autoEmbedEnabled)
|
||||
return useAIPreferencesStore.getState().setVndAntInlineFiles('off');
|
||||
if (await showPromisedOverlay('chat-message-auto-embed-notice', { rejectWithValue: false }, ({ onResolve, onUserReject }) =>
|
||||
<ConfirmationModal
|
||||
open onClose={onUserReject} onPositive={() => onResolve(true)}
|
||||
noTitleBar
|
||||
lowStakes
|
||||
confirmationText={<>
|
||||
From now on, files generated by Claude tools (code execution, etc.) will be automatically downloaded and embedded into messages, then removed from Anthropic's File API.
|
||||
<br /><br />
|
||||
You can change this anytime in <b>Settings > Chat AI > Anthropic File Inlining</b>.
|
||||
</>}
|
||||
positiveActionText='Enable & Embed'
|
||||
negativeActionText='Cancel'
|
||||
/>,
|
||||
)) {
|
||||
useAIPreferencesStore.getState().setVndAntInlineFiles('inline-file-and-delete');
|
||||
await handleInline();
|
||||
}
|
||||
}, [autoEmbedEnabled, handleInline, showPromisedOverlay]);
|
||||
|
||||
|
||||
const canCopy = !!metadata?.mimeIsText || !!metadata?.mimeIsImage;
|
||||
const canInline = !!onFragmentReplace && !!metadata?.mimeIsText; // for images, replace with ... && canCopy
|
||||
|
||||
const isBusy = !!busy || metaLoading;
|
||||
const hasError = !!metaError || !!actionError;
|
||||
const isFileGone = !!metaError && typeof metaError === 'object' && 'data' in metaError && (metaError.data?.httpStatus === 404 || metaError.data?.aixFHttpStatus === 404);
|
||||
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
variant='soft'
|
||||
color='primary'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mx: 1.5,
|
||||
px: 1.125,
|
||||
py: 0.5,
|
||||
borderRadius: 'sm',
|
||||
overflow: 'hidden',
|
||||
maxWidth: '100%',
|
||||
boxShadow: 'inset 1px 2px 2px -2px rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
>
|
||||
<AttachFileRoundedIcon sx={{ fontSize: 'lg', opacity: 0.5 }} />
|
||||
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Box className='agi-ellipsize' sx={{ fontSize: 'sm', fontWeight: 'md', color: hasError ? 'var(--joy-palette-danger-plainColor)' : undefined }}>
|
||||
{metaLoading ? 'Loading...' : isFileGone ? `${fileId} - file no longer available` : hasError ? `${displayName} - ${actionError || metaError?.message || 'Could not load file info'}` : displayName}
|
||||
</Box>
|
||||
{metadata && (
|
||||
<Box sx={{ fontSize: 'xs', opacity: 0.6 }}>
|
||||
{humanReadableBytes(metadata.size_bytes)} · <TimeAgo date={metadata.created_at} /> · {metadata.mime_type}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!isFileGone ? <>
|
||||
|
||||
{canCopy && (
|
||||
<GoodTooltip title='Copy to clipboard'>
|
||||
<IconButton variant='soft' color='primary' disabled={isBusy} onClick={handleCopy} size='sm'>
|
||||
{busy === 'copy' ? <CircularProgress size='sm' /> : <ContentCopyIcon sx={{ fontSize: 'lg' }} />}
|
||||
</IconButton>
|
||||
</GoodTooltip>
|
||||
)}
|
||||
{/*{canInline && (*/}
|
||||
{/* <GoodTooltip title='Embed in chat'>*/}
|
||||
{/* <IconButton variant='soft' color='primary' disabled={isBusy} onClick={handleInline} size='sm'>*/}
|
||||
{/* {busy === 'inline' ? <CircularProgress size='sm' /> : <VerticalAlignBottomIcon sx={{ fontSize: 'lg' }} />}*/}
|
||||
{/* </IconButton>*/}
|
||||
{/* </GoodTooltip>*/}
|
||||
{/*)}*/}
|
||||
<GoodTooltip title='Download file'>
|
||||
<IconButton variant='soft' color='primary' disabled={isBusy || isFileGone} onClick={handleDownload} size='sm'>
|
||||
{busy === 'download' ? <CircularProgress size='sm' /> : <DownloadIcon sx={{ fontSize: 'lg' }} />}
|
||||
</IconButton>
|
||||
</GoodTooltip>
|
||||
{(onFragmentDelete || onFragmentReplace) && (
|
||||
<Dropdown>
|
||||
<MenuButton slots={{ root: IconButton }} slotProps={{ root: { variant: 'soft', color: 'primary', size: 'sm', disabled: isBusy && busy !== 'inline' } }}>
|
||||
{(busy === 'delete' || busy === 'inline') ? <CircularProgress size='sm' /> : <MoreVertIcon sx={{ fontSize: 'lg' }} />}
|
||||
</MenuButton>
|
||||
<Menu placement='bottom-end' sx={{ minWidth: 220 }}>
|
||||
{/* Inline as doc attachment */}
|
||||
<MenuItem disabled={!canInline || isBusy} onClick={handleInline}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
<div>
|
||||
Embed
|
||||
{!canInline && <Typography level='body-xs' sx={{ opacity: 0.6 }}>
|
||||
File type not supported
|
||||
</Typography>}
|
||||
</div>
|
||||
</MenuItem>
|
||||
{/* Auto-embed toggle - shared global preference */}
|
||||
{!autoEmbedEnabled && <>
|
||||
<MenuItem disabled={!canInline || isBusy} onClick={handleToggleAutoEmbed}>
|
||||
<ListItemDecorator><Checkbox checked={autoEmbedEnabled} readOnly color='neutral' /></ListItemDecorator>
|
||||
<div>
|
||||
Always embed
|
||||
<Typography level='body-xs' sx={{ opacity: 0.6 }}>
|
||||
Change anytime in Settings
|
||||
</Typography>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>}
|
||||
{!!onFragmentDelete && <ListDivider />}
|
||||
{/* Delete from provider */}
|
||||
{!!onFragmentDelete && (
|
||||
<MenuItem color='danger' disabled={isBusy} onClick={handleDelete}>
|
||||
<ListItemDecorator><DeleteOutlineIcon /></ListItemDecorator>
|
||||
Delete
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
</> : onFragmentDelete && (
|
||||
<GoodTooltip title='Remove from message'>
|
||||
<IconButton variant='plain' color='danger' onClick={onFragmentDelete} size='sm'>
|
||||
<DeleteOutlineIcon sx={{ fontSize: 'lg' }} />
|
||||
</IconButton>
|
||||
</GoodTooltip>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function NoAccessChip(props: { fileId: string }) {
|
||||
return (
|
||||
<Sheet variant='outlined' sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5, borderRadius: 'sm' }}>
|
||||
<AttachFileRoundedIcon sx={{ fontSize: 'lg', opacity: 0.4 }} />
|
||||
<Typography level='body-sm' sx={{ opacity: 0.5 }}>
|
||||
{props.fileId} (no credentials)
|
||||
</Typography>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function BlockPartHostedResource(props: {
|
||||
hostedResourcePart: DMessageHostedResourcePart,
|
||||
fragmentId: DMessageFragmentId,
|
||||
messageGeneratorLlmId?: string | null,
|
||||
contentScaling: ContentScaling,
|
||||
onFragmentDelete?: (fragmentId: DMessageFragmentId) => void,
|
||||
onFragmentReplace?: (fragmentId: DMessageFragmentId, newFragment: DMessageContentFragment) => void,
|
||||
}) {
|
||||
|
||||
const { resource } = props.hostedResourcePart;
|
||||
const { fragmentId, onFragmentDelete, onFragmentReplace } = props;
|
||||
|
||||
const handleFragmentDelete = React.useCallback(() => {
|
||||
onFragmentDelete?.(fragmentId);
|
||||
}, [fragmentId, onFragmentDelete]);
|
||||
|
||||
const handleFragmentReplace = React.useCallback((newFragment: DMessageContentFragment) => {
|
||||
onFragmentReplace?.(fragmentId, newFragment);
|
||||
}, [fragmentId, onFragmentReplace]);
|
||||
|
||||
// TODO: OpenAI container_file_citation support (via: 'openai' with fileId + containerId)?
|
||||
|
||||
// reactive service + access resolution
|
||||
const isAnthropic = resource.via === 'anthropic';
|
||||
const antAccess = useLlmServiceAccess(isAnthropic ? props.messageGeneratorLlmId : undefined, 'anthropic');
|
||||
|
||||
// only support Anthropic files for now
|
||||
if (!isAnthropic || !antAccess)
|
||||
return <NoAccessChip fileId={resource?.fileId || 'unknown'} />;
|
||||
|
||||
return (
|
||||
<AnthropicFileChip
|
||||
access={antAccess}
|
||||
fileId={resource.fileId}
|
||||
contentScaling={props.contentScaling}
|
||||
onFragmentDelete={onFragmentDelete ? handleFragmentDelete : undefined}
|
||||
onFragmentReplace={onFragmentReplace ? handleFragmentReplace : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { BlocksContainer } from '~/modules/blocks/BlocksContainers';
|
||||
import { RenderImageRefDBlob } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
@@ -78,17 +77,15 @@ export function BlockPartImageRef(props: {
|
||||
scaledImageSx={scaledImageSx}
|
||||
variant='content-part'
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
ContentPartImageRef: unknown reftype
|
||||
</Box>
|
||||
)}
|
||||
) : 'BlockPartImageRef: unknown reftype'}
|
||||
|
||||
{/* Image viewer modal */}
|
||||
{!props.disableViewer && viewingImageRefPart && (
|
||||
<ViewImageRefPartModal
|
||||
imageRefPart={viewingImageRefPart}
|
||||
onClose={() => setViewingImageRefPart(null)}
|
||||
onDeleteFragment={onFragmentDelete ? handleDeleteFragment : undefined}
|
||||
onReplaceFragment={onFragmentReplace ? handleReplaceFragment : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ export function BlockPartText_AutoBlocks(props: {
|
||||
isMobile: boolean,
|
||||
fitScreen: boolean,
|
||||
disableMarkdownText: boolean,
|
||||
enhanceCodeBlocks: boolean,
|
||||
renderAsWordsDiff?: WordsDiff,
|
||||
|
||||
showUnsafeHtmlCode?: boolean,
|
||||
optiAllowSubBlocksMemo: boolean,
|
||||
optiStreamingLastFragment?: boolean,
|
||||
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onDoubleClick?: (event: React.MouseEvent) => void;
|
||||
@@ -75,9 +75,10 @@ export function BlockPartText_AutoBlocks(props: {
|
||||
isMobile={props.isMobile}
|
||||
showUnsafeHtmlCode={props.showUnsafeHtmlCode}
|
||||
renderAsWordsDiff={props.renderAsWordsDiff}
|
||||
codeRenderVariant={props.enhanceCodeBlocks ? 'enhanced' : 'outlined'}
|
||||
codeRenderVariant='enhanced' // was: { props.enhanceCodeBlocks ? 'enhanced' : 'outlined' }
|
||||
textRenderVariant={props.disableMarkdownText ? 'text' : 'markdown'}
|
||||
optiAllowSubBlocksMemo={props.optiAllowSubBlocksMemo}
|
||||
optiStreamingLastFragment={props.optiStreamingLastFragment}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
setText={!props.setEditedText ? undefined : handleSetText}
|
||||
|
||||
@@ -14,8 +14,9 @@ import type { ChatMessageTextPartEditState } from '../ChatMessage';
|
||||
import { BlockEdit_TextFragment } from './BlockEdit_TextFragment';
|
||||
import { BlockOpEmpty } from './BlockOpEmpty';
|
||||
import { BlockPartError } from './BlockPartError';
|
||||
import { BlockPartHostedResource } from './BlockPartHostedResource';
|
||||
import { BlockPartImageRef } from './BlockPartImageRef';
|
||||
import { BlockPartModelAux } from '../fragments-void/BlockPartModelAux';
|
||||
import { BlockPartModelAux, BlockPartModelAuxMemo } from '../fragments-void/BlockPartModelAux';
|
||||
import { BlockPartPlaceholder } from '../fragments-void/BlockPartPlaceholder';
|
||||
import { BlockPartText_AutoBlocks } from './BlockPartText_AutoBlocks';
|
||||
import { BlockPartToolInvocation } from './BlockPartToolInvocation';
|
||||
@@ -59,7 +60,6 @@ export function ContentFragments(props: {
|
||||
messageGeneratorLlmId?: string | null,
|
||||
optiAllowSubBlocksMemo?: boolean,
|
||||
disableMarkdownText: boolean,
|
||||
enhanceCodeBlocks: boolean,
|
||||
showUnsafeHtmlCode?: boolean,
|
||||
|
||||
textEditsState: ChatMessageTextPartEditState | null,
|
||||
@@ -87,6 +87,7 @@ export function ContentFragments(props: {
|
||||
// solo placeholder - dataStreamViz trigger
|
||||
const showDataStreamViz =
|
||||
!Release.Features.LIGHTER_ANIMATIONS
|
||||
&& !!props.messagePendingIncomplete // if generating
|
||||
&& props.uiComplexityMode !== 'minimal'
|
||||
&& props.contentFragments.length === 1
|
||||
// && props.noVoidFragments // not needed, we have all the interleaved fragments here
|
||||
@@ -134,6 +135,8 @@ export function ContentFragments(props: {
|
||||
|
||||
// simplify
|
||||
const { fId, ft } = fragment;
|
||||
const isLastFragment = fragmentIndex === props.contentFragments.length - 1;
|
||||
const optimizeMemoBeforeLastBlock = props.optiAllowSubBlocksMemo === true && !isLastFragment;
|
||||
|
||||
// VOID FRAGMENTS (reasoning, placeholders - interleaved with content)
|
||||
if (ft === 'void') {
|
||||
@@ -146,8 +149,9 @@ export function ContentFragments(props: {
|
||||
// return null;
|
||||
|
||||
case 'ma':
|
||||
const BlockPartModelAuxMemoOrNot = optimizeMemoBeforeLastBlock ? BlockPartModelAuxMemo : BlockPartModelAux;
|
||||
return (
|
||||
<BlockPartModelAux
|
||||
<BlockPartModelAuxMemoOrNot
|
||||
key={fId}
|
||||
fragmentId={fId}
|
||||
auxType={part.aType}
|
||||
@@ -157,7 +161,7 @@ export function ContentFragments(props: {
|
||||
messagePendingIncomplete={!!props.messagePendingIncomplete}
|
||||
zenMode={props.uiComplexityMode === 'minimal'}
|
||||
contentScaling={props.contentScaling}
|
||||
isLastFragment={fragmentIndex === props.contentFragments.length - 1}
|
||||
isLastFragment={isLastFragment}
|
||||
onFragmentDelete={props.onFragmentDelete}
|
||||
onFragmentReplace={props.onFragmentReplace}
|
||||
/>
|
||||
@@ -167,14 +171,13 @@ export function ContentFragments(props: {
|
||||
return (
|
||||
<BlockPartPlaceholder
|
||||
key={fId}
|
||||
placeholderText={part.pText}
|
||||
placeholderType={part.pType}
|
||||
placeholderModelOp={part.modelOp}
|
||||
placeholderAixControl={part.aixControl}
|
||||
messageRole={props.messageRole}
|
||||
fragmentId={fId}
|
||||
placeholderPart={part}
|
||||
contentScaling={props.contentScaling}
|
||||
showAsItalic
|
||||
messagePendingIncomplete={!!props.messagePendingIncomplete}
|
||||
showAsDataStreamViz={showDataStreamViz}
|
||||
zenMode={props.uiComplexityMode === 'minimal'}
|
||||
onFragmentDelete={props.messagePendingIncomplete ? undefined : props.onFragmentDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -333,10 +336,10 @@ export function ContentFragments(props: {
|
||||
fitScreen={props.fitScreen}
|
||||
isMobile={props.isMobile}
|
||||
disableMarkdownText={props.disableMarkdownText}
|
||||
enhanceCodeBlocks={props.enhanceCodeBlocks}
|
||||
// renderWordsDiff={wordsDiff || undefined}
|
||||
showUnsafeHtmlCode={props.showUnsafeHtmlCode}
|
||||
optiAllowSubBlocksMemo={!!props.optiAllowSubBlocksMemo}
|
||||
optiStreamingLastFragment={!!props.optiAllowSubBlocksMemo && isLastFragment && props.uiComplexityMode === 'minimal'}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
/>
|
||||
@@ -362,6 +365,19 @@ export function ContentFragments(props: {
|
||||
/>
|
||||
);
|
||||
|
||||
case 'hosted_resource':
|
||||
return (
|
||||
<BlockPartHostedResource
|
||||
key={fId}
|
||||
hostedResourcePart={part}
|
||||
fragmentId={fId}
|
||||
messageGeneratorLlmId={props.messageGeneratorLlmId}
|
||||
contentScaling={props.contentScaling}
|
||||
onFragmentDelete={props.onFragmentDelete}
|
||||
onFragmentReplace={props.onFragmentReplace}
|
||||
/>
|
||||
);
|
||||
|
||||
case '_pt_sentinel':
|
||||
return null;
|
||||
|
||||
|
||||
@@ -4,17 +4,18 @@ import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Button } from '@mui/joy';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
|
||||
import { RenderImageRefDBlob } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
import { RenderImageURL } from '~/modules/blocks/image/RenderImageURL';
|
||||
|
||||
import { getImageAsset } from '~/common/stores/blob/dblobs-portability';
|
||||
|
||||
import type { DMessageImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import type { DMessageContentFragment, DMessageImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { AppBreadcrumbs } from '~/common/components/AppBreadcrumbs';
|
||||
import { GoodModal } from '~/common/components/modals/GoodModal';
|
||||
import { convert_Base64WithMimeType_To_Blob } from '~/common/util/blobUtils';
|
||||
import { downloadBlob } from '~/common/util/downloadUtils';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
import { BlockPartImageRef } from './BlockPartImageRef';
|
||||
import { AppBreadcrumbs } from '~/common/components/AppBreadcrumbs';
|
||||
|
||||
|
||||
const imageViewerModalSx: SxProps = {
|
||||
maxWidth: '90vw',
|
||||
@@ -28,10 +29,11 @@ const imageViewerContainerSx: SxProps = {
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
|
||||
// pre-compensate the Block > Render Items 1.5 margin
|
||||
m: -1.5,
|
||||
// pre-compensate the RenderImageRefDBlob > Sheet's 1.5 (BlocksContainer-alike) margin
|
||||
mx: -1.5,
|
||||
// add some margin to unclip the Sheet's shadow
|
||||
'& > div': {
|
||||
pt: 1.5,
|
||||
mb: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,6 +41,8 @@ const imageViewerContainerSx: SxProps = {
|
||||
export function ViewImageRefPartModal(props: {
|
||||
imageRefPart: DMessageImageRefPart,
|
||||
onClose: () => void,
|
||||
onDeleteFragment?: () => void,
|
||||
onReplaceFragment?: (newFragment: DMessageContentFragment) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
@@ -49,7 +53,7 @@ export function ViewImageRefPartModal(props: {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// derived state
|
||||
const { dataRef, altText } = props.imageRefPart;
|
||||
const { dataRef, altText, width, height } = props.imageRefPart;
|
||||
const isDBlob = dataRef.reftype === 'dblob';
|
||||
|
||||
// handlers
|
||||
@@ -133,11 +137,27 @@ export function ViewImageRefPartModal(props: {
|
||||
sx={imageViewerModalSx}
|
||||
>
|
||||
<Box sx={imageViewerContainerSx}>
|
||||
<BlockPartImageRef
|
||||
disableViewer={true /* we're in the Modal, we won't pop this up anymore */}
|
||||
imageRefPart={props.imageRefPart}
|
||||
contentScaling='sm'
|
||||
/>
|
||||
{dataRef.reftype === 'dblob' ? (
|
||||
<RenderImageRefDBlob
|
||||
dataRefDBlobAssetId={dataRef.dblobAssetId}
|
||||
dataRefMimeType={dataRef.mimeType}
|
||||
dataRefBytesSize={dataRef.bytesSize}
|
||||
imageAltText={altText}
|
||||
imageWidth={width}
|
||||
imageHeight={height}
|
||||
onDeleteFragment={props.onDeleteFragment}
|
||||
onReplaceFragment={props.onReplaceFragment}
|
||||
// onViewImage={} we're already viewing the image in the dialog
|
||||
// scaledImageSx={} we reset scale in this dialog
|
||||
variant='content-part'
|
||||
/>
|
||||
) : dataRef.reftype === 'url' ? (
|
||||
<RenderImageURL
|
||||
imageURL={dataRef.url}
|
||||
expandableText={altText}
|
||||
variant='content-part'
|
||||
/>
|
||||
) : 'ViewImageRefPartModal: unknown reftype'}
|
||||
</Box>
|
||||
</GoodModal>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { ColorPaletteProp } from '@mui/joy/styles/types';
|
||||
import type { ColorPaletteProp, SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Chip, Typography } from '@mui/joy';
|
||||
import AllInclusiveIcon from '@mui/icons-material/AllInclusive';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
@@ -11,7 +11,7 @@ import { useScaledTypographySx } from '~/modules/blocks/blocks.styles';
|
||||
|
||||
import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal';
|
||||
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
|
||||
import { adjustContentScaling, ContentScaling } from '~/common/app.theme';
|
||||
import { adjustContentScaling, ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { animationSpinHalfPause } from '~/common/util/animUtils';
|
||||
import { createTextContentFragment, DMessageContentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents';
|
||||
@@ -32,35 +32,32 @@ const _styles = {
|
||||
},
|
||||
|
||||
chip: {
|
||||
px: 1.5,
|
||||
py: 0.375,
|
||||
pl: 1.5,
|
||||
pr: 1.75,
|
||||
my: '1px', // to not crop the outline on mobile, or on beam
|
||||
minHeight: '1.5rem', // similar parts, modelOps and paired tools, are 1.75rem
|
||||
'& .MuiChip-startDecorator': {
|
||||
marginRight: '0.5em',
|
||||
},
|
||||
},
|
||||
|
||||
chipActive: {
|
||||
outline: '1px solid',
|
||||
outlineColor: `${REASONING_COLOR}.solidBg`, // .outlinedBorder
|
||||
boxShadow: `1px 2px 4px -3px var(--joy-palette-${REASONING_COLOR}-solidBg)`,
|
||||
// '& > button': {
|
||||
// boxShadow: `inset 1px 2px 4px -3px var(--joy-palette-${REASONING_COLOR}-solidBg)`,
|
||||
// },
|
||||
},
|
||||
|
||||
chipDisabled: {
|
||||
px: 1.5,
|
||||
py: 0.375,
|
||||
my: '1px', // to not crop the outline on mobile, or on beam
|
||||
},
|
||||
|
||||
chipIcon: {
|
||||
fontSize: '1rem',
|
||||
mr: 0.5,
|
||||
},
|
||||
|
||||
chipIcon: undefined, // { fontSize: '1rem', },
|
||||
chipIconPending: {
|
||||
fontSize: '1rem',
|
||||
mr: 0.5,
|
||||
// fontSize: '1rem',
|
||||
animation: `${animationSpinHalfPause} 2s ease-in-out infinite`,
|
||||
},
|
||||
|
||||
chipExpanded: {
|
||||
mt: '1px', // need to copy the `chip` mt
|
||||
px: 1.5,
|
||||
py: 0.375,
|
||||
// borderRadius: 'sm',
|
||||
// transition: 'border-radius 0.2s ease-in-out',
|
||||
},
|
||||
@@ -94,13 +91,12 @@ const _styles = {
|
||||
// borderRadius: 'sm',
|
||||
// fontSize: 'xs',
|
||||
},
|
||||
|
||||
} as const;
|
||||
|
||||
|
||||
/** Detect if content is potentially markdown based on common markdown patterns */
|
||||
function _maybeMarkdownReasoning(trimmed: string): boolean {
|
||||
// const trimmed = text.trimStart();
|
||||
function _maybeMarkdownReasoning(text: string): boolean {
|
||||
const trimmed = text.trimStart();
|
||||
return trimmed.startsWith('**')
|
||||
|| trimmed.startsWith('# ')
|
||||
// || trimmed.startsWith('* ')
|
||||
@@ -109,6 +105,8 @@ function _maybeMarkdownReasoning(trimmed: string): boolean {
|
||||
}
|
||||
|
||||
|
||||
export const BlockPartModelAuxMemo = React.memo(BlockPartModelAux);
|
||||
|
||||
export function BlockPartModelAux(props: {
|
||||
fragmentId: DMessageFragmentId,
|
||||
auxType: 'reasoning' | string,
|
||||
@@ -130,17 +128,28 @@ export function BlockPartModelAux(props: {
|
||||
// external state
|
||||
const { showPromisedOverlay } = useOverlayComponents();
|
||||
|
||||
// derived
|
||||
const isActive = props.isLastFragment && props.messagePendingIncomplete;
|
||||
const contentScaling = adjustContentScaling(props.contentScaling, -1);
|
||||
const typeText = props.auxType === 'reasoning' ? 'Reasoning' : 'Auxiliary';
|
||||
|
||||
// memo
|
||||
const scaledTypographySx = useScaledTypographySx(adjustContentScaling(props.contentScaling, -1), false, false);
|
||||
const maybeMarkdown = React.useMemo(() => !ENABLE_MARKDOWN_DETECTION || neverExpanded ? false : _maybeMarkdownReasoning(props.auxText), [neverExpanded, props.auxText]);
|
||||
|
||||
// memo style
|
||||
const chipSx: SxProps = React.useMemo(() => ({
|
||||
..._styles.chip,
|
||||
...(isActive && _styles.chipActive),
|
||||
...(expanded && _styles.chipExpanded),
|
||||
fontSize: themeScalingMap[contentScaling]?.blockFontSize ?? undefined,
|
||||
}), [contentScaling, expanded, isActive]);
|
||||
const scaledTypographySx = useScaledTypographySx(contentScaling, false, false);
|
||||
const textSx = React.useMemo(() => ({
|
||||
..._styles.text,
|
||||
...scaledTypographySx,
|
||||
...(maybeMarkdown ? _styles.textUndoWhitespace : {}),
|
||||
}), [maybeMarkdown, scaledTypographySx]);
|
||||
|
||||
let typeText = props.auxType === 'reasoning' ? 'Reasoning' : 'Auxiliary';
|
||||
|
||||
|
||||
// handlers
|
||||
|
||||
@@ -196,20 +205,21 @@ export function BlockPartModelAux(props: {
|
||||
{/* Chip to expand/collapse */}
|
||||
<Box data-agi-no-copy /* do not copy these buttons */ sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Chip
|
||||
color={props.isLastFragment ? REASONING_COLOR : 'neutral'}
|
||||
variant={expanded ? 'solid' : 'soft'}
|
||||
size='sm'
|
||||
color={isActive || expanded ? REASONING_COLOR : 'neutral'}
|
||||
variant={expanded ? 'solid' : 'soft'}
|
||||
onClick={handleToggleExpanded}
|
||||
sx={expanded ? _styles.chipExpanded : props.isLastFragment ? _styles.chip : _styles.chipDisabled}
|
||||
sx={chipSx}
|
||||
startDecorator={
|
||||
<AllInclusiveIcon
|
||||
sx={(props.messagePendingIncomplete && !expanded && props.isLastFragment) ? _styles.chipIconPending : _styles.chipIcon}
|
||||
sx={!expanded && isActive ? _styles.chipIconPending : _styles.chipIcon}
|
||||
/* sx={{ color: expanded ? undefined : REASONING_COLOR }} */
|
||||
/>
|
||||
}
|
||||
// startDecorator='🧠'
|
||||
>
|
||||
Show {typeText}
|
||||
{/*Show {typeText}*/}
|
||||
{isActive && !expanded && typeText === 'Reasoning' ? `${typeText}...` : `Show ${typeText}`}
|
||||
</Chip>
|
||||
|
||||
{expanded && !props.messagePendingIncomplete && (showInline || showDelete) && !!props.auxText && (
|
||||
@@ -223,7 +233,8 @@ export function BlockPartModelAux(props: {
|
||||
disabled={!onFragmentReplace /* || props.messagePendingIncomplete */}
|
||||
onClick={!onFragmentReplace ? undefined : handleInline}
|
||||
endDecorator={<TextFieldsIcon />}
|
||||
sx={(!onFragmentReplace /* || props.messagePendingIncomplete */) ? _styles.chipDisabled : _styles.chip}
|
||||
sx={_styles.chip}
|
||||
// sx={(!onFragmentReplace /* || props.messagePendingIncomplete */) ? _styles.chipDisabled : _styles.chip}
|
||||
>
|
||||
Make Regular Text
|
||||
</Chip>}
|
||||
@@ -236,7 +247,8 @@ export function BlockPartModelAux(props: {
|
||||
disabled={!onFragmentDelete /* || props.messagePendingIncomplete */}
|
||||
onClick={!onFragmentDelete ? undefined : handleDelete}
|
||||
endDecorator={<DeleteOutlineIcon />}
|
||||
sx={(!onFragmentDelete /* || props.messagePendingIncomplete */) ? _styles.chipDisabled : _styles.chip}
|
||||
sx={_styles.chip}
|
||||
// sx={(!onFragmentDelete /* || props.messagePendingIncomplete */) ? _styles.chipDisabled : _styles.chip}
|
||||
>
|
||||
Delete
|
||||
</Chip>}
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Chip } from '@mui/joy';
|
||||
import { Box, Chip, ColorPaletteProp, Divider, Tooltip } from '@mui/joy';
|
||||
import BrushRoundedIcon from '@mui/icons-material/BrushRounded';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import ClearAllRoundedIcon from '@mui/icons-material/ClearAllRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
|
||||
import RepeatIcon from '@mui/icons-material/Repeat';
|
||||
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
||||
|
||||
import { BlocksContainer } from '~/modules/blocks/BlocksContainers';
|
||||
import { RenderCodeMemo } from '~/modules/blocks/code/RenderCode';
|
||||
import { ScaledTextBlockRenderer } from '~/modules/blocks/ScaledTextBlockRenderer';
|
||||
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import type { DVoidPlaceholderModelOp, DVoidPlaceholderPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { adjustContentScaling, ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import type { DMessageFragmentId, DVoidPlaceholderMOp, DVoidPlaceholderPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { DataStreamViz } from '~/common/components/DataStreamViz';
|
||||
import { adjustContentScaling, ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { animationSpinHalfPause } from '~/common/util/animUtils';
|
||||
|
||||
|
||||
// configuration
|
||||
const DATASTREAM_VISUALIZATION_DELAY = Math.round(2 * Math.PI * 1000);
|
||||
const MODELOP_TIMEOUT_DELAY = 5; // seconds
|
||||
const MODELOP_TIMEOUT_LIMIT = 300; // seconds
|
||||
const MODELOP_TIMEOUT_LIMIT = 7 * 24 * 60 * 60; // seconds - 1 week for long ops, such as Gemini Deep Research
|
||||
|
||||
const modelOperationConfig: Record<DVoidPlaceholderMOp['mot'], { Icon: React.ElementType, color: ColorPaletteProp }> = {
|
||||
'search-web': { Icon: SearchRoundedIcon, color: 'neutral' },
|
||||
'gen-image': { Icon: BrushRoundedIcon, color: 'success' },
|
||||
'code-exec': { Icon: CodeIcon, color: 'primary' },
|
||||
} as const;
|
||||
|
||||
function _formatElapsed(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m < 60) return s ? `${m}m ${s}s` : `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return rm ? `${h}h ${rm}m` : `${h}h`;
|
||||
}
|
||||
|
||||
|
||||
const _styles = {
|
||||
@@ -36,60 +55,230 @@ const _styles = {
|
||||
// wrap text if needed - introduced for retry error messages
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
} as const,
|
||||
},
|
||||
|
||||
followUpChipIcon: {
|
||||
fontSize: '1rem',
|
||||
mr: 0.5,
|
||||
animation: `${animationSpinHalfPause} 2s ease-in-out infinite`,
|
||||
} as const,
|
||||
},
|
||||
|
||||
opList: {
|
||||
// backgroundColor: 'red',
|
||||
px: 1.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
|
||||
opChipTooltip: {
|
||||
borderRadius: 'xs',
|
||||
boxShadow: 'md',
|
||||
fontSize: 'xs',
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxWidth: '96vw',
|
||||
p: 2,
|
||||
},
|
||||
opChip: {
|
||||
maxWidth: '100%', // fundamental for the ellipsize to work
|
||||
// width: '100%', // would have way less 'jumpy-ness'
|
||||
// minWidth: 200, // would work on mobile, but no clear advantage
|
||||
minWidth: 100, // safety floor, constant across active/done states
|
||||
// fontWeight: 500,
|
||||
minHeight: '2rem',
|
||||
minHeight: '1.75rem',
|
||||
// replaced by Box with px: 2
|
||||
// mx: 1.5, // example: RenderPlainText has _styles.typography.mx = 1.5
|
||||
pl: 1.5,
|
||||
pr: 1.75,
|
||||
borderRadius: 'sm',
|
||||
boxShadow: 'inset 1px 1px 4px -2px rgba(0, 0, 0, 0.2)',
|
||||
transition: 'all 0.2s ease',
|
||||
'& .MuiChip-startDecorator': {
|
||||
marginRight: '0.5em',
|
||||
},
|
||||
},
|
||||
opChipDone: {
|
||||
boxShadow: undefined, // reset
|
||||
color: 'text.tertiary',
|
||||
background: 'transparent',
|
||||
// done chips are rendered in 'plain' only, so the following works, otherwise it would remove the bg even in 'soft' for instance
|
||||
'& > button': {
|
||||
background: 'transparent',
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, SxProps>;
|
||||
|
||||
|
||||
const modelOperationConfig = {
|
||||
'search-web': { Icon: SearchRoundedIcon, color: 'neutral' },
|
||||
'gen-image': { Icon: BrushRoundedIcon, color: 'success' },
|
||||
'code-exec': { Icon: CodeIcon, color: 'primary' },
|
||||
} as const;
|
||||
// --- Render Follow-Up ---
|
||||
|
||||
function RenderChipFollowUp(props: {
|
||||
text: string
|
||||
}) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='soft'
|
||||
sx={_styles.followUpChip}
|
||||
startDecorator={<HourglassEmptyIcon sx={_styles.followUpChipIcon} />}
|
||||
>
|
||||
{props.text}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// --- Render AIX Control ---
|
||||
|
||||
function RenderChipAixControl({ aixControl, text }: {
|
||||
text: string,
|
||||
aixControl: Exclude<DVoidPlaceholderPart['aixControl'], undefined>, // DVoidPlaceholderAixControlRetry
|
||||
}) {
|
||||
|
||||
// derived
|
||||
let startText: number | string | undefined;
|
||||
let color: ColorPaletteProp;
|
||||
let Icon: React.ElementType | undefined;
|
||||
if (aixControl.ctl === 'ac-info')
|
||||
color = 'primary';
|
||||
else if (aixControl.ctl === 'ec-retry') {
|
||||
const { rCauseConn, rCauseHttp, rScope } = aixControl;
|
||||
startText = rCauseHttp || rCauseConn || rScope;
|
||||
color = rScope === 'srv-dispatch' ? 'primary'
|
||||
: rScope === 'srv-op' ? 'warning'
|
||||
: 'danger';
|
||||
Icon = RepeatIcon;
|
||||
} else
|
||||
color = 'danger';
|
||||
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
color={color}
|
||||
variant='soft'
|
||||
startDecorator={startText ? <div style={{ opacity: 0.75, textWrap: 'nowrap' }}>{startText}</div> : Icon ? <Icon style={{ opacity: 0.75 }} /> : undefined}
|
||||
sx={{
|
||||
mx: 1.5, // usual, esp for the looks into Beam
|
||||
gap: 1.5,
|
||||
px: 1.5,
|
||||
py: 0.375,
|
||||
my: '1px', // to not crop the outline on mobile, or on beam
|
||||
boxShadow: `inset 1px 2px 2px -1px var(--joy-palette-${color}-outlinedBorder)`,
|
||||
// outline: `1px solid var(--joy-palette-${color}-outlinedBorder)`,
|
||||
// wrap text if needed - introduced for retry error messages
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{text || 'Unknown Stream Control'}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// --- Render Model Operations ---
|
||||
|
||||
function RenderChipListModelOps(props: {
|
||||
opLog: Exclude<DVoidPlaceholderPart['opLog'], undefined>,
|
||||
contentScaling: ContentScaling,
|
||||
messagePendingIncomplete: boolean,
|
||||
fragmentId: DMessageFragmentId,
|
||||
onFragmentDelete?: (fragmentId: DMessageFragmentId) => void,
|
||||
}) {
|
||||
|
||||
// destructure
|
||||
const { contentScaling, opLog, fragmentId, onFragmentDelete } = props;
|
||||
|
||||
// memo ordering - children right after their parent (recursive, for PFC nesting)
|
||||
const ordered = React.useMemo(() => {
|
||||
|
||||
// fast path: no nesting -> keep insertion order
|
||||
if (!opLog.some(e => e.parentOpId)) return opLog;
|
||||
|
||||
// collect children by parent
|
||||
const roots: DVoidPlaceholderMOp[] = [];
|
||||
const childrenOf = new Map<string, DVoidPlaceholderMOp[]>();
|
||||
for (const e of opLog)
|
||||
if (e.parentOpId) (childrenOf.get(e.parentOpId) ?? childrenOf.set(e.parentOpId, []).get(e.parentOpId)!).push(e);
|
||||
else roots.push(e);
|
||||
|
||||
// recursively emit entry + descendants, then orphans
|
||||
const result: DVoidPlaceholderMOp[] = [];
|
||||
const placed = new Set<DVoidPlaceholderMOp>();
|
||||
const emit = (entry: DVoidPlaceholderMOp) => {
|
||||
result.push(entry);
|
||||
placed.add(entry);
|
||||
if (entry.opId)
|
||||
for (const child of childrenOf.get(entry.opId) ?? [])
|
||||
emit(child);
|
||||
};
|
||||
for (const root of roots) emit(root);
|
||||
for (const e of opLog) if (!placed.has(e)) result.push(e);
|
||||
|
||||
return result;
|
||||
}, [opLog]);
|
||||
|
||||
if (!ordered.length) return null;
|
||||
|
||||
return (
|
||||
<BlocksContainer sx={_styles.opList}>
|
||||
|
||||
{/* Operations list, with indentations */}
|
||||
{ordered.map((entry, i) => (
|
||||
<Box
|
||||
key={entry.opId}
|
||||
sx={!entry.level ? undefined : {
|
||||
ml: 2.125 * entry.level,
|
||||
borderLeft: '1px solid var(--joy-palette-neutral-outlinedBorder)',
|
||||
pl: 0.5,
|
||||
}}
|
||||
>
|
||||
<ModelOperationChip
|
||||
op={entry}
|
||||
contentScaling={contentScaling}
|
||||
messagePendingIncomplete={props.messagePendingIncomplete}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Harakiri chip, if possible (the div avoids x-stretching) */}
|
||||
{!!onFragmentDelete && <div>
|
||||
<OperationsHarakiriChip
|
||||
label='Clear steps'
|
||||
fragmentId={fragmentId}
|
||||
contentScaling={contentScaling}
|
||||
onFragmentDelete={onFragmentDelete}
|
||||
/>
|
||||
</div>}
|
||||
|
||||
</BlocksContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelOperationChip(props: {
|
||||
mot: 'search-web' | 'gen-image' | 'code-exec',
|
||||
cts: number,
|
||||
text: string,
|
||||
op: DVoidPlaceholderMOp,
|
||||
contentScaling: ContentScaling,
|
||||
messagePendingIncomplete: boolean,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [elapsedSeconds, setElapsedSeconds] = React.useState(0);
|
||||
|
||||
// derived
|
||||
const { Icon, color } = modelOperationConfig[props.mot] ?? {};
|
||||
const timerActive = Math.floor((Date.now() - props.cts) / 1000) < MODELOP_TIMEOUT_LIMIT;
|
||||
const { mot, cts, text, state, iTexts, oTexts } = props.op;
|
||||
const { Icon, color } = modelOperationConfig[mot] ?? {};
|
||||
const isDone = state === 'done';
|
||||
const isError = state === 'error';
|
||||
const isFinished = isDone || isError;
|
||||
|
||||
const iText = iTexts?.join('\n\n').trimStart() ?? null;
|
||||
const oText = oTexts?.join('\n') ?? null;
|
||||
const hasDetails = !!iText || !!oText;
|
||||
|
||||
const timerIsActive = props.messagePendingIncomplete && !isFinished && Math.floor((Date.now() - cts) / 1000) < MODELOP_TIMEOUT_LIMIT;
|
||||
|
||||
// [effect] show the elapsed time
|
||||
React.useEffect(() => {
|
||||
if (!timerActive) return; // prevent long-past timers to show
|
||||
if (!timerIsActive) return; // prevent long-past timers to show
|
||||
const timerId = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - props.cts) / 1000);
|
||||
const elapsed = Math.floor((Date.now() - cts) / 1000);
|
||||
if (elapsed >= MODELOP_TIMEOUT_DELAY)
|
||||
setElapsedSeconds(elapsed);
|
||||
}, 1000);
|
||||
@@ -97,123 +286,171 @@ function ModelOperationChip(props: {
|
||||
clearInterval(timerId);
|
||||
setElapsedSeconds(0);
|
||||
};
|
||||
}, [props.cts, timerActive]);
|
||||
}, [cts, timerIsActive]);
|
||||
|
||||
|
||||
// memo style
|
||||
const chipSx: SxProps = React.useMemo(() => ({
|
||||
..._styles.opChip,
|
||||
...(isFinished && _styles.opChipDone),
|
||||
...(isError && { color: undefined /* we inherit 'warning' */ }),
|
||||
...(hasDetails && { cursor: 'pointer' }),
|
||||
fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined,
|
||||
}), [isFinished, isError, hasDetails, props.contentScaling]);
|
||||
|
||||
const chipElement = (
|
||||
<Chip
|
||||
size='sm'
|
||||
color={isError ? 'warning' : isFinished ? 'neutral' : color}
|
||||
variant={isFinished ? 'plain' : 'soft'}
|
||||
onClick={!hasDetails ? undefined : () => false}
|
||||
startDecorator={isError ? <CloseRoundedIcon /> : isDone ? <CheckRoundedIcon /> : <Icon />}
|
||||
sx={chipSx}
|
||||
>
|
||||
<span className='agi-ellipsize'>
|
||||
{text}
|
||||
{elapsedSeconds >= MODELOP_TIMEOUT_DELAY && (
|
||||
<span style={{ opacity: 0.6 }}>
|
||||
{' · '}<span style={{ display: 'inline-block', minWidth: elapsedSeconds >= 60 ? '6ch' : '3ch' }}>{_formatElapsed(elapsedSeconds)}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Chip>
|
||||
);
|
||||
|
||||
return !hasDetails ? chipElement : (
|
||||
<Tooltip variant='outlined' placement='top' arrow sx={_styles.opChipTooltip} title={
|
||||
<div>
|
||||
{/* Input: rendered as code if */}
|
||||
{!!iText && mot === 'code-exec' ? (
|
||||
<RenderCodeMemo
|
||||
code={iText}
|
||||
semiStableId={`model-op-input-${props.op.opId}`}
|
||||
title=''
|
||||
isPartial={false}
|
||||
renderHideTitle={true}
|
||||
sx={{ m: -1.5, fontSize: props.contentScaling }}
|
||||
/>
|
||||
) : iText}
|
||||
|
||||
{!!iTexts?.length && !!oTexts?.length && <Divider sx={{ my: 2 }} />}
|
||||
|
||||
{!!oTexts?.length && oTexts.map((t, i) => (
|
||||
<span key={i} style={t.startsWith('exit code:') ? { color: 'var(--joy-palette-warning-plainColor)', fontWeight: 600 } : undefined}>
|
||||
{i > 0 && '\n'}{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
}>
|
||||
{chipElement}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationsHarakiriChip(props: {
|
||||
label: string,
|
||||
fragmentId: DMessageFragmentId, // used for self deletion
|
||||
contentScaling: ContentScaling,
|
||||
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
|
||||
}) {
|
||||
|
||||
const { fragmentId, onFragmentDelete } = props;
|
||||
|
||||
// handler
|
||||
|
||||
const handleDeleteSelf = React.useCallback(() => {
|
||||
onFragmentDelete(fragmentId);
|
||||
}, [fragmentId, onFragmentDelete]);
|
||||
|
||||
|
||||
// memo style
|
||||
const chipSx: SxProps = React.useMemo(() => ({
|
||||
..._styles.opChip,
|
||||
..._styles.opChipDone,
|
||||
fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined,
|
||||
}), [props.contentScaling]);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
color={color}
|
||||
variant='soft'
|
||||
startDecorator={<Icon />}
|
||||
sx={{
|
||||
..._styles.opChip,
|
||||
fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined,
|
||||
}}
|
||||
variant='plain'
|
||||
onClick={handleDeleteSelf}
|
||||
startDecorator={<ClearAllRoundedIcon /* sx={{ opacity: 0 }} */ />}
|
||||
sx={chipSx}
|
||||
>
|
||||
<span className='agi-ellipsize'>{props.text}{elapsedSeconds >= MODELOP_TIMEOUT_DELAY && <span style={{ opacity: 0.6 }}> · {elapsedSeconds}s</span>}</span>
|
||||
{props.label}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function BlockPartPlaceholder(props: {
|
||||
placeholderText: string,
|
||||
placeholderType?: DVoidPlaceholderPart['pType'],
|
||||
placeholderModelOp?: DVoidPlaceholderModelOp,
|
||||
placeholderAixControl?: DVoidPlaceholderPart['aixControl'],
|
||||
messageRole: DMessageRole,
|
||||
interface BlockPartPlaceholderProps {
|
||||
placeholderPart: DVoidPlaceholderPart,
|
||||
contentScaling: ContentScaling,
|
||||
showAsItalic?: boolean,
|
||||
messagePendingIncomplete: boolean,
|
||||
showAsDataStreamViz?: boolean,
|
||||
}) {
|
||||
zenMode?: boolean,
|
||||
|
||||
// used for self deletion
|
||||
fragmentId: DMessageFragmentId,
|
||||
onFragmentDelete?: (fragmentId: DMessageFragmentId) => void,
|
||||
// onFragmentReplace?: (fragmentId: DMessageFragmentId, newFragment: DMessageContentFragment) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient placeholder: follow-ups, retries, model-op progress (with PFC nesting), or italic text.
|
||||
*/
|
||||
export function BlockPartPlaceholder({ placeholderPart, contentScaling, messagePendingIncomplete, showAsDataStreamViz, zenMode, fragmentId, onFragmentDelete }: BlockPartPlaceholderProps){
|
||||
|
||||
// state
|
||||
const [showVisualization, setShowVisualization] = React.useState(false);
|
||||
|
||||
// derived state
|
||||
const shouldShowViz = props.showAsDataStreamViz && !props.placeholderModelOp;
|
||||
const { pText, pType, opLog, aixControl } = placeholderPart;
|
||||
const shouldShowViz = showAsDataStreamViz && !opLog?.length && !aixControl;
|
||||
|
||||
|
||||
// [effect] if allowed trigger the viz effect in 6.28 seconds, otherwise clear it
|
||||
React.useEffect(() => {
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (shouldShowViz)
|
||||
timerId = setTimeout(() => setShowVisualization(true), DATASTREAM_VISUALIZATION_DELAY);
|
||||
else
|
||||
setShowVisualization(false);
|
||||
|
||||
return () => timerId && clearTimeout(timerId);
|
||||
if (!shouldShowViz) return setShowVisualization(false);
|
||||
const timerId = setTimeout(() => setShowVisualization(true), DATASTREAM_VISUALIZATION_DELAY);
|
||||
return () => clearTimeout(timerId);
|
||||
}, [shouldShowViz]);
|
||||
|
||||
|
||||
// rendering switchboard
|
||||
|
||||
// Alternative placeholder visualization
|
||||
if (shouldShowViz && showVisualization)
|
||||
return <DataStreamViz height={1 + 8 * 4} />;
|
||||
|
||||
// 1. autoChatFollowUps's 'Follow Up' notices
|
||||
if (pType === 'chat-gen-follow-up')
|
||||
return <RenderChipFollowUp text={pText} />;
|
||||
|
||||
// Type-based visualization
|
||||
const isFollowUp = props.placeholderType === 'chat-gen-follow-up';
|
||||
if (isFollowUp) return (
|
||||
<Chip
|
||||
color='primary'
|
||||
variant='soft'
|
||||
size='sm'
|
||||
sx={_styles.followUpChip}
|
||||
startDecorator={<HourglassEmptyIcon sx={_styles.followUpChipIcon} />}
|
||||
>
|
||||
{props.placeholderText}
|
||||
</Chip>
|
||||
// 2. AIX Control renderer - only for error correction retry
|
||||
if (aixControl?.ctl)
|
||||
return <RenderChipAixControl text={pText} aixControl={aixControl} />;
|
||||
|
||||
// 3. Model operation render - stacked list when multiple operations, single chip otherwise
|
||||
if (opLog?.length) return zenMode ? null : (
|
||||
<RenderChipListModelOps
|
||||
opLog={opLog}
|
||||
contentScaling={adjustContentScaling(contentScaling, -1)}
|
||||
messagePendingIncomplete={messagePendingIncomplete}
|
||||
fragmentId={fragmentId}
|
||||
onFragmentDelete={onFragmentDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// AIX Control renderer (e.g., error correction retry)
|
||||
if (props.placeholderAixControl?.ctl === 'ec-retry') {
|
||||
const { rScope, rCauseHttp, rCauseConn } = props.placeholderAixControl;
|
||||
const color = rScope === 'srv-dispatch' ? 'primary' : rScope === 'srv-op' ? 'warning' : 'danger';
|
||||
return (
|
||||
<Chip
|
||||
// size='sm'
|
||||
color={color}
|
||||
variant='soft'
|
||||
startDecorator={<div style={{ opacity: 0.75 }}>{rCauseHttp || rCauseConn || rScope}</div>}
|
||||
endDecorator={<RepeatIcon style={{ opacity: 0.5 }} />}
|
||||
onClick={() => console.log({ props })}
|
||||
sx={{
|
||||
gap: 1.5,
|
||||
px: 1.5,
|
||||
py: 0.375,
|
||||
my: '1px', // to not crop the outline on mobile, or on beam
|
||||
boxShadow: `1px 2px 4px -3px var(--joy-palette-${color}-solidBg)`,
|
||||
// wrap text if needed - introduced for retry error messages
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{props.placeholderText}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
// Model operation renderer
|
||||
if (props.placeholderModelOp)
|
||||
return (
|
||||
<BlocksContainer>
|
||||
<Box sx={{ px: 1.5 }}>
|
||||
<ModelOperationChip
|
||||
text={props.placeholderText}
|
||||
mot={props.placeholderModelOp.mot}
|
||||
cts={props.placeholderModelOp.cts}
|
||||
contentScaling={adjustContentScaling(props.contentScaling, -1)}
|
||||
/>
|
||||
</Box>
|
||||
</BlocksContainer>
|
||||
);
|
||||
|
||||
// 4. 'placeholder text' in italic - used in various places in the app
|
||||
return (
|
||||
<ScaledTextBlockRenderer
|
||||
text={props.placeholderText}
|
||||
contentScaling={props.contentScaling}
|
||||
text={pText}
|
||||
contentScaling={contentScaling}
|
||||
textRenderVariant='text'
|
||||
showAsItalic={props.showAsItalic}
|
||||
// showAsDanger={false}
|
||||
showAsItalic={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const INLINE_COLOR = 'primary';
|
||||
|
||||
const bubbleComposerSx: SxProps = {
|
||||
// contained
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
zIndex: 2, // stays on top of the 'tokens' bubble in the composer
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DMessageId } from '~/common/stores/chat/chat.message';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { createTextContentFragment, DMessageContentFragment, DMessageFragment, DMessageFragmentId, isTextContentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
import { wrapWithMarkdownSyntax } from '~/modules/blocks/markdown/markdown.wrapper';
|
||||
|
||||
import { BUBBLE_MIN_TEXT_LENGTH } from './ChatMessage';
|
||||
@@ -33,7 +35,7 @@ const APPLY_HTML_STRIKE = (text: string) => `<del>${text}</del>`;
|
||||
const APPLY_MD_STRONG = (text: string) => wrapWithMarkdownSyntax(text, '**');
|
||||
const APPLY_CUT = (_text: string) => ''; // Cut removes the text entirely
|
||||
|
||||
type HighlightTool = 'highlight' | 'strike' | 'strong' | 'cut';
|
||||
export type HighlightTool = 'highlight' | 'strike' | 'strong' | 'cut';
|
||||
|
||||
|
||||
// -- Matcher algorithms --
|
||||
@@ -171,6 +173,10 @@ export function useSelHighlighterMemo(
|
||||
// Tool application function
|
||||
acc = (tool: HighlightTool) => {
|
||||
|
||||
// Copy to clipboard before cutting
|
||||
if (tool === 'cut')
|
||||
copyToClipboard(selText, 'Cut text');
|
||||
|
||||
// Apply the tool to the inner text
|
||||
const selProcessed =
|
||||
tool === 'highlight' ? APPLY_HTML_HIGHLIGHT(selText)
|
||||
|
||||
@@ -353,7 +353,8 @@ export function PersonaSelector(props: {
|
||||
|
||||
{/* [row -3] Example incipits */}
|
||||
{systemPurposeId !== 'Custom' && (
|
||||
<ExpanderControlledBox expanded={showExamples || (!isCustomPurpose && showPrompt)} sx={{ gridColumn: '1 / -1', pt: 1 }}>
|
||||
<Box sx={{ gridColumn: '1 / -1', pt: 1 }}>
|
||||
<ExpanderControlledBox expanded={showExamples || (!isCustomPurpose && showPrompt)}>
|
||||
{showExamples && (
|
||||
<List
|
||||
aria-label='Persona Conversation Starters'
|
||||
@@ -419,6 +420,7 @@ export function PersonaSelector(props: {
|
||||
</Card>
|
||||
)}
|
||||
</ExpanderControlledBox>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* [row -1] Custom Prompt box */}
|
||||
|
||||
@@ -7,10 +7,10 @@ import type { DLLMId } from '~/common/stores/llms/llms.types';
|
||||
import { AudioGenerator } from '~/common/util/audio/AudioGenerator';
|
||||
import { ConversationsManager } from '~/common/chat-overlay/ConversationsManager';
|
||||
import { DMessage, MESSAGE_FLAG_NOTIFY_COMPLETE, messageWasInterruptedAtStart } from '~/common/stores/chat/chat.message';
|
||||
import { getUXLabsHighPerformance } from '~/common/stores/store-ux-labs';
|
||||
import { getLabsHighPerformance } from '~/common/stores/store-ux-labs';
|
||||
|
||||
import { PersonaChatMessageSpeak } from './persona/PersonaChatMessageSpeak';
|
||||
import { getChatAutoAI, getIsNotificationEnabledForModel } from '../store-app-chat';
|
||||
import { getChatAutoAI, getChatThinkingPolicy, getIsNotificationEnabledForModel } from '../store-app-chat';
|
||||
import { getInstantAppChatPanesCount } from '../components/panes/store-panes-manager';
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ export async function runPersonaOnConversationHead(
|
||||
},
|
||||
);
|
||||
|
||||
const parallelViewCount = getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount();
|
||||
const parallelViewCount = getLabsHighPerformance() ? 0 : getInstantAppChatPanesCount();
|
||||
|
||||
// ai follow-up operations (fire/forget)
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions, autoTitleChat, chatThinkingPolicy } = getChatAutoAI();
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
|
||||
|
||||
// AutoSpeak
|
||||
const autoSpeaker: PersonaProcessorInterface | null = autoSpeak !== 'off' ? new PersonaChatMessageSpeak(autoSpeak) : null;
|
||||
@@ -78,15 +78,14 @@ export async function runPersonaOnConversationHead(
|
||||
// if (abortController.signal.aborted)
|
||||
// console.warn('runPersonaOnConversationHead: Aborted', { conversationId, assistantLlmId, messageOverwrite });
|
||||
|
||||
// deep copy the object to avoid partial updates
|
||||
let deepCopy = structuredClone(messageOverwrite);
|
||||
// fragments and generator are already immutable (new refs per update) - no deep clone needed
|
||||
const { fragments, ...rest } = messageOverwrite;
|
||||
|
||||
// [Cosmetic Logic] if the content hasn't come yet, don't replace the fragments to still show the placeholder
|
||||
if (!messageComplete && deepCopy.pendingIncomplete && deepCopy.fragments?.length === 0)
|
||||
delete (deepCopy as any).fragments;
|
||||
const includeFragments = !!fragments?.length || messageComplete || !messageOverwrite.pendingIncomplete;
|
||||
|
||||
// update the message
|
||||
cHandler.messageEdit(assistantMessageId, deepCopy, messageComplete, false);
|
||||
cHandler.messageEdit(assistantMessageId, { ...(includeFragments && { fragments }), ...rest }, messageComplete, false);
|
||||
|
||||
// if requested, speak the message
|
||||
autoSpeaker?.handleMessage(messageOverwrite, messageComplete);
|
||||
@@ -97,12 +96,12 @@ export async function runPersonaOnConversationHead(
|
||||
);
|
||||
|
||||
// final message update (needed only in case of error)
|
||||
const lastDeepCopy = structuredClone(messageStatus.lastDMessage);
|
||||
if (messageStatus.outcome === 'errored')
|
||||
cHandler.messageEdit(assistantMessageId, lastDeepCopy, true, false);
|
||||
const lastDMessage = messageStatus.lastDMessage;
|
||||
if (messageStatus.outcome === 'failed')
|
||||
cHandler.messageEdit(assistantMessageId, lastDMessage, true, false);
|
||||
|
||||
// special case: if the last message was aborted and had no content, delete it
|
||||
if (messageWasInterruptedAtStart(lastDeepCopy)) {
|
||||
if (messageWasInterruptedAtStart(lastDMessage)) {
|
||||
cHandler.messagesDelete([assistantMessageId]);
|
||||
// NOTE: ok to exit here, as the abort was already done
|
||||
return false;
|
||||
@@ -129,11 +128,12 @@ export async function runPersonaOnConversationHead(
|
||||
if (!hasBeenAborted && (autoSuggestDiagrams || autoSuggestHTMLUI || autoSuggestQuestions))
|
||||
void autoChatFollowUps(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions);
|
||||
|
||||
const chatThinkingPolicy = getChatThinkingPolicy();
|
||||
if (chatThinkingPolicy === 'last-only')
|
||||
cHandler.historyStripThinking(1);
|
||||
else if (chatThinkingPolicy === 'discard-all')
|
||||
cHandler.historyStripThinking(0);
|
||||
|
||||
// return true if this succeeded
|
||||
return messageStatus.outcome === 'success';
|
||||
return messageStatus.outcome === 'completed';
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ interface AppChatStore {
|
||||
|
||||
clearFilters: () => void;
|
||||
|
||||
filterHasBeamOpen: boolean;
|
||||
toggleFilterHasBeamOpen: () => void;
|
||||
|
||||
filterHasDocFragments: boolean;
|
||||
toggleFilterHasDocFragments: () => void;
|
||||
|
||||
@@ -120,7 +123,10 @@ const useAppChatStore = create<AppChatStore>()(persist(
|
||||
|
||||
// Chat UI
|
||||
|
||||
clearFilters: () => _set({ filterIsArchived: false, filterHasDocFragments: false, filterHasImageAssets: false, filterHasStars: false }),
|
||||
clearFilters: () => _set({ filterIsArchived: false, filterHasBeamOpen: false, filterHasDocFragments: false, filterHasImageAssets: false, filterHasStars: false }),
|
||||
|
||||
filterHasBeamOpen: false,
|
||||
toggleFilterHasBeamOpen: () => _set(({ filterHasBeamOpen }) => ({ filterHasBeamOpen: !filterHasBeamOpen })),
|
||||
|
||||
filterHasDocFragments: false,
|
||||
toggleFilterHasDocFragments: () => _set(({ filterHasDocFragments }) => ({ filterHasDocFragments: !filterHasDocFragments })),
|
||||
@@ -212,7 +218,6 @@ export const getChatAutoAI = (): {
|
||||
autoSuggestQuestions: boolean,
|
||||
autoTitleChat: boolean,
|
||||
autoVndAntBreakpoints: boolean,
|
||||
chatThinkingPolicy: ChatThinkingPolicy,
|
||||
} => useAppChatStore.getState();
|
||||
|
||||
export const useChatAutoSuggestHTMLUI = (): boolean =>
|
||||
@@ -221,6 +226,9 @@ export const useChatAutoSuggestHTMLUI = (): boolean =>
|
||||
export const useChatAutoSuggestAttachmentPrompts = (): boolean =>
|
||||
useAppChatStore(state => state.autoSuggestAttachmentPrompts);
|
||||
|
||||
export const getChatThinkingPolicy = (): ChatThinkingPolicy =>
|
||||
useAppChatStore.getState().chatThinkingPolicy;
|
||||
|
||||
export const getChatTokenCountingMethod = (): TokenCountingMethod =>
|
||||
useAppChatStore.getState().tokenCountingMethod;
|
||||
|
||||
@@ -232,6 +240,7 @@ export const useChatMicTimeoutMs = (): [number, (micTimeoutMs: number) => void]
|
||||
|
||||
export function useChatDrawerFilters() {
|
||||
return useAppChatStore(useShallow(state => ({
|
||||
filterHasBeamOpen: state.filterHasBeamOpen,
|
||||
filterHasDocFragments: state.filterHasDocFragments,
|
||||
filterHasImageAssets: state.filterHasImageAssets,
|
||||
filterHasStars: state.filterHasStars,
|
||||
@@ -239,6 +248,7 @@ export function useChatDrawerFilters() {
|
||||
showPersonaIcons: state.showPersonaIcons2,
|
||||
showRelativeSize: state.showRelativeSize,
|
||||
clearFilters: state.clearFilters,
|
||||
toggleFilterHasBeamOpen: state.toggleFilterHasBeamOpen,
|
||||
toggleFilterHasDocFragments: state.toggleFilterHasDocFragments,
|
||||
toggleFilterHasImageAssets: state.toggleFilterHasImageAssets,
|
||||
toggleFilterHasStars: state.toggleFilterHasStars,
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
import { BigAgiProNewsCallout, bigAgiProUrl } from './bigAgiPro.data';
|
||||
import { DevNewsItem, newsFrontendTimestamp, NewsItems } from './news.data';
|
||||
import { beamNewsCallout } from './beam.data';
|
||||
|
||||
|
||||
// number of news items to show by default, before the expander
|
||||
@@ -266,12 +265,12 @@ export function AppNews() {
|
||||
{/* </Box>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/* Inject the Beam item here*/}
|
||||
{idx === 2 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{beamNewsCallout}
|
||||
</Box>
|
||||
)}
|
||||
{/*/!* Inject the Beam item here*!/*/}
|
||||
{/*{idx === 2 && (*/}
|
||||
{/* <Box sx={{ mb: 3 }}>*/}
|
||||
{/* {beamNewsCallout}*/}
|
||||
{/* </Box>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/* News Item */}
|
||||
<NewsCard key={'news-' + idx} newsItem={ni} idx={idx} addPadding={addPadding} />
|
||||
@@ -283,7 +282,7 @@ export function AppNews() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{idx === 1 && <Divider sx={{ my: 6, mx: 6 }}/>}
|
||||
{/*{idx === 1 && <Divider sx={{ my: 6, mx: 6 }}/>}*/}
|
||||
|
||||
</React.Fragment>;
|
||||
})}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, Card, CardContent, Grid, Typography } from '@mui/joy';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
|
||||
// export const beamReleaseDate = '2024-04-01T22:00:00Z';
|
||||
export const beamBlogUrl = 'https://big-agi.com/blog/beam-multi-model-ai-reasoning/';
|
||||
|
||||
export const beamNewsCallout =
|
||||
<Card variant='solid' invertedColors>
|
||||
<CardContent sx={{ gap: 2 }}>
|
||||
<Typography level='title-lg'>
|
||||
Beam - launched in 1.15
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Beam is a world-first, multi-model AI chat modality that accelerates the discovery of superior solutions by leveraging the collective strengths of diverse LLMs.
|
||||
{/*Beam is a world-first, multi-model AI chat modality. By combining the strenghts of diverse LLMs, Beam allows you to find better answers, faster.*/}
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
<Grid xs={12} sm={7}>
|
||||
<Button
|
||||
fullWidth variant='soft' color='primary' endDecorator={<LaunchIcon />}
|
||||
component={Link} href={beamBlogUrl} noLinkStyle target='_blank'
|
||||
>
|
||||
Blog
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={5} sx={{ display: 'flex', flexAlign: 'center', justifyContent: 'center' }}>
|
||||
{/*<Button*/}
|
||||
{/* fullWidth variant='outlined' color='primary' startDecorator={<ThumbUpRoundedIcon />}*/}
|
||||
{/* // endDecorator={<LaunchIcon />}*/}
|
||||
{/* component={Link} href={beamHNUrl} noLinkStyle target='_blank'*/}
|
||||
{/*>*/}
|
||||
{/* on Hackernews 🙏*/}
|
||||
{/*</Button>*/}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
@@ -18,8 +18,6 @@ import { Release } from '~/common/app.release';
|
||||
import { clientUtmSource } from '~/common/util/pwaUtils';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
|
||||
import { beamBlogUrl } from './beam.data';
|
||||
|
||||
|
||||
// Cover Images
|
||||
// A capybara created from the intersection of two perfect spheres, creating a unique geometric form. Made of frosted glass with black sunglasses. Sitting on a platform where two squares overlap - their intersection glows softly. The overlapping area contains the word "OPEN" in clean sans-serif. White background with geometric shadows.
|
||||
@@ -37,6 +35,9 @@ import coverV113 from '../../../public/images/covers/release-cover-v1.13.0.png';
|
||||
import coverV112 from '../../../public/images/covers/release-cover-v1.12.0.png';
|
||||
|
||||
|
||||
const beamBlogUrl = 'https://big-agi.com/blog/beam-multi-model-ai-reasoning/';
|
||||
|
||||
|
||||
interface NewsItem {
|
||||
versionCode: string;
|
||||
versionName?: string;
|
||||
@@ -71,6 +72,19 @@ export const DevNewsItem: NewsItem = {
|
||||
|
||||
// news and feature surfaces
|
||||
export const NewsItems: NewsItem[] = [
|
||||
{
|
||||
versionCode: '2.0.4',
|
||||
versionName: 'Hyper Params',
|
||||
versionDate: new Date('2026-03-25T12:00:00Z'),
|
||||
items: [
|
||||
{ text: <><B>Opus 4.6</B> adaptive thinking 1M tokens, <B>Sonnet 4.6</B>, <B>GPT-5.4</B> family, <B>Gemini 3.1 Pro</B>, <B>Nano Banana 2</B>, <B>Grok 4.20</B>, <B>Z.ai</B> models</> },
|
||||
{ text: <>Improved parameter accuracy for reasoning effort, verbosity, and temperature</> },
|
||||
{ text: <><B issue={965}>AWS Bedrock</B>: native Anthropic, Amazon Nova, and OpenAI-compatible</> },
|
||||
{ text: <>Anthropic: <B>Fast mode</B>, <B>continuation</B>, search depth US-inference</> },
|
||||
{ text: <><B issue={945}>Attachments on any message</B>, lossless images, focus mode</> },
|
||||
{ text: <>Rich text copy, reasoning trace controls, and more fixes</> },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionCode: '2.0.3',
|
||||
versionName: 'Red Carpet',
|
||||
@@ -174,10 +188,10 @@ export const NewsItems: NewsItem[] = [
|
||||
{ text: <>Support for new Mistral-Large models</>, icon: MistralIcon },
|
||||
{ text: <>Support for Google Gemini 1.5 models and various improvements</>, icon: GoogleIcon as any },
|
||||
{ text: <>Deeper LocalAI integration including support for <B issue={411}>model galleries</B></>, icon: LocalAIIcon },
|
||||
{ text: <>Major <B href='https://twitter.com/enricoros/status/1756553038293303434'>performance optimizations</B>: runs faster, saves power, saves memory</> },
|
||||
{ text: <>Major <B href='https://x.com/enricoros/status/1756553038293303434'>performance optimizations</B>: runs faster, saves power, saves memory</> },
|
||||
{ text: <>Improvements: auto-size charts, search and folder experience</> },
|
||||
{ text: <>Perfect chat scaling, with rapid keyboard shortcuts</> },
|
||||
{ text: <>Also: diagrams auto-resize, open code with StackBlitz and JSFiddle, quick model visibility toggle, open links externally, docs on the web</> },
|
||||
{ text: <>Also: diagrams auto-resize, quick model visibility toggle, open links externally, docs on the web</> },
|
||||
{ text: <>Fixes: standalone LaTeX blocks, close views by dragging, knowledge cutoff dates, crashes on Google translate (thanks dad)</> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
|
||||
import type { DModelDomainId } from '~/common/stores/llms/model.domains.types';
|
||||
import { AIVndAntInlineFilesPolicy, useAIPreferencesStore } from '~/common/stores/store-ai';
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { FormSelectControl, FormSelectOption } from '~/common/components/forms/FormSelectControl';
|
||||
import { useLLMSelect } from '~/common/components/forms/useLLMSelect';
|
||||
@@ -33,6 +34,12 @@ const _keepThinkingBlocksOptions: FormSelectOption<ChatThinkingPolicy>[] = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const _vndAntInlineFilesOptions: FormSelectOption<AIVndAntInlineFilesPolicy>[] = [
|
||||
{ value: 'off', label: 'Show', description: 'Keep as links' },
|
||||
{ value: 'inline-file', label: 'Embed', description: 'Default, embed in chat' },
|
||||
{ value: 'inline-file-and-delete', label: 'Embed + Free', description: 'Embed, then free' },
|
||||
] as const;
|
||||
|
||||
const _tokenCountingMethodOptions: FormSelectOption<TokenCountingMethod>[] = [
|
||||
{
|
||||
value: 'approximate',
|
||||
@@ -82,6 +89,7 @@ export function AppChatSettingsAI() {
|
||||
chatThinkingPolicy, setChatThinkingPolicy,
|
||||
tokenCountingMethod, setTokenCountingMethod,
|
||||
} = useChatAutoAI();
|
||||
const vndAntInlineFiles = useAIPreferencesStore(state => state.vndAntInlineFiles);
|
||||
|
||||
const showModelIcons = false; // useUIComplexityMode() === 'extra';
|
||||
|
||||
@@ -153,6 +161,22 @@ export function AppChatSettingsAI() {
|
||||
onChange={setChatThinkingPolicy}
|
||||
/>
|
||||
|
||||
<FormSelectControl<AIVndAntInlineFilesPolicy>
|
||||
title='Anthropic Files'
|
||||
tooltip={<>
|
||||
When Claude uses tools like code execution, it may produce text and image files stored in Anthropic's File API. This setting controls whether Big-AGI should automatically download and embed them in the chat.
|
||||
<ul>
|
||||
<li><b>Show</b>: keep as references.</li>
|
||||
<li><b>Embed</b>: download and embed text/images (default).</li>
|
||||
<li><b>Embed + Free</b>: embed, then delete from Anthropic to free storage.</li>
|
||||
</ul>
|
||||
Only affects Anthropic models.
|
||||
</>}
|
||||
options={_vndAntInlineFilesOptions}
|
||||
value={vndAntInlineFiles}
|
||||
onChange={useAIPreferencesStore.getState().setVndAntInlineFiles}
|
||||
/>
|
||||
|
||||
<ListDivider inset='gutter'>Automatic AI Functions</ListDivider>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between' }}>
|
||||
|
||||
@@ -1,65 +1,146 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { ScaledTextBlockRenderer } from '~/modules/blocks/ScaledTextBlockRenderer';
|
||||
import { Box, Chip, Divider, Typography } from '@mui/joy';
|
||||
|
||||
import { GoodModal } from '~/common/components/modals/GoodModal';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
import type { ShortcutDefinition } from '~/common/components/shortcuts/useGlobalShortcuts';
|
||||
import { shortcutsCatalog } from '~/common/components/shortcuts/shortcutsCatalog';
|
||||
import { useGlobalShortcutsStore } from '~/common/components/shortcuts/store-global-shortcuts';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { useUIContentScaling } from '~/common/stores/store-ui';
|
||||
import { Box } from '@mui/joy';
|
||||
import { Is } from '~/common/util/pwaUtils';
|
||||
|
||||
|
||||
const shortcutsMd = platformAwareKeystrokes(`
|
||||
// Styles
|
||||
|
||||
| Shortcut | Description |
|
||||
|------------------|-----------------------------------------|
|
||||
| **Edit** | |
|
||||
| Shift + Enter | Newline |
|
||||
| Alt + Enter | Append (no response) |
|
||||
| Ctrl + Enter | Beam (and start all Beams) |
|
||||
| Ctrl + Shift + Z | **Regenerate** last message |
|
||||
| Ctrl + Shift + B | **Beam** last message |
|
||||
| Ctrl + Shift + F | Attach file |
|
||||
| Ctrl + Shift + V | Attach clipboard (better than Ctrl + V) |
|
||||
| Ctrl + M | Microphone (voice typing) |
|
||||
| Ctrl + L | Change Model |
|
||||
| Ctrl + P | Change Persona |
|
||||
| **Chats** | |
|
||||
| Ctrl + O | Open Chat ... |
|
||||
| Ctrl + S | Save Chat ... |
|
||||
| Ctrl + Shift + N | **New** chat |
|
||||
| Ctrl + Shift + X | **Reset** chat |
|
||||
| Ctrl + Shift + D | **Delete** chat |
|
||||
| Ctrl + Up | Previous message/Beam (shift for top) |
|
||||
| Ctrl + Down | Next message/Beam (shift to bottom) |
|
||||
| Ctrl + [ | **Previous** chat (in history) |
|
||||
| Ctrl + ] | **Next** chat (in history) |
|
||||
| **Settings** | |
|
||||
| Ctrl + , | ⚙️ Preferences |
|
||||
| Ctrl + Shift + M | 🧠 Models |
|
||||
| Ctrl + Shift + O | 💬 Options (current Chat Model) |
|
||||
| Ctrl + Shift + A | Toggle AI Request Inspector |
|
||||
| Ctrl + Shift + + | Increase Text Size |
|
||||
| Ctrl + Shift + - | Decrease Text Size |
|
||||
| Ctrl + Shift + / | Shortcuts |
|
||||
const _styles = {
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
|
||||
gap: 0.75,
|
||||
columnGap: { md: 3 },
|
||||
alignItems: 'center',
|
||||
},
|
||||
categoryLabel: {
|
||||
gridColumn: { md: '1 / -1' },
|
||||
mt: 1.5,
|
||||
mb: 0.5,
|
||||
'&:first-of-type': { mt: 0 },
|
||||
},
|
||||
categoryDivider: {
|
||||
gridColumn: { md: '1 / -1' },
|
||||
mt: 1,
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
},
|
||||
keys: {
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
flexShrink: 0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
`).trim();
|
||||
|
||||
function _platformModifier(mod: string): string {
|
||||
if (!Is.OS.MacOS) return mod;
|
||||
switch (mod) {
|
||||
case 'Ctrl':
|
||||
return '⌃';
|
||||
case 'Shift':
|
||||
return '⇧';
|
||||
case 'Alt':
|
||||
return '⌥';
|
||||
default:
|
||||
return mod;
|
||||
}
|
||||
}
|
||||
|
||||
function _displayKey(key: string): string {
|
||||
switch (key) {
|
||||
case 'ArrowUp':
|
||||
return '↑';
|
||||
case 'ArrowDown':
|
||||
return '↓';
|
||||
case 'ArrowLeft':
|
||||
return '←';
|
||||
case 'ArrowRight':
|
||||
return '→';
|
||||
case 'Backspace':
|
||||
return '⌫';
|
||||
default:
|
||||
return key.length === 1 ? key.toUpperCase() : key;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a set of fingerprints from currently registered shortcuts for active detection.
|
||||
* Fingerprint: `key_lowercase:ctrl:shift` - matches the global handler resolution.
|
||||
*/
|
||||
function _buildActiveFingerprints(): Set<string> {
|
||||
const allShortcuts = useGlobalShortcutsStore.getState().getAllShortcuts();
|
||||
const fingerprints = new Set<string>();
|
||||
for (const s of allShortcuts) {
|
||||
if (!s.disabled)
|
||||
fingerprints.add(`${s.key.toLowerCase()}:${!!s.ctrl}:${!!s.shift}`);
|
||||
}
|
||||
return fingerprints;
|
||||
}
|
||||
|
||||
function _isActive(def: ShortcutDefinition, fingerprints: Set<string>): boolean {
|
||||
return fingerprints.has(`${def.key.toLowerCase()}:${!!def.ctrl}:${!!def.shift}`);
|
||||
}
|
||||
|
||||
|
||||
function ShortcutKeyCombo(props: { def: ShortcutDefinition }) {
|
||||
const { ctrl, shift, alt, key } = props.def;
|
||||
const parts: string[] = [];
|
||||
if (ctrl) parts.push(_platformModifier('Ctrl'));
|
||||
if (shift) parts.push(_platformModifier('Shift'));
|
||||
if (alt) parts.push(_platformModifier('Alt'));
|
||||
parts.push(_displayKey(key));
|
||||
return (
|
||||
<Box sx={_styles.keys}>
|
||||
{parts.map((part, i) =>
|
||||
<Chip key={i} size='sm' variant='soft' color='neutral'>{part}</Chip>,
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function ShortcutsModal(props: { onClose: () => void }) {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const contentScaling = useUIContentScaling();
|
||||
|
||||
// build active fingerprints once at render time
|
||||
const activeFingerprints = React.useMemo(_buildActiveFingerprints, []);
|
||||
|
||||
return (
|
||||
<GoodModal open fullscreen={isMobile} title='Desktop Shortcuts' onClose={props.onClose}>
|
||||
<Box sx={{ mx: -2 }}>
|
||||
<ScaledTextBlockRenderer
|
||||
text={shortcutsMd}
|
||||
contentScaling={contentScaling}
|
||||
textRenderVariant='markdown'
|
||||
/>
|
||||
<GoodModal open fullscreen={isMobile} title='Keyboard Shortcuts' onClose={props.onClose}>
|
||||
<Box sx={_styles.grid}>
|
||||
{shortcutsCatalog.map((category, ci) => (
|
||||
<React.Fragment key={category.label}>
|
||||
{ci > 0 && <Divider sx={_styles.categoryDivider} />}
|
||||
<Typography level='body-xs' textTransform='uppercase' fontWeight='lg' sx={_styles.categoryLabel}>
|
||||
{category.label}
|
||||
</Typography>
|
||||
{category.items.map((item, i) => {
|
||||
const active = _isActive(item, activeFingerprints);
|
||||
return (
|
||||
<Box key={i} sx={_styles.row}>
|
||||
<ShortcutKeyCombo def={item} />
|
||||
<Typography level='body-xs' sx={!active ? { opacity: 0.5 } : undefined}>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</GoodModal>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FormControl, Switch, Typography } from '@mui/joy';
|
||||
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import { FormControl, Typography } from '@mui/joy';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import LocalAtmOutlinedIcon from '@mui/icons-material/LocalAtmOutlined';
|
||||
import ScreenshotMonitorIcon from '@mui/icons-material/ScreenshotMonitor';
|
||||
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
||||
import ShortcutIcon from '@mui/icons-material/Shortcut';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { PhImageSquare } from '~/common/components/icons/phosphor/PhImageSquare';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { useUXLabsStore } from '~/common/stores/store-ux-labs';
|
||||
|
||||
@@ -20,53 +19,35 @@ export function UxLabsSettings() {
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const {
|
||||
labsAttachScreenCapture, setLabsAttachScreenCapture,
|
||||
labsCameraDesktop, setLabsCameraDesktop,
|
||||
labsEnhanceCodeBlocks, setLabsEnhanceCodeBlocks,
|
||||
labsHighPerformance, setLabsHighPerformance,
|
||||
labsShowCost, setLabsShowCost,
|
||||
labsLosslessImages, setLabsPreserveLosslessImages,
|
||||
labsAutoHideComposer, setLabsAutoHideComposer,
|
||||
labsShowShortcutBar, setLabsShowShortcutBar,
|
||||
labsComposerAttachmentsInline, setLabsComposerAttachmentsInline,
|
||||
} = useUXLabsStore();
|
||||
|
||||
return <>
|
||||
|
||||
<FormSwitchControl
|
||||
title={<><CodeIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Enhance Legacy Code</>} description={labsEnhanceCodeBlocks ? 'Auto-Enhance' : 'Disabled'}
|
||||
checked={labsEnhanceCodeBlocks} onChange={setLabsEnhanceCodeBlocks}
|
||||
title={<><PhImageSquare sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Lossless Images</>} description={labsLosslessImages ? 'Large storage use' : 'Compress'}
|
||||
tooltipWarning={labsLosslessImages}
|
||||
tooltip={<>
|
||||
Preserves the original lossless PNG format for AI-generated images instead of compressing them to WebP/JPEG.
|
||||
<hr />
|
||||
WARNING: PNG images can be very large (e.g. 10-20MB each in high quality modes in Gemini Nano Banana models). This will use significantly more storage.
|
||||
</>}
|
||||
checked={labsLosslessImages} onChange={setLabsPreserveLosslessImages}
|
||||
/>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between' }}>
|
||||
<FormLabelStart
|
||||
title={<><SpeedIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Unlock Refresh</>}
|
||||
description={labsHighPerformance ? 'Unlocked' : 'Default'}
|
||||
tooltipWarning={labsHighPerformance}
|
||||
tooltip={<>
|
||||
Unlocks the maximum UI refresh rate for Chats and Beams, and will draw every single token as they come in.
|
||||
<hr />
|
||||
THIS MAY CAUSE HIGH CPU USAGE, BATTERY DRAIN, AND STUTTERING WITH FAST MODELS.
|
||||
<hr />
|
||||
Default: OFF
|
||||
</>}
|
||||
/>
|
||||
<Switch checked={labsHighPerformance} onChange={event => setLabsHighPerformance(event.target.checked)}
|
||||
endDecorator={labsHighPerformance ? 'On' : 'Off'}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
|
||||
</FormControl>
|
||||
|
||||
{!isMobile && <FormSwitchControl
|
||||
title={<><ScreenshotMonitorIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} /> Screen Capture</>} description={labsAttachScreenCapture ? 'Enabled' : 'Disabled'}
|
||||
checked={labsAttachScreenCapture} onChange={setLabsAttachScreenCapture}
|
||||
/>}
|
||||
|
||||
{!isMobile && <FormSwitchControl
|
||||
title={<><AddAPhotoIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} /> Webcam Capture</>} description={/*'v1.8 · ' +*/ (labsCameraDesktop ? 'Enabled' : 'Disabled')}
|
||||
checked={labsCameraDesktop} onChange={setLabsCameraDesktop}
|
||||
/>}
|
||||
|
||||
<FormSwitchControl
|
||||
title={<><LocalAtmOutlinedIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Cost of messages</>} description={labsShowCost ? 'Show when available' : 'Disabled'}
|
||||
checked={labsShowCost} onChange={setLabsShowCost}
|
||||
title={<><SpeedIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Unlock Refresh</>} description={labsHighPerformance ? 'Unlocked' : 'Default'}
|
||||
tooltipWarning={labsHighPerformance}
|
||||
tooltip={<>
|
||||
Unlocks the maximum UI refresh rate for Chats and Beams, and will draw every single token as they come in.
|
||||
<hr />
|
||||
THIS MAY CAUSE HIGH CPU USAGE, BATTERY DRAIN, AND STUTTERING WITH FAST MODELS.
|
||||
</>}
|
||||
checked={labsHighPerformance} onChange={setLabsHighPerformance}
|
||||
/>
|
||||
|
||||
{!isMobile && <FormSwitchControl
|
||||
@@ -74,6 +55,11 @@ export function UxLabsSettings() {
|
||||
checked={labsShowShortcutBar} onChange={setLabsShowShortcutBar}
|
||||
/>}
|
||||
|
||||
<FormSwitchControl
|
||||
title={<><AttachFileRoundedIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Attachment Buttons</>} description={labsComposerAttachmentsInline ? 'Enabled' : 'Disabled'}
|
||||
checked={labsComposerAttachmentsInline} onChange={setLabsComposerAttachmentsInline}
|
||||
/>
|
||||
|
||||
<FormSwitchControl
|
||||
title={<><EditNoteIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Auto-hide input</>} description={labsAutoHideComposer ? 'Hover to show' : 'Always visible'}
|
||||
checked={labsAutoHideComposer} onChange={setLabsAutoHideComposer}
|
||||
@@ -89,7 +75,8 @@ export function UxLabsSettings() {
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart title='Graduated' description='Ex-labs' />
|
||||
<Typography level='body-xs'>
|
||||
<Link href='https://big-agi.com/blog/beam-multi-model-ai-reasoning' target='_blank'>Beam</Link>
|
||||
Screen Capture · Webcam · Cost Estimation · Enhanced Code Blocks
|
||||
{' · '}<Link href='https://big-agi.com/blog/beam-multi-model-ai-reasoning' target='_blank'>Beam</Link>
|
||||
{' · '}<Link href='https://github.com/enricoros/big-AGI/issues/208' target='_blank'>Split Chats</Link>
|
||||
{' · '}<Link href='https://github.com/enricoros/big-AGI/issues/354' target='_blank'>Call AGI</Link>
|
||||
{' · '}<Link href='https://github.com/enricoros/big-AGI/issues/282' target='_blank'>Persona Creator</Link>
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
*/
|
||||
export const Brand = {
|
||||
Title: {
|
||||
Base: 'big-AGI',
|
||||
Common: (process.env.NODE_ENV === 'development' ? '[DEV] ' : '') + 'big-AGI',
|
||||
Base: 'Big-AGI',
|
||||
Common: (process.env.NODE_ENV === 'development' ? '[DEV] ' : '') + 'Big-AGI',
|
||||
},
|
||||
Meta: {
|
||||
Description: 'Launch big-AGI to unlock the full potential of AI, with precise control over your data and models. Voice interface, AI personas, advanced features, and fun UX.',
|
||||
SiteName: 'big-AGI | Precision AI for You',
|
||||
Description: 'Launch the open-source AI workspace for experts. BYO API keys. Compare and tune models, use personas, voice and vision - your data stays local.',
|
||||
SiteName: 'Big-AGI | The Expert\'s AI Workspace',
|
||||
ThemeColor: '#32383E',
|
||||
TwitterSite: '@enricoros',
|
||||
},
|
||||
@@ -24,7 +24,7 @@ export const Brand = {
|
||||
OpenRepo: 'https://github.com/enricoros/big-agi',
|
||||
OpenProject: 'https://github.com/users/enricoros/projects/4',
|
||||
SupportInvite: 'https://discord.gg/MkH4qj2Jp9',
|
||||
// Twitter: 'https://www.twitter.com/enricoros',
|
||||
// Twitter: 'https://x.com/enricoros',
|
||||
PrivacyPolicy: 'https://big-agi.com/privacy',
|
||||
TermsOfService: 'https://big-agi.com/terms',
|
||||
},
|
||||
|
||||
+9
-11
@@ -8,8 +8,6 @@ import Diversity2Icon from '@mui/icons-material/Diversity2';
|
||||
import EventNoteIcon from '@mui/icons-material/EventNote';
|
||||
import EventNoteOutlinedIcon from '@mui/icons-material/EventNoteOutlined';
|
||||
import GrainIcon from '@mui/icons-material/Grain';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import IosShareIcon from '@mui/icons-material/IosShare';
|
||||
import IosShareOutlinedIcon from '@mui/icons-material/IosShareOutlined';
|
||||
// Link icons
|
||||
@@ -189,15 +187,15 @@ export const navItems: {
|
||||
hideIcon: true,
|
||||
isDev: true,
|
||||
},
|
||||
{
|
||||
name: 'Media Library',
|
||||
icon: ImageOutlinedIcon,
|
||||
iconActive: ImageIcon,
|
||||
type: 'app',
|
||||
route: '/media',
|
||||
isDev: true,
|
||||
_delete: true,
|
||||
},
|
||||
// {
|
||||
// name: 'Media Library',
|
||||
// icon: ImageOutlinedIcon,
|
||||
// iconActive: ImageIcon,
|
||||
// type: 'app',
|
||||
// route: '/media',
|
||||
// isDev: true,
|
||||
// _delete: true,
|
||||
// },
|
||||
{
|
||||
name: 'Shared Chats',
|
||||
barTitle: 'Shared Chat',
|
||||
|
||||
@@ -23,8 +23,8 @@ export const Release = {
|
||||
|
||||
// this is here to trigger revalidation of data, e.g. models refresh
|
||||
Monotonics: {
|
||||
Aix: 59,
|
||||
NewsVersion: 203,
|
||||
Aix: 69,
|
||||
NewsVersion: 204,
|
||||
},
|
||||
|
||||
// Frontend: pretty features
|
||||
|
||||
+14
-12
@@ -6,7 +6,6 @@ import AbcIcon from '@mui/icons-material/Abc';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||
import HtmlIcon from '@mui/icons-material/Html';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import PermMediaOutlinedIcon from '@mui/icons-material/PermMediaOutlined';
|
||||
import PhotoSizeSelectLargeOutlinedIcon from '@mui/icons-material/PhotoSizeSelectLargeOutlined';
|
||||
import PhotoSizeSelectSmallOutlinedIcon from '@mui/icons-material/PhotoSizeSelectSmallOutlined';
|
||||
@@ -24,10 +23,11 @@ import { RenderImageURL } from '~/modules/blocks/image/RenderImageURL';
|
||||
import type { AttachmentDraft, AttachmentDraftConverterType, AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import { DMessageDataRef, DMessageImageRefPart, isImageRefPart, isZyncAssetImageReferencePartWithLegacyDBlob } from '~/common/stores/chat/chat.fragments';
|
||||
import { LiveFileIcon } from '~/common/livefile/liveFile.icons';
|
||||
import { PhImageSquare } from '~/common/components/icons/phosphor/PhImageSquare';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
import { ellipsizeFront, ellipsizeMiddle } from '~/common/util/textUtils';
|
||||
|
||||
import type { LLMAttachmentDraft } from './useLLMAttachmentDrafts';
|
||||
import type { IAttachmentEnrichment } from '../llm-enrichment/attachment.enrichment';
|
||||
|
||||
|
||||
const ATTACHMENT_MIN_STYLE = {
|
||||
@@ -97,10 +97,10 @@ const converterTypeToIconMap: { [key in AttachmentDraftConverterType]: React.Com
|
||||
'rich-text-cleaner': CodeIcon,
|
||||
'rich-text-markdown': TextFieldsIcon,
|
||||
'rich-text-table': PivotTableChartIcon,
|
||||
'image-original': ImageOutlinedIcon,
|
||||
'image-original': PhImageSquare,
|
||||
'image-resized-high': PhotoSizeSelectLargeOutlinedIcon,
|
||||
'image-resized-low': PhotoSizeSelectSmallOutlinedIcon,
|
||||
'image-to-default': ImageOutlinedIcon,
|
||||
'image-to-default': PhImageSquare,
|
||||
'image-caption': AbcIcon,
|
||||
'image-ocr': AbcIcon,
|
||||
'pdf-auto': PictureAsPdfIcon,
|
||||
@@ -113,14 +113,14 @@ const converterTypeToIconMap: { [key in AttachmentDraftConverterType]: React.Com
|
||||
'url-page-markdown': CodeIcon, // was LanguageIcon
|
||||
'url-page-html': HtmlIcon, // was LanguageIcon
|
||||
'url-page-null': TextureIcon,
|
||||
'url-page-image': ImageOutlinedIcon,
|
||||
'url-page-image': PhImageSquare,
|
||||
'youtube-transcript': YouTubeIcon,
|
||||
'youtube-transcript-simple': YouTubeIcon,
|
||||
'ego-fragments-inlined': TelegramIcon,
|
||||
'unhandled': TextureIcon,
|
||||
};
|
||||
|
||||
function attachmentIcons(attachmentDraft: AttachmentDraft, noTooltips: boolean, onViewImageRefPart: (imageRefPart: DMessageImageRefPart) => void) {
|
||||
function attachmentIcons(attachmentDraft: AttachmentDraft, noTooltips: boolean, onViewImageRefPart?: (imageRefPart: DMessageImageRefPart) => void) {
|
||||
const activeConverters = attachmentDraft.converters.filter(c => c.isActive);
|
||||
if (activeConverters.length === 0)
|
||||
return null;
|
||||
@@ -139,7 +139,7 @@ function attachmentIcons(attachmentDraft: AttachmentDraft, noTooltips: boolean,
|
||||
outputSingleImageRefDBlobs = [fragment.part.dataRef];
|
||||
}
|
||||
|
||||
const handleViewFirstImage = (e: React.MouseEvent) => {
|
||||
const handleViewFirstImage = !onViewImageRefPart ? undefined : (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const fragment = attachmentDraft.outputFragments[0];
|
||||
@@ -224,17 +224,19 @@ function attachmentLabelText(attachmentDraft: AttachmentDraft): string {
|
||||
}
|
||||
|
||||
|
||||
export const LLMAttachmentButtonMemo = React.memo(LLMAttachmentButton);
|
||||
export const AttachmentDraftButtonMemo = React.memo(AttachmentDraftButton);
|
||||
|
||||
function LLMAttachmentButton(props: {
|
||||
llmAttachment: LLMAttachmentDraft,
|
||||
function AttachmentDraftButton(props: {
|
||||
draft: AttachmentDraft,
|
||||
enrichment?: IAttachmentEnrichment,
|
||||
menuShown: boolean,
|
||||
onToggleMenu: (attachmentDraftId: AttachmentDraftId, anchor: HTMLAnchorElement) => void,
|
||||
onViewImageRefPart: (imageRefPart: DMessageImageRefPart) => void,
|
||||
onViewImageRefPart?: (imageRefPart: DMessageImageRefPart) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const { attachmentDraft: draft, llmSupportsAllFragments } = props.llmAttachment;
|
||||
const { draft, enrichment } = props;
|
||||
const llmSupportsAllFragments = enrichment?.isCompatible(draft) ?? true;
|
||||
|
||||
const isInputLoading = draft.inputLoading;
|
||||
const isInputError = !!draft.inputError;
|
||||
+21
-23
@@ -21,10 +21,9 @@ import { humanReadableBytes } from '~/common/util/textUtils';
|
||||
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
|
||||
import { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
|
||||
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts_slice';
|
||||
import type { LLMAttachmentDraft } from './useLLMAttachmentDrafts';
|
||||
import type { LLMAttachmentDraftsAction } from './LLMAttachmentsList';
|
||||
import type { AttachmentDraft, AttachmentDraftId, AttachmentDraftsAction } from '../attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from '../store-attachment-drafts_slice';
|
||||
import type { IAttachmentEnrichment } from '../llm-enrichment/attachment.enrichment';
|
||||
|
||||
|
||||
// configuration
|
||||
@@ -49,16 +48,17 @@ const actionButtonsSx: SxProps = {
|
||||
};
|
||||
|
||||
|
||||
export function LLMAttachmentMenu(props: {
|
||||
export function AttachmentDraftMenu(props: {
|
||||
attachmentDraftsStoreApi: AttachmentDraftsStoreApi,
|
||||
llmAttachmentDraft: LLMAttachmentDraft,
|
||||
draft: AttachmentDraft,
|
||||
enrichment?: IAttachmentEnrichment,
|
||||
menuAnchor: HTMLAnchorElement,
|
||||
isPositionFirst: boolean,
|
||||
isPositionLast: boolean,
|
||||
onClose: () => void,
|
||||
onDraftAction?: (attachmentDraftId: AttachmentDraftId, actionId: LLMAttachmentDraftsAction) => void,
|
||||
onViewDocPart: (docPart: DMessageDocPart) => void,
|
||||
onViewImageRefPart: (imageRefPart: DMessageImageRefPart) => void
|
||||
onDraftAction?: (attachmentDraftId: AttachmentDraftId, actionId: AttachmentDraftsAction) => void,
|
||||
onViewDocPart?: (docPart: DMessageDocPart) => void,
|
||||
onViewImageRefPart?: (imageRefPart: DMessageImageRefPart) => void
|
||||
}) {
|
||||
|
||||
// state
|
||||
@@ -72,12 +72,10 @@ export function LLMAttachmentMenu(props: {
|
||||
|
||||
const isUnmoveable = props.isPositionFirst && props.isPositionLast;
|
||||
|
||||
const {
|
||||
attachmentDraft: draft,
|
||||
llmSupportsAllFragments,
|
||||
llmSupportsTextFragments,
|
||||
llmTokenCountApprox,
|
||||
} = props.llmAttachmentDraft;
|
||||
const { draft, enrichment } = props;
|
||||
const llmSupportsAllFragments = enrichment?.isCompatible(draft) ?? true;
|
||||
const llmSupportsTextFragments = enrichment?.supportsTextInline(draft) ?? false;
|
||||
const llmTokenCountApprox = enrichment?.estimateTokens(draft) ?? null;
|
||||
|
||||
const {
|
||||
id: draftId,
|
||||
@@ -145,13 +143,13 @@ export function LLMAttachmentMenu(props: {
|
||||
const handleViewImageRefPart = React.useCallback((event: React.MouseEvent, imageRefPart: DMessageImageRefPart) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onViewImageRefPart(imageRefPart);
|
||||
onViewImageRefPart?.(imageRefPart);
|
||||
}, [onViewImageRefPart]);
|
||||
|
||||
const handleViewDocPart = React.useCallback((event: React.MouseEvent, docPart: DMessageDocPart) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onViewDocPart(docPart);
|
||||
onViewDocPart?.(docPart);
|
||||
}, [onViewDocPart]);
|
||||
|
||||
const canHaveDetails = !!draftInput && !isConverting;
|
||||
@@ -344,7 +342,7 @@ export function LLMAttachmentMenu(props: {
|
||||
<Typography level='body-sm' textColor='success.softColor' sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
Input: {draftInput.urlImage.mimeType} · {draftInput.urlImage.width}x{draftInput.urlImage.height}{!draftInput.urlImage.imgDataUrl?.length ? '' : ` · ${humanReadableBytes(draftInput.urlImage.imgDataUrl.length)}`}
|
||||
|
||||
<Chip component='span' size='sm' color='success' variant='soft' startDecorator={<VisibilityIcon />} onClick={(event) => {
|
||||
{!!onViewImageRefPart && <Chip component='span' size='sm' color='success' variant='soft' startDecorator={<VisibilityIcon />} onClick={(event) => {
|
||||
if (draftInput?.urlImage?.imgDataUrl) {
|
||||
// Invoke the viewer but with a virtual 'temp' part description to see this preview image
|
||||
handleViewImageRefPart(event, {
|
||||
@@ -360,7 +358,7 @@ export function LLMAttachmentMenu(props: {
|
||||
}
|
||||
}} sx={{ ml: 'auto' }}>
|
||||
view input
|
||||
</Chip>
|
||||
</Chip>}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -390,9 +388,9 @@ export function LLMAttachmentMenu(props: {
|
||||
{/* copy*/}
|
||||
{/*</Chip>*/}
|
||||
<ButtonGroup size='sm' color='primary' variant='outlined' sx={actionButtonsSx}>
|
||||
<Button startDecorator={<VisibilityIcon sx={{ fontSize: 'md' }} />} onClick={(event) => handleViewDocPart(event, part)}>
|
||||
{!!onViewDocPart && <Button startDecorator={<VisibilityIcon sx={{ fontSize: 'md' }} />} onClick={(event) => handleViewDocPart(event, part)}>
|
||||
view
|
||||
</Button>
|
||||
</Button>}
|
||||
<Button onClick={(event) => handleCopyToClipboard(event, part.data.text)}/* endDecorator={<ContentCopyIcon />} */>
|
||||
copy
|
||||
</Button>
|
||||
@@ -419,12 +417,12 @@ export function LLMAttachmentMenu(props: {
|
||||
{/* del*/}
|
||||
{/*</Chip>}*/}
|
||||
<ButtonGroup size='sm' color='primary' variant='outlined' sx={actionButtonsSx}>
|
||||
<Button
|
||||
{!!onViewImageRefPart && <Button
|
||||
startDecorator={<VisibilityIcon sx={{ fontSize: 'md' }} />}
|
||||
onClick={(event) => handleViewImageRefPart(event, legacyImageRefPart)}
|
||||
>
|
||||
view
|
||||
</Button>
|
||||
</Button>}
|
||||
{isOutputMultiple && (
|
||||
<Button
|
||||
color='warning'
|
||||
+55
-64
@@ -1,32 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, CircularProgress, IconButton, ListDivider, ListItemDecorator, MenuItem } from '@mui/joy';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import { Box, IconButton, ListDivider, ListItemDecorator, MenuItem } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import type { AgiAttachmentPromptsData } from '~/modules/aifn/agiattachmentprompts/useAgiAttachmentPrompts';
|
||||
|
||||
import type { DMessageDocPart, DMessageImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { CloseablePopup } from '~/common/components/CloseablePopup';
|
||||
import { ConfirmationModal } from '~/common/components/modals/ConfirmationModal';
|
||||
import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents';
|
||||
|
||||
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts_slice';
|
||||
import type { DMessageDocPart, DMessageImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import type { AttachmentDraft, AttachmentDraftId, AttachmentDraftsAction } from '../attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from '../store-attachment-drafts_slice';
|
||||
import type { AttachmentEnrichmentSummary, IAttachmentEnrichment } from '../llm-enrichment/attachment.enrichment';
|
||||
|
||||
import { ViewImageRefPartModal } from '../../message/fragments-content/ViewImageRefPartModal';
|
||||
|
||||
import type { LLMAttachmentDraft } from './useLLMAttachmentDrafts';
|
||||
import { LLMAttachmentButtonMemo } from './LLMAttachmentButton';
|
||||
import { LLMAttachmentMenu } from './LLMAttachmentMenu';
|
||||
import { LLMAttachmentsPromptsButtonMemo } from './LLMAttachmentsPromptsButton';
|
||||
import { ViewDocPartModal } from '../../message/fragments-content/ViewDocPartModal';
|
||||
|
||||
|
||||
export type LLMAttachmentDraftsAction = 'inline-text' | 'copy-text';
|
||||
import { AttachmentDraftButtonMemo } from './AttachmentDraftButton';
|
||||
import { AttachmentDraftMenu } from './AttachmentDraftMenu';
|
||||
|
||||
|
||||
const _style = {
|
||||
@@ -62,15 +52,21 @@ const _style = {
|
||||
|
||||
|
||||
/**
|
||||
* Renderer of attachment drafts, with menus, etc.
|
||||
* Generic renderer of attachment drafts, with menus, etc.
|
||||
* Portable across Composer, ChatMessage edit, FollowUps, etc.
|
||||
*/
|
||||
export function LLMAttachmentsList(props: {
|
||||
agiAttachmentPrompts?: AgiAttachmentPromptsData,
|
||||
export function AttachmentDraftsList(props: {
|
||||
attachmentDraftsStoreApi: AttachmentDraftsStoreApi,
|
||||
canInlineSomeFragments: boolean,
|
||||
llmAttachmentDrafts: LLMAttachmentDraft[],
|
||||
onAttachmentDraftsAction?: (attachmentDraftId: AttachmentDraftId | null, actionId: LLMAttachmentDraftsAction) => void,
|
||||
attachmentDrafts: AttachmentDraft[],
|
||||
enrichment?: IAttachmentEnrichment,
|
||||
enrichmentSummary?: AttachmentEnrichmentSummary,
|
||||
buttonsCanWrap?: boolean,
|
||||
onAttachmentDraftsAction?: (attachmentDraftId: AttachmentDraftId | null, actionId: AttachmentDraftsAction) => void,
|
||||
// optional rendering props
|
||||
startDecorator?: React.ReactNode,
|
||||
renderDocViewer?: (docPart: DMessageDocPart, onClose: () => void) => React.ReactNode,
|
||||
renderImageViewer?: (imageRefPart: DMessageImageRefPart, onClose: () => void) => React.ReactNode,
|
||||
renderOverallMenuExtra?: () => React.ReactNode,
|
||||
}) {
|
||||
|
||||
// state
|
||||
@@ -82,15 +78,20 @@ export function LLMAttachmentsList(props: {
|
||||
|
||||
// derived state
|
||||
|
||||
const { agiAttachmentPrompts, canInlineSomeFragments, llmAttachmentDrafts } = props;
|
||||
const hasAttachments = llmAttachmentDrafts.length >= 1;
|
||||
const { attachmentDrafts, enrichmentSummary } = props;
|
||||
const canInlineSomeFragments = enrichmentSummary?.anyInlinable ?? false;
|
||||
const hasAttachments = attachmentDrafts.length >= 1;
|
||||
|
||||
// ref to optimize
|
||||
const attachmentDraftsRef = React.useRef(attachmentDrafts);
|
||||
attachmentDraftsRef.current = attachmentDrafts;
|
||||
|
||||
// derived item menu state
|
||||
|
||||
const itemMenuAnchor = draftMenu?.anchor;
|
||||
const itemMenuAttachmentDraftId = draftMenu?.attachmentDraftId;
|
||||
const itemMenuAttachmentDraft = itemMenuAttachmentDraftId ? llmAttachmentDrafts.find(la => la.attachmentDraft.id === draftMenu.attachmentDraftId) : undefined;
|
||||
const itemMenuIndex = itemMenuAttachmentDraft ? llmAttachmentDrafts.indexOf(itemMenuAttachmentDraft) : -1;
|
||||
const itemMenuAttachmentDraft = itemMenuAttachmentDraftId ? attachmentDrafts.find(a => a.id === draftMenu.attachmentDraftId) : undefined;
|
||||
const itemMenuIndex = itemMenuAttachmentDraft ? attachmentDrafts.indexOf(itemMenuAttachmentDraft) : -1;
|
||||
|
||||
|
||||
// overall menu
|
||||
@@ -100,10 +101,10 @@ export function LLMAttachmentsList(props: {
|
||||
const handleOverallMenuHide = React.useCallback(() => setOverallMenuAnchor(null), []);
|
||||
|
||||
const handleOverallMenuToggle = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.shiftKey && console.log('llmAttachmentDrafts', llmAttachmentDrafts);
|
||||
event.shiftKey && console.log('llmAttachmentDrafts', attachmentDraftsRef.current);
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
setOverallMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
}, [llmAttachmentDrafts]);
|
||||
}, []);
|
||||
|
||||
const handleOverallCopyText = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
@@ -121,13 +122,13 @@ export function LLMAttachmentsList(props: {
|
||||
open onClose={onUserReject} onPositive={() => onResolve(true)}
|
||||
title='Confirm Removal'
|
||||
positiveActionText='Remove All'
|
||||
confirmationText={`This action will remove all (${llmAttachmentDrafts.length}) attachments. Do you want to proceed?`}
|
||||
confirmationText={`This action will remove all (${attachmentDraftsRef.current.length}) attachments. Do you want to proceed?`}
|
||||
/>,
|
||||
)) {
|
||||
handleOverallMenuHide();
|
||||
props.attachmentDraftsStoreApi.getState().removeAllAttachmentDrafts();
|
||||
}
|
||||
}, [handleOverallMenuHide, llmAttachmentDrafts.length, props.attachmentDraftsStoreApi, showPromisedOverlay]);
|
||||
}, [handleOverallMenuHide, props.attachmentDraftsStoreApi, showPromisedOverlay]);
|
||||
|
||||
|
||||
// item menu
|
||||
@@ -139,7 +140,7 @@ export function LLMAttachmentsList(props: {
|
||||
setDraftMenu(prev => prev?.attachmentDraftId === attachmentDraftId ? null : { anchor, attachmentDraftId });
|
||||
}, [handleOverallMenuHide]);
|
||||
|
||||
const handleDraftAction = React.useCallback((attachmentDraftId: AttachmentDraftId, actionId: LLMAttachmentDraftsAction) => {
|
||||
const handleDraftAction = React.useCallback((attachmentDraftId: AttachmentDraftId, actionId: AttachmentDraftsAction) => {
|
||||
// pass-through, but close the menu as well, as the action is destructive for the caller
|
||||
handleDraftMenuHide();
|
||||
onAttachmentDraftsAction?.(attachmentDraftId, actionId);
|
||||
@@ -174,19 +175,18 @@ export function LLMAttachmentsList(props: {
|
||||
{/* Horizontally scrollable */}
|
||||
<Box sx={!props.buttonsCanWrap ? _style.barScrollX : _style.barWraps}>
|
||||
|
||||
{/* AI Suggestion Button */}
|
||||
{(!!agiAttachmentPrompts && (agiAttachmentPrompts.isVisible || agiAttachmentPrompts.hasData)) && (
|
||||
<LLMAttachmentsPromptsButtonMemo data={agiAttachmentPrompts} />
|
||||
)}
|
||||
{/* Slot: before buttons (e.g. AI Suggestion Button) */}
|
||||
{props.startDecorator}
|
||||
|
||||
{/* Attachment Buttons */}
|
||||
{llmAttachmentDrafts.map((llmAttachment) =>
|
||||
<LLMAttachmentButtonMemo
|
||||
key={llmAttachment.attachmentDraft.id}
|
||||
llmAttachment={llmAttachment}
|
||||
menuShown={llmAttachment.attachmentDraft.id === itemMenuAttachmentDraftId}
|
||||
{attachmentDrafts.map((draft) =>
|
||||
<AttachmentDraftButtonMemo
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
enrichment={props.enrichment}
|
||||
menuShown={draft.id === itemMenuAttachmentDraftId}
|
||||
onToggleMenu={handleDraftMenuToggle}
|
||||
onViewImageRefPart={handleViewImageRefPart}
|
||||
onViewImageRefPart={!props.renderImageViewer ? undefined : handleViewImageRefPart}
|
||||
/>,
|
||||
)}
|
||||
|
||||
@@ -207,28 +207,25 @@ export function LLMAttachmentsList(props: {
|
||||
|
||||
|
||||
{/* Image Viewer Modal - when opening attachment images */}
|
||||
{!!viewerImageRefPart && (
|
||||
<ViewImageRefPartModal imageRefPart={viewerImageRefPart} onClose={handleCloseImageViewer} />
|
||||
)}
|
||||
{!!viewerImageRefPart && props.renderImageViewer?.(viewerImageRefPart, handleCloseImageViewer)}
|
||||
|
||||
{/* Text Viewer Modal */}
|
||||
{!!viewerDocPart && (
|
||||
<ViewDocPartModal docPart={viewerDocPart} onClose={handleCloseDocPartViewer} />
|
||||
)}
|
||||
{!!viewerDocPart && props.renderDocViewer?.(viewerDocPart, handleCloseDocPartViewer)}
|
||||
|
||||
|
||||
{/* Single LLM Attachment Draft Menu */}
|
||||
{/* Single Attachment Draft Menu */}
|
||||
{!!itemMenuAnchor && !!itemMenuAttachmentDraft && !!props.attachmentDraftsStoreApi && (
|
||||
<LLMAttachmentMenu
|
||||
<AttachmentDraftMenu
|
||||
attachmentDraftsStoreApi={props.attachmentDraftsStoreApi}
|
||||
llmAttachmentDraft={itemMenuAttachmentDraft}
|
||||
draft={itemMenuAttachmentDraft}
|
||||
enrichment={props.enrichment}
|
||||
menuAnchor={itemMenuAnchor}
|
||||
isPositionFirst={itemMenuIndex === 0}
|
||||
isPositionLast={itemMenuIndex === llmAttachmentDrafts.length - 1}
|
||||
isPositionLast={itemMenuIndex === attachmentDrafts.length - 1}
|
||||
onClose={handleDraftMenuHide}
|
||||
onDraftAction={!onAttachmentDraftsAction ? undefined : handleDraftAction}
|
||||
onViewDocPart={handleViewDocPart}
|
||||
onViewImageRefPart={handleViewImageRefPart}
|
||||
onViewDocPart={!props.renderDocViewer ? undefined : handleViewDocPart}
|
||||
onViewImageRefPart={!props.renderImageViewer ? undefined : handleViewImageRefPart}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -241,14 +238,8 @@ export function LLMAttachmentsList(props: {
|
||||
minWidth={200}
|
||||
placement='top-start'
|
||||
>
|
||||
{/* uses the agiAttachmentPrompts to imagine what the user will ask aboud those */}
|
||||
{!!agiAttachmentPrompts && (
|
||||
<MenuItem color='primary' variant='soft' onClick={agiAttachmentPrompts.refetch} disabled={!hasAttachments || agiAttachmentPrompts.isFetching}>
|
||||
<ListItemDecorator>{agiAttachmentPrompts.isFetching ? <CircularProgress size='sm' /> : <AutoFixHighIcon />}</ListItemDecorator>
|
||||
What can I do?
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!agiAttachmentPrompts && <ListDivider />}
|
||||
{/* Slot: extra overall menu items (e.g. "What can I do?") */}
|
||||
{props.renderOverallMenuExtra?.()}
|
||||
|
||||
{!!onAttachmentDraftsAction && <MenuItem onClick={handleOverallInlineText} disabled={!canInlineSomeFragments}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
@@ -262,10 +253,10 @@ export function LLMAttachmentsList(props: {
|
||||
|
||||
<MenuItem onClick={handleOverallClear}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Remove All{llmAttachmentDrafts.length > 5 ? <span style={{ opacity: 0.5 }}> {llmAttachmentDrafts.length} attachments</span> : null}
|
||||
Remove All{attachmentDrafts.length > 5 ? <span style={{ opacity: 0.5 }}> {attachmentDrafts.length} attachments</span> : null}
|
||||
</MenuItem>
|
||||
</CloseablePopup>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import * as React from 'react';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import type { FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Button, Checkbox, ColorPaletteProp, Dropdown, IconButton, ListDivider, ListItem, ListItemDecorator, Menu, MenuButton, MenuItem } from '@mui/joy';
|
||||
import AddRoundedIcon from '@mui/icons-material/AddRounded';
|
||||
import AddToDriveRoundedIcon from '@mui/icons-material/AddToDriveRounded';
|
||||
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
||||
import CameraAltOutlinedIcon from '@mui/icons-material/CameraAltOutlined';
|
||||
import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo';
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
import LanguageRoundedIcon from '@mui/icons-material/LanguageRounded';
|
||||
import ScreenshotMonitorIcon from '@mui/icons-material/ScreenshotMonitor';
|
||||
|
||||
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { ButtonAttachFilesMemo, openFileForAttaching } from '~/common/components/ButtonAttachFiles';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { takeScreenCapture } from '~/common/util/screenCaptureUtils';
|
||||
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
|
||||
|
||||
import { ButtonAttachCameraMemo } from './ButtonAttachCamera';
|
||||
import { ButtonAttachClipboardMemo } from './ButtonAttachClipboard';
|
||||
import { ButtonAttachGoogleDriveMemo } from './ButtonAttachGoogleDrive';
|
||||
import { ButtonAttachScreenCaptureMemo } from './ButtonAttachScreenCapture';
|
||||
import { ButtonAttachWebMemo } from './ButtonAttachWeb';
|
||||
import { hasGoogleDriveCapability } from './useGoogleDrivePicker';
|
||||
|
||||
|
||||
// configuration
|
||||
export const ATTACH_BUTTON_RADIUS = '18px'; // for the rich (non-compact) menu button
|
||||
|
||||
|
||||
// animations for the rich (non-compact) menu
|
||||
const animationMenu = keyframes` from {opacity: 0;} to {opacity: 1;}`;
|
||||
const animationMenuItem = keyframes` from {opacity: 0;transform: translateY(-6px);} to {opacity: 1;transform: translateY(0);}`;
|
||||
|
||||
const _style = {
|
||||
menuItem: {
|
||||
// pl: 3,
|
||||
// pr: 2,
|
||||
py: 0.5, // was 1
|
||||
minHeight: 60,
|
||||
// minHeight: '3.25rem', // now 52, was 60
|
||||
},
|
||||
menuItemContent: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.125,
|
||||
},
|
||||
menuItemContentDisabled: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.125,
|
||||
opacity: 0.5,
|
||||
},
|
||||
menuItemName: {
|
||||
typography: 'title-sm',
|
||||
fontWeight: 600,
|
||||
// fontSize: '15px',
|
||||
},
|
||||
menuItemDescription: {
|
||||
fontSize: 'xs',
|
||||
color: 'text.tertiary',
|
||||
// fontWeight: 400,
|
||||
},
|
||||
liveFeedButton: {
|
||||
ml: 1,
|
||||
// outline: '1px solid transparent',
|
||||
// '&:hover': {
|
||||
// outlineColor: 'currentColor',
|
||||
// },
|
||||
},
|
||||
} as const satisfies Record<string, SxProps>;
|
||||
|
||||
|
||||
// Live feed record button - returns null if onClick is undefined
|
||||
function LiveFeedButton(props: { isActive: boolean, tooltip: string, onClick: () => void }) {
|
||||
return (
|
||||
<TooltipOutlined title={props.tooltip} placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant={props.isActive ? 'solid' : 'outlined'}
|
||||
color='danger'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClick();
|
||||
}}
|
||||
sx={_style.liveFeedButton}
|
||||
>
|
||||
<FiberManualRecordIcon sx={{ fontSize: 16 }} />
|
||||
{/*{props.isActive ? <AddRoundedIcon sx={{ fontSize: 18 }} /> : <FiberManualRecordIcon sx={{ fontSize: 16 }} />}*/}
|
||||
</IconButton>
|
||||
</TooltipOutlined>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Rich menu item (used in menu-rich mode)
|
||||
function RichMenuItem(props: {
|
||||
name: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
Icon: React.ComponentType;
|
||||
onClick: () => void;
|
||||
delay?: number;
|
||||
disabled?: boolean;
|
||||
color?: ColorPaletteProp;
|
||||
endAction?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MenuItem
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
color={props.color}
|
||||
sx={!props.delay ? _style.menuItem : {
|
||||
..._style.menuItem,
|
||||
animation: `${animationMenuItem} 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${props.delay}s both`,
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<props.Icon />
|
||||
</ListItemDecorator>
|
||||
<Box sx={props.disabled ? _style.menuItemContentDisabled : _style.menuItemContent}>
|
||||
<Box sx={_style.menuItemName}>
|
||||
{props.name}
|
||||
</Box>
|
||||
<Box sx={_style.menuItemDescription}>
|
||||
{props.description}
|
||||
</Box>
|
||||
</Box>
|
||||
{props.endAction && (
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center' }}>
|
||||
{props.endAction}
|
||||
</Box>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Auto-download toggle (shown when browsing capability exists)
|
||||
function AutoDownloadToggle(props: { delay?: number }) {
|
||||
|
||||
// external state
|
||||
const enableComposerAttach = useBrowseStore(s => s.enableComposerAttach);
|
||||
|
||||
const handleToggle = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
useBrowseStore.getState().setEnableComposerAttach(event.target.checked);
|
||||
}, []);
|
||||
|
||||
return <>
|
||||
|
||||
<ListDivider inset='gutter' sx={{ my: 1 }} />
|
||||
|
||||
<ListItem
|
||||
sx={{
|
||||
..._style.menuItem,
|
||||
animation: `${animationMenuItem} 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${props.delay}s both`,
|
||||
}}
|
||||
// onClick={(event) => {
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
// setEnableComposerAttach(!enableComposerAttach);
|
||||
// }}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
color='neutral'
|
||||
checked={enableComposerAttach}
|
||||
onChange={handleToggle}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
sx={{ ml: 0.375 }}
|
||||
/>
|
||||
</ListItemDecorator>
|
||||
<Box sx={_style.menuItemContent}>
|
||||
<Box sx={{ typography: 'title-sm' }}>
|
||||
Attach pasted URLs
|
||||
</Box>
|
||||
<Box sx={_style.menuItemDescription}>
|
||||
Download and attach pasted web links
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
</>;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Portable attachment sources component.
|
||||
*
|
||||
* Three modes:
|
||||
* - **menu-compact**: Mobile-style - icon trigger, simple MenuItems (no descriptions/animations)
|
||||
* - **menu-rich**: Desktop-style - labeled button trigger, rich items with descriptions and animations
|
||||
* - **inline-buttons**: Individual source buttons rendered inline (no dropdown)
|
||||
*/
|
||||
export const AttachmentSourcesMemo = React.memo(AttachmentSources);
|
||||
|
||||
function AttachmentSources(props: {
|
||||
// mode
|
||||
mode: 'menu-compact' | 'menu-rich' | 'inline-buttons' | 'menu-message',
|
||||
color?: ColorPaletteProp, // menu-rich and inline-buttons
|
||||
richButtonStandOut?: boolean, // menu-rich only
|
||||
menuButton?: React.ReactNode, // custom MenuButton trigger for menu-compact/menu-message modes
|
||||
// source availability - note that hasGoogleDriveCapability is local
|
||||
canBrowse: boolean, // whether browsing is available (for Web button and showing the auto-attach toggle)
|
||||
hasCamera: boolean,
|
||||
// hasGoogleDrive: boolean, // it's now local: hasGoogleDriveCapability
|
||||
hasScreenCapture: boolean,
|
||||
// configuration
|
||||
onlyImages?: boolean, // makes clipboard/drive/web unavailable
|
||||
// callbacks
|
||||
onAttachClipboard: () => void,
|
||||
onAttachFiles: (files: FileWithHandle[], errorMessage: string | null) => void,
|
||||
onAttachScreenCapture: (file: File) => void,
|
||||
onOpenCamera: () => void,
|
||||
onOpenGoogleDrivePicker?: () => void, // optional because requires additional external setup (e.g. user-storage of tokens)
|
||||
onOpenWebInput: () => void,
|
||||
// live feeds - end action buttons (presence if the callback is set, active state if the boolean is true)
|
||||
hasActiveCameraFeed?: boolean,
|
||||
hasActiveScreenFeed?: boolean,
|
||||
onStartLiveCameraFeed?: () => void,
|
||||
onStartLiveScreenFeed?: () => void,
|
||||
}) {
|
||||
|
||||
// state (screen capture - used in menu modes where the component handles the capture)
|
||||
const [capturingScreen, setCapturingScreen] = React.useState(false);
|
||||
const [screenCaptureError, setScreenCaptureError] = React.useState<string | null>(null);
|
||||
|
||||
|
||||
// handlers
|
||||
|
||||
const { onAttachFiles, onAttachScreenCapture } = props;
|
||||
|
||||
const handleAttachFilePicker = React.useCallback(() => {
|
||||
return openFileForAttaching(true, onAttachFiles);
|
||||
}, [onAttachFiles]);
|
||||
|
||||
const handleTakeScreenCapture = React.useCallback(async () => {
|
||||
setScreenCaptureError(null);
|
||||
setCapturingScreen(true);
|
||||
try {
|
||||
const file = await takeScreenCapture();
|
||||
file && onAttachScreenCapture(file);
|
||||
} catch (error: any) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setScreenCaptureError(message);
|
||||
}
|
||||
setCapturingScreen(false);
|
||||
}, [onAttachScreenCapture]);
|
||||
|
||||
|
||||
// inline-buttons mode - individual buttons rendered flat (no dropdown)
|
||||
if (props.mode === 'inline-buttons')
|
||||
return <>
|
||||
|
||||
{/* Files */}
|
||||
<ButtonAttachFilesMemo color={props.color} onAttachFiles={props.onAttachFiles} /*fullWidth*/ multiple />
|
||||
|
||||
{/* Web */}
|
||||
{!props.onlyImages && <ButtonAttachWebMemo color={props.color} disabled={!props.canBrowse} onOpenWebInput={props.onOpenWebInput} />}
|
||||
|
||||
{/* Google Drive */}
|
||||
{hasGoogleDriveCapability && !props.onlyImages && !!props.onOpenGoogleDrivePicker && (
|
||||
<ButtonAttachGoogleDriveMemo color={props.color} onOpenGoogleDrivePicker={props.onOpenGoogleDrivePicker} />
|
||||
)}
|
||||
|
||||
{/* Clipboard */}
|
||||
{supportsClipboardRead() && !props.onlyImages && (
|
||||
<ButtonAttachClipboardMemo color={props.color} onAttachClipboard={props.onAttachClipboard} />
|
||||
)}
|
||||
|
||||
{/* Screen Capture */}
|
||||
{props.hasScreenCapture && (
|
||||
<ButtonAttachScreenCaptureMemo color={props.color} onAttachScreenCapture={props.onAttachScreenCapture} />
|
||||
)}
|
||||
|
||||
{/* Camera */}
|
||||
{props.hasCamera && (
|
||||
<ButtonAttachCameraMemo color={props.color} onOpenCamera={props.onOpenCamera} />
|
||||
)}
|
||||
|
||||
</>;
|
||||
|
||||
|
||||
// menu-compact mode (mobile) - simple icon trigger with flat menu items
|
||||
if (props.mode === 'menu-compact' || props.mode === 'menu-message') {
|
||||
const isMessage = props.mode === 'menu-message';
|
||||
return <>
|
||||
|
||||
<Dropdown>
|
||||
{props.menuButton ? props.menuButton : !isMessage ? (
|
||||
<MenuButton slots={{ root: IconButton }}>
|
||||
<AddRoundedIcon />
|
||||
</MenuButton>
|
||||
) : (
|
||||
<MenuButton slots={{ root: Button }} slotProps={{
|
||||
root: {
|
||||
size: 'sm',
|
||||
variant: 'soft',
|
||||
color: 'warning',
|
||||
startDecorator: <AddRoundedIcon />,
|
||||
sx: { minHeight: '2.25rem', m: -0.25 /* absorb parent's padding */ },
|
||||
},
|
||||
} as const}>
|
||||
Attach
|
||||
</MenuButton>
|
||||
)}
|
||||
<Menu sx={{ '--List-padding': '0.5rem', zIndex: themeZIndexOverMobileDrawer /* menu-compact or menu-message: above dialogs */ }}>
|
||||
|
||||
{/* Files */}
|
||||
{/*<MenuItem onClick={handleAttachFilePicker}>*/}
|
||||
{/* <ListItemDecorator><AttachFileRoundedIcon /></ListItemDecorator>*/}
|
||||
{/* {props.onlyImages ? 'Images' : 'File'}*/}
|
||||
{/*</MenuItem>*/}
|
||||
<RichMenuItem name={props.onlyImages ? 'Images' : 'Files'} description='PDF, DOCX, images, code' color={props.color} Icon={AttachFileRoundedIcon} onClick={handleAttachFilePicker} />
|
||||
|
||||
{/* Web */}
|
||||
{!props.onlyImages && /*props.canBrowse &&*/ (
|
||||
// <MenuItem onClick={props.onOpenWebInput} disabled={!props.canBrowse}>
|
||||
// <ListItemDecorator><LanguageRoundedIcon /></ListItemDecorator>
|
||||
// Web
|
||||
// </MenuItem>
|
||||
<RichMenuItem name='Web' description='Import from web pages' color={props.color} Icon={LanguageRoundedIcon} onClick={props.onOpenWebInput} disabled={!props.canBrowse} />
|
||||
)}
|
||||
|
||||
{/* Google Drive */}
|
||||
{!props.onlyImages && hasGoogleDriveCapability && !!props.onOpenGoogleDrivePicker && (
|
||||
// <MenuItem onClick={props.onOpenGoogleDrivePicker}>
|
||||
// <ListItemDecorator><AddToDriveRoundedIcon /></ListItemDecorator>
|
||||
// Drive
|
||||
// </MenuItem>
|
||||
<RichMenuItem name='Drive' description='Attach Google Drive files' color={props.color} Icon={AddToDriveRoundedIcon} onClick={props.onOpenGoogleDrivePicker} />
|
||||
)}
|
||||
|
||||
{/* Clipboard */}
|
||||
{!props.onlyImages && supportsClipboardRead() && (
|
||||
// <MenuItem onClick={props.onAttachClipboard}>
|
||||
// <ListItemDecorator><ContentPasteGoIcon /></ListItemDecorator>
|
||||
// Paste
|
||||
// </MenuItem>
|
||||
<RichMenuItem name='Clipboard' description='Auto-convert to the best format' color={props.color} Icon={ContentPasteGoIcon} onClick={props.onAttachClipboard} />
|
||||
)}
|
||||
|
||||
{/* Screen Capture */}
|
||||
{props.hasScreenCapture && (
|
||||
// <MenuItem onClick={handleTakeScreenCapture} disabled={capturingScreen}>
|
||||
// <ListItemDecorator><ScreenshotMonitorIcon /></ListItemDecorator>
|
||||
// Screen
|
||||
// </MenuItem>
|
||||
<RichMenuItem
|
||||
name='Screen'
|
||||
color={screenCaptureError ? 'danger' : props.color}
|
||||
description={screenCaptureError ? `Error: ${screenCaptureError}` : 'Capture tabs, apps, and screens'}
|
||||
Icon={ScreenshotMonitorIcon}
|
||||
disabled={capturingScreen}
|
||||
onClick={handleTakeScreenCapture}
|
||||
endAction={!isMessage && props.onStartLiveScreenFeed && <LiveFeedButton isActive={!!props.hasActiveScreenFeed} tooltip='Live Screen chat' onClick={props.onStartLiveScreenFeed} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Camera */}
|
||||
{props.hasCamera && isMessage && (
|
||||
// <MenuItem onClick={props.onOpenCamera}>
|
||||
// <ListItemDecorator><CameraAltOutlinedIcon /></ListItemDecorator>
|
||||
// Camera
|
||||
// </MenuItem>
|
||||
<RichMenuItem
|
||||
name='Camera'
|
||||
color={props.color}
|
||||
Icon={CameraAltOutlinedIcon}
|
||||
description='Capture photos with optional OCR'
|
||||
onClick={props.onOpenCamera}
|
||||
endAction={!isMessage && props.onStartLiveCameraFeed && <LiveFeedButton isActive={!!props.hasActiveCameraFeed} tooltip='Live Camera chat' onClick={props.onStartLiveCameraFeed} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
|
||||
{/* [mobile] Responsive Camera OCR button */}
|
||||
{props.hasCamera && !isMessage && <ButtonAttachCameraMemo isMobile color={props.color} onOpenCamera={props.onOpenCamera} />}
|
||||
|
||||
</>;
|
||||
}
|
||||
|
||||
|
||||
// menu-rich mode (desktop) - labeled button trigger with animated, descriptive menu items
|
||||
return (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
slots={{ root: Button }}
|
||||
slotProps={{
|
||||
root: {
|
||||
// size: 'sm',
|
||||
variant: 'plain',
|
||||
color: props.color,
|
||||
startDecorator: <AddRoundedIcon />,
|
||||
fullWidth: true, // to match other buttons in the col
|
||||
sx: {
|
||||
minWidth: 100,
|
||||
justifyContent: 'flex-start',
|
||||
borderRadius: ATTACH_BUTTON_RADIUS,
|
||||
textWrap: 'nowrap',
|
||||
...(props.richButtonStandOut && {
|
||||
backgroundColor: 'background.popup',
|
||||
border: '1px solid',
|
||||
borderColor: `${props.color || 'neutral'}.outlinedBorder`,
|
||||
}),
|
||||
// when aria-expanded is true (menu open), remove top border radius
|
||||
'&[aria-expanded="true"]': {
|
||||
borderTopRightRadius: 0,
|
||||
borderTopLeftRadius: 0,
|
||||
backgroundColor: `${props.color || 'neutral'}.softHoverBg`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
Attach
|
||||
</MenuButton>
|
||||
|
||||
<Menu
|
||||
// variant='soft'
|
||||
color={props.color}
|
||||
placement='top-start'
|
||||
popperOptions={{ modifiers: [{ name: 'offset', options: { offset: [-10 /* 62 */, -2] } }] }}
|
||||
sx={{
|
||||
minWidth: 280,
|
||||
'--List-padding': '0.5rem',
|
||||
zIndex: themeZIndexOverMobileDrawer,
|
||||
animation: `${animationMenu} 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)`,
|
||||
// boxShadow: '0 16px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
boxShadow: 'md',
|
||||
borderRadius: ATTACH_BUTTON_RADIUS,
|
||||
border: '1px solid',
|
||||
borderColor: `${props.color || 'neutral'}.outlinedBorder`,
|
||||
backgroundColor: 'background.popup',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
|
||||
{/* File Attachment */}
|
||||
<RichMenuItem
|
||||
name={props.onlyImages ? 'Images' : 'Files'}
|
||||
Icon={AttachFileRoundedIcon}
|
||||
description={props.onlyImages ? 'PNG, JPG, WEBP images to edit' : 'PDF, DOCX, images, code'}
|
||||
onClick={handleAttachFilePicker}
|
||||
delay={0}
|
||||
/>
|
||||
|
||||
{/* Web/URL Attachment */}
|
||||
{!props.onlyImages && /*props.canBrowse &&*/ (
|
||||
<RichMenuItem
|
||||
name='Web'
|
||||
Icon={LanguageRoundedIcon}
|
||||
description='Import web pages, including screenshots'
|
||||
onClick={props.onOpenWebInput}
|
||||
disabled={!props.canBrowse}
|
||||
delay={0.02}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Google Drive Attachment */}
|
||||
{!props.onlyImages && hasGoogleDriveCapability && !!props.onOpenGoogleDrivePicker && (
|
||||
<RichMenuItem
|
||||
name='Drive'
|
||||
Icon={AddToDriveRoundedIcon}
|
||||
description='Attach Google Drive files'
|
||||
onClick={props.onOpenGoogleDrivePicker}
|
||||
delay={0.04}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Clipboard Attachment */}
|
||||
{!props.onlyImages && supportsClipboardRead() && (
|
||||
<RichMenuItem
|
||||
name='Clipboard'
|
||||
Icon={ContentPasteGoIcon}
|
||||
// description='Auto-converts images and text to the best format'
|
||||
description='Auto-adapts images and text'
|
||||
onClick={props.onAttachClipboard}
|
||||
delay={0.06}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*{!props.onlyImages && props.canBrowse && (*/}
|
||||
{/* <ListItem>*/}
|
||||
{/* <ListItemDecorator />*/}
|
||||
{/* <Checkbox*/}
|
||||
{/* size='sm'*/}
|
||||
{/* color='neutral'*/}
|
||||
{/* // checked={enableComposerAttach}*/}
|
||||
{/* // onChange={handleToggle}*/}
|
||||
{/* onClick={(event) => event.stopPropagation()}*/}
|
||||
{/* sx={{ ml: 0.375 }}*/}
|
||||
{/* slotProps={{*/}
|
||||
{/* label: {*/}
|
||||
{/* sx: {*/}
|
||||
{/* fontSize: 'sm',*/}
|
||||
{/* fontWeight: 'md',*/}
|
||||
{/* },*/}
|
||||
{/* },*/}
|
||||
{/* }}*/}
|
||||
{/* label='Download and attach links'*/}
|
||||
{/* />*/}
|
||||
{/* </ListItem>*/}
|
||||
{/*)}*/}
|
||||
|
||||
|
||||
{/* Divider before labs features */}
|
||||
{(props.hasScreenCapture || props.hasCamera) && <ListDivider inset='gutter' sx={{ my: 1 }} />}
|
||||
|
||||
{/* Screen Capture */}
|
||||
{props.hasScreenCapture && (
|
||||
<RichMenuItem
|
||||
name='Screen'
|
||||
Icon={ScreenshotMonitorIcon}
|
||||
description={screenCaptureError ? `Error: ${screenCaptureError}` : 'Capture tabs, apps, and screens'}
|
||||
onClick={handleTakeScreenCapture}
|
||||
disabled={capturingScreen}
|
||||
color={screenCaptureError ? 'danger' : undefined}
|
||||
delay={0.08}
|
||||
endAction={props.onStartLiveScreenFeed && <LiveFeedButton isActive={!!props.hasActiveScreenFeed} tooltip='Live Screen chat' onClick={props.onStartLiveScreenFeed} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Camera */}
|
||||
{props.hasCamera && (
|
||||
<RichMenuItem
|
||||
name='Camera'
|
||||
Icon={CameraAltOutlinedIcon}
|
||||
description='Capture photos with optional OCR'
|
||||
onClick={props.onOpenCamera}
|
||||
delay={0.1}
|
||||
endAction={props.onStartLiveCameraFeed && <LiveFeedButton isActive={!!props.hasActiveCameraFeed} tooltip='Live Camera chat' onClick={props.onStartLiveCameraFeed} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* URL Auto-Download Toggle - only show when browse capability exists */}
|
||||
{!props.onlyImages && props.canBrowse && (
|
||||
<AutoDownloadToggle delay={0.12} />
|
||||
)}
|
||||
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
-22
@@ -6,8 +6,6 @@ import CameraAltOutlinedIcon from '@mui/icons-material/CameraAltOutlined';
|
||||
|
||||
import { buttonAttachSx } from '~/common/components/ButtonAttachFiles';
|
||||
|
||||
import { CameraCaptureModal } from '../CameraCaptureModal';
|
||||
|
||||
|
||||
export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera);
|
||||
|
||||
@@ -43,24 +41,4 @@ function ButtonAttachCamera(props: {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCameraCaptureModalDialog(onAttachImageStable: (file: File) => void) {
|
||||
|
||||
// state
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const openCamera = React.useCallback(() => setOpen(true), []);
|
||||
|
||||
const cameraCaptureComponent = React.useMemo(() => open && (
|
||||
<CameraCaptureModal
|
||||
onCloseModal={() => setOpen(false)}
|
||||
onAttachImage={onAttachImageStable}
|
||||
/>
|
||||
), [open, onAttachImageStable]);
|
||||
|
||||
return {
|
||||
openCamera,
|
||||
cameraCaptureComponent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as React from 'react';
|
||||
import type { FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
import type { CameraCaptureDialogOptions } from '~/common/components/camera/useCameraCaptureDialog';
|
||||
import type { CameraLiveStream } from '~/common/components/camera/useCameraCapture';
|
||||
import { addSnackbar } from '~/common/components/snackbar/useSnackbarsStore';
|
||||
import { useCameraCaptureDialog } from '~/common/components/camera/useCameraCaptureDialog';
|
||||
|
||||
import type { AttachmentDraftsApi } from '../useAttachmentDrafts';
|
||||
import { useWebAttachmentModal } from './useWebAttachmentModal';
|
||||
|
||||
|
||||
// Focused hooks that bridge `useAttachmentDrafts` return values to UI callback shapes.
|
||||
// Each hook wraps one attachment source. Consumers compose only what they need.
|
||||
|
||||
type _HandleCameraOpen = (options?: CameraCaptureDialogOptions) => Promise<void>;
|
||||
type _HandleFiles = (files: FileWithHandle[], errorMessage: string | null) => void;
|
||||
type _HandlePasteIntercept = (event: React.ClipboardEvent) => void;
|
||||
type _HandleScreenCapture = (file: File) => void;
|
||||
type _HandleWebLinks = (links: { url: string }[]) => void;
|
||||
|
||||
|
||||
/**
|
||||
* Returns a handler that opens the camera capture dialog and appends the captured files.
|
||||
*/
|
||||
export function useAttachHandler_CameraOpen(
|
||||
attachAppendFile: AttachmentDraftsApi['attachAppendFile'],
|
||||
handleLiveStream?: (stream: CameraLiveStream) => void,
|
||||
): _HandleCameraOpen {
|
||||
|
||||
// external state
|
||||
const { openCameraCapture } = useCameraCaptureDialog(); // -> showPromisedOverlay
|
||||
|
||||
return React.useCallback(async (optionsOrEvent?: CameraCaptureDialogOptions | React.SyntheticEvent) => {
|
||||
|
||||
// guard: onClick handlers pass the event as first arg
|
||||
const options = optionsOrEvent && 'nativeEvent' in optionsOrEvent ? undefined : optionsOrEvent;
|
||||
|
||||
const result = await openCameraCapture({ allowMultiCapture: true, allowLiveFeed: !!handleLiveStream, ...options });
|
||||
if (!result) return; // user dismissed the dialog without capturing anything
|
||||
|
||||
// append all captured images
|
||||
for (const imageFile of result.images)
|
||||
void attachAppendFile('camera', imageFile);
|
||||
|
||||
// handle live stream if provided
|
||||
if (result.liveStream)
|
||||
handleLiveStream?.(result.liveStream);
|
||||
|
||||
}, [attachAppendFile, handleLiveStream, openCameraCapture]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a handler for files to become attachments.
|
||||
*/
|
||||
export function useAttachHandler_Files(attachAppendFile: AttachmentDraftsApi['attachAppendFile']) {
|
||||
return React.useCallback<_HandleFiles>(async (files, errorMessage) => {
|
||||
|
||||
if (errorMessage)
|
||||
addSnackbar({ key: 'attach-files-open-fail', message: `Unable to open files: ${errorMessage}`, type: 'issue' });
|
||||
|
||||
// files are appended sequentially (awaited) so conversion pipelines don't race
|
||||
for (const file of files)
|
||||
await attachAppendFile('file-open', file)
|
||||
.catch((error: any) => addSnackbar({ key: 'attach-file-open-fail', message: `Unable to attach the file "${file.name}" (${error?.message || error?.toString() || 'unknown error'})`, type: 'issue' }));
|
||||
|
||||
}, [attachAppendFile]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a paste handler that intercepts Ctrl+V, routing pasted files through the attachment pipeline.
|
||||
*/
|
||||
export function useAttachHandler_PasteIntercept(attachAppendDataTransfer: AttachmentDraftsApi['attachAppendDataTransfer']) {
|
||||
return React.useCallback<_HandlePasteIntercept>(async (event) => {
|
||||
|
||||
// false = don't attach text (only files), to prevent duplicate text in input
|
||||
if (await attachAppendDataTransfer(event.clipboardData, 'paste', false) === 'as_files') {
|
||||
// preventDefault stops the browser's default paste only when files were captured
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
}, [attachAppendDataTransfer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a handler for screen/window/tab captures to become attachments.
|
||||
*/
|
||||
export function useAttachHandler_ScreenCapture(attachAppendFile: AttachmentDraftsApi['attachAppendFile']) {
|
||||
return React.useCallback<_HandleScreenCapture>((file) => {
|
||||
|
||||
void attachAppendFile('screencapture', file);
|
||||
|
||||
}, [attachAppendFile]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `{ openWebInputDialog, webInputDialogComponent }` for web link attachments.
|
||||
* Consumer must render `webInputDialogComponent`.
|
||||
*/
|
||||
export function useAttachHandler_UrlWebLinks(attachAppendUrl: AttachmentDraftsApi['attachAppendUrl'], composerText?: string) {
|
||||
|
||||
// local handler
|
||||
const _handleAttachWebLinks = React.useCallback<_HandleWebLinks>(async (links) => {
|
||||
|
||||
// processd in parallel
|
||||
const attachPromises = links.map(link => attachAppendUrl('input-link', link.url));
|
||||
|
||||
// find if any failed
|
||||
const results = await Promise.allSettled(attachPromises);
|
||||
const issueUrls = results.reduce<string[]>((acc, result, index) => {
|
||||
if (result.status === 'rejected')
|
||||
acc.push(links[index].url);
|
||||
return acc;
|
||||
}, []);
|
||||
if (issueUrls.length)
|
||||
addSnackbar({ key: 'attach-web-fail', message: `Unable to attach: ${issueUrls.join(', ')}`, type: 'issue', overrides: { autoHideDuration: 4000 } });
|
||||
|
||||
}, [attachAppendUrl]);
|
||||
|
||||
// return the component and open() function
|
||||
// optional composerText is passed to the modal for URL auto-detection from the current input text
|
||||
return useWebAttachmentModal(_handleAttachWebLinks, composerText);
|
||||
}
|
||||
+1
-1
@@ -10,7 +10,7 @@ import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
import { addSnackbar } from '~/common/components/snackbar/useSnackbarsStore';
|
||||
|
||||
import type { AttachmentStoreCloudInput } from './useAttachmentDrafts';
|
||||
import type { AttachmentStoreCloudInput } from '../useAttachmentDrafts';
|
||||
|
||||
|
||||
// configuration
|
||||
+1
-1
@@ -259,7 +259,7 @@ function WebInputModal(props: {
|
||||
}
|
||||
|
||||
|
||||
export function useWebInputModal(onAttachWebLinks: (urls: WebInputData[]) => void, composerText?: string) {
|
||||
export function useWebAttachmentModal(onAttachWebLinks: (urls: WebInputData[]) => void, composerText?: string) {
|
||||
|
||||
// state
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -19,6 +19,7 @@ export async function imageDataToImageAttachmentFragmentViaDBlob(
|
||||
caption: string,
|
||||
convertToMimeType: false | CommonImageMimeTypes,
|
||||
resizeMode: false | LLMImageResizeMode,
|
||||
scopeId: DBlobDBScopeId = 'attachment-drafts',
|
||||
): Promise<DMessageAttachmentFragment | null> {
|
||||
|
||||
// convert to Blobs if needed
|
||||
@@ -49,7 +50,7 @@ export async function imageDataToImageAttachmentFragmentViaDBlob(
|
||||
});
|
||||
|
||||
// add the image to the DBlobs DB
|
||||
const dblobAssetId = await addDBImageAsset('attachment-drafts', imageBlob, {
|
||||
const dblobAssetId = await addDBImageAsset(scopeId, imageBlob, {
|
||||
label: title ? 'Image: ' + title : 'Image',
|
||||
metadata: {
|
||||
width: imageWidth,
|
||||
|
||||
@@ -30,11 +30,13 @@ const GuessedMimeLookupTable: Record<string, GuessedMimeInfo> = {
|
||||
// Code (including various programming languages)
|
||||
'text/css': { ext: ['css', 'scss', 'less', 'sass'], dt: 'code' },
|
||||
'text/javascript': { ext: ['js', 'mjs', 'jsx'], dt: 'code' },
|
||||
'application/javascript': { ext: null, dt: 'code' }, // [Anthropic 2026-04-09] non-standard variant returned by the Anthropic Files API
|
||||
'application/x-javascript': { ext: null, dt: 'code' },
|
||||
'text/x-typescript': { ext: ['ts', 'tsx', 'd.ts'], dt: 'code' }, // TypeScript files (recommended is application/typescript, but we standardize to text/x-typescript instead as per Gemini's standard)
|
||||
'application/x-typescript': { ext: null, dt: 'code' },
|
||||
'text/csv': { ext: ['csv', 'tsv'], dt: 'code' },
|
||||
'text/x-python': { ext: ['py', 'pyw'], dt: 'code' },
|
||||
'text/x-script.python': { ext: null, dt: 'code' }, // [Anthropic 2026-04-09]
|
||||
'application/x-python-code': { ext: null, dt: 'code' },
|
||||
'application/x-ipynb+json': { ext: ['ipynb'], dt: 'code' },
|
||||
'application/json': { ext: ['json', 'jsonld'], dt: 'code' },
|
||||
|
||||
@@ -459,6 +459,11 @@ function _prepareDocData(source: AttachmentDraftSource, input: Readonly<Attachme
|
||||
case 'drop':
|
||||
fileTitle = source.refPath || _lowCollisionRefString('Dropped File', 6);
|
||||
break;
|
||||
case 'live-feed-camera':
|
||||
case 'live-feed-screen':
|
||||
fileCaption = sourceOrigin === 'live-feed-camera' ? 'Live Camera' : 'Live Screen';
|
||||
fileTitle = source.refPath || _lowCollisionRefString(fileCaption, 6);
|
||||
break;
|
||||
default:
|
||||
const _exhaustiveCheck: never = sourceOrigin;
|
||||
fileTitle = 'File';
|
||||
|
||||
@@ -93,7 +93,12 @@ export type AttachmentDraftSource = {
|
||||
egoFragmentsInputData: DraftEgoFragmentsInputData;
|
||||
};
|
||||
|
||||
export type AttachmentDraftSourceOriginFile = 'camera' | 'screencapture' | 'file-open' | 'clipboard-read' | AttachmentDraftSourceOriginDTO;
|
||||
export type AttachmentDraftSourceOriginFile =
|
||||
| 'camera' | 'screencapture'
|
||||
| 'live-feed-camera' | 'live-feed-screen'
|
||||
| 'file-open'
|
||||
| 'clipboard-read'
|
||||
| AttachmentDraftSourceOriginDTO;
|
||||
|
||||
export type AttachmentDraftSourceOriginDTO = 'drop' | 'paste';
|
||||
|
||||
@@ -180,6 +185,11 @@ export type AttachmentDraftConverterType =
|
||||
// 3. Output - this is done via DMessageAttachmentFragment[], to be directly compatible with our data
|
||||
|
||||
|
||||
// Actions on attachment drafts
|
||||
|
||||
export type AttachmentDraftsAction = 'inline-text' | 'copy-text';
|
||||
|
||||
|
||||
/*export type AttachmentDraftPreview = {
|
||||
renderer: 'noPreview',
|
||||
title: string; // A title for the preview
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AttachmentDraft } from '../attachment.types';
|
||||
|
||||
|
||||
/**
|
||||
* Per-draft enrichment interface - provides LLM-specific (or context-specific)
|
||||
* compatibility/token info for an AttachmentDraft.
|
||||
*
|
||||
* Implementations may be LLM-aware (Composer) or simple pass-throughs (edit mode).
|
||||
*/
|
||||
export interface IAttachmentEnrichment {
|
||||
/** Whether all output fragments of this draft are supported */
|
||||
isCompatible(draft: AttachmentDraft): boolean;
|
||||
|
||||
/** Whether this draft has text fragments that can be inlined */
|
||||
supportsTextInline(draft: AttachmentDraft): boolean;
|
||||
|
||||
/** Approximate token count for this draft, or null if unknown */
|
||||
estimateTokens(draft: AttachmentDraft): number | null;
|
||||
|
||||
/** Approximate total token count across all drafts, or null if unknown */
|
||||
estimateTotalTokens(drafts: AttachmentDraft[]): number | null;
|
||||
|
||||
/** Whether this draft contains image fragments */
|
||||
hasImages(draft: AttachmentDraft): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computed collection-level summary derived from IAttachmentEnrichment
|
||||
* across all drafts. Used to avoid re-computing in multiple places.
|
||||
*/
|
||||
export interface AttachmentEnrichmentSummary {
|
||||
allCompatible: boolean;
|
||||
anyImages: boolean;
|
||||
anyInlinable: boolean;
|
||||
totalTokensApprox: number | null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user