mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
584 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7bd76ef74 | |||
| 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 | |||
| b60e2bae65 | |||
| 19c7fa4285 | |||
| f450dd3eac | |||
| d366cdd542 | |||
| c1ba83fddb | |||
| 617d6038b1 | |||
| 0abee15c30 | |||
| 1aa2e68e4a | |||
| cd692218ce | |||
| a5b7191185 | |||
| 56baba4cae | |||
| b696447be4 | |||
| e1ef2e72d7 | |||
| e85905e63c | |||
| c6208a2900 | |||
| 01299e4f19 | |||
| 1771575641 | |||
| 88a796fd87 | |||
| e403467d6d | |||
| 1914a2a8a3 | |||
| 683892afef | |||
| 470f8aab70 | |||
| 7a561d6b42 | |||
| affff0df4a | |||
| f5a81bdc94 | |||
| 818ed53b53 | |||
| 12c875f4e3 | |||
| 6ff715c0f0 | |||
| c4a89822d8 | |||
| a8a917f786 | |||
| 3aa9a71a4b | |||
| 3758612ed6 | |||
| b71a4265f8 | |||
| 870cdb67cf | |||
| 902c9dc3f4 | |||
| 0d1db0a360 | |||
| ddd784f041 | |||
| 830d45c06d | |||
| 6e27a31013 | |||
| ed87595e17 | |||
| da01b59ae3 | |||
| 79046b808b | |||
| 5a71153390 | |||
| 94056cdf4b | |||
| 41cb35c6b9 | |||
| e133fc81f6 | |||
| 418c2e496c | |||
| 3690202b38 | |||
| f069c2e5ab | |||
| 97bf6ca276 | |||
| a1390b152f | |||
| 4e8c7d46f6 | |||
| 02944d2015 | |||
| 58726f0425 | |||
| 85f796fb1d | |||
| 311a9c2bf2 | |||
| 6768917d44 | |||
| 7beb412738 | |||
| cf724625cc | |||
| f60b2410dd | |||
| bbdc16b06a | |||
| 0fa2d06725 | |||
| 36cdc4b55f | |||
| c2b4a50bfa | |||
| 73f88d4715 | |||
| af919be2ac | |||
| facffbc6c8 | |||
| dd5b7cb8c2 | |||
| 3dc61109d7 | |||
| 9ef84260b0 | |||
| cf2df7d7f9 | |||
| 16a883526b | |||
| 7b66b1a2eb | |||
| a4adce5c79 | |||
| 9e4174df53 | |||
| b5975713a3 | |||
| 0cd04266b7 | |||
| 5cbd162454 | |||
| bea1600358 | |||
| 6a2e201cf5 | |||
| 960551933e | |||
| 8b38b6416d | |||
| fac4c39f48 | |||
| 4c930efbf0 | |||
| 5a2a47cb87 | |||
| 4912a03250 | |||
| 3b13580613 | |||
| 95905113ac | |||
| c6b34bb252 | |||
| e5387c2323 | |||
| d3b4447669 | |||
| d5c5eac9ec | |||
| 49b61495d0 | |||
| e8298e9d30 | |||
| b29681e1f7 | |||
| 1e0b9a2f0c | |||
| 442b8e95b1 | |||
| 27090d9e28 | |||
| c37b4fa076 | |||
| 83161bbe98 | |||
| 4b166120e6 | |||
| 04494ac752 | |||
| 979809ddb1 | |||
| 5d797c3339 | |||
| 2ff74f6b80 | |||
| 06b1195f9a | |||
| c337b70a42 | |||
| 5047354892 | |||
| ce4e405fc6 | |||
| 30c8d66cd1 | |||
| fb5c8aad29 | |||
| 08d221d00f | |||
| af918178f6 | |||
| ed19896e3c | |||
| 47ad135e4b | |||
| 0eff7825c8 | |||
| 5c8baee390 | |||
| 3f71facb49 | |||
| eba42cc8f2 | |||
| 53092cee51 | |||
| 4bf621f128 | |||
| 33505dbb8e | |||
| c81e1f144f | |||
| ee788b967b | |||
| 38ac8733f6 | |||
| 737a20ee06 | |||
| 19f48b8001 | |||
| 3471d6b4f5 | |||
| 2dc7ba72b3 | |||
| e12279dab0 | |||
| 2e0c79cb64 | |||
| aa697edb8c | |||
| c72e3c58dd | |||
| 1de30c8bd5 | |||
| 3a8eea6fb7 | |||
| b7fd0bdba7 | |||
| 58457cac50 | |||
| 0fbacee7dc | |||
| a498f28d14 | |||
| 5b9c6a2d0e | |||
| 4c7f50ab98 | |||
| ef03d33bbf | |||
| 22c9fc56c0 | |||
| c952fd734f | |||
| 310e99af23 | |||
| e78446904a | |||
| 760e9d8279 | |||
| 61a60c5b9f | |||
| 3054e1b88d | |||
| 6f4fabf147 | |||
| b0c791a055 | |||
| 748991249a | |||
| 1aea7122cc | |||
| 9a83b428f1 | |||
| 2cd38bc02b | |||
| e586142190 | |||
| a10d0dcf5d | |||
| 6fdff488a9 | |||
| 8af0d78127 | |||
| 177686a7fc | |||
| 09b6e47036 | |||
| 704187ba3e | |||
| 4ea8a06503 | |||
| 80fcc7d3e3 | |||
| a04c62da6f | |||
| fcb518a050 | |||
| a222626933 | |||
| a3ceade738 | |||
| 51d58223b4 | |||
| d37a603db2 | |||
| ea984f3ddf | |||
| a9d3e3dead | |||
| 5499e57205 | |||
| 6f8ee0247f | |||
| 05ee5cc3d1 | |||
| cb6b569330 | |||
| 53073ff109 | |||
| 26d362d7a6 | |||
| 91d99e1a63 | |||
| a20917c971 | |||
| af9bf9e5b3 | |||
| 46b473b8a0 | |||
| e2b4028223 | |||
| bac2a31782 | |||
| 3d20e6bf91 | |||
| 9337216092 | |||
| cd35d0ca55 | |||
| 6d591b98b8 | |||
| 486381ab9d | |||
| c619b4debb | |||
| 383a3085ec | |||
| 5a3bb3d817 | |||
| d1ba758887 | |||
| 6fef149997 | |||
| aad3b16ff2 | |||
| 819ba14523 | |||
| d3c25ca16a | |||
| 99a65f72ac | |||
| be9080d392 | |||
| f32d991413 | |||
| 94b68ebefa | |||
| 0450eaaceb | |||
| 408c5ce088 | |||
| d936629ead | |||
| 9bd1a66208 | |||
| 1a0c029ee8 | |||
| e7be228703 | |||
| 0ab4dc972f | |||
| 5f1ca8954f | |||
| 3ec1b033ce | |||
| 0caf27af9b | |||
| bd67e14fa4 | |||
| 494c3b542c | |||
| 8e0884eb64 | |||
| 73c4dc4ac8 | |||
| d77274058d | |||
| 0c8460419b | |||
| eabb589390 | |||
| 62f860ae93 | |||
| 605aae873c | |||
| 62e9ee5b05 | |||
| d686f5d143 | |||
| 3922f232ae | |||
| 6735b438d3 | |||
| fb1e30ab32 | |||
| 0ec06edb57 | |||
| 2a52673c56 | |||
| cc20d00d8a | |||
| 3d9201f7dc | |||
| 176732a6c0 | |||
| 39815b3af3 | |||
| bcce517089 | |||
| a4b50d0d97 | |||
| 2a124e7588 | |||
| a85556ab5b | |||
| cef93d6084 | |||
| 207e257778 | |||
| 12203daa22 | |||
| 27f8e9248d | |||
| 51384dc984 | |||
| bc76cbb5ad | |||
| 5a1ca83f6d | |||
| c9f585f808 | |||
| 9f559e1dbf | |||
| e458bca1a7 | |||
| 43d2226019 | |||
| 122bc34701 | |||
| e01358e268 | |||
| 847c84c3e6 | |||
| b11cac4328 | |||
| f617b06109 | |||
| 345ccf3369 | |||
| d111b8af62 | |||
| 8f964c5c49 | |||
| b6f3f4538f | |||
| f6dd30d5d8 | |||
| af8b79f849 | |||
| 0cfccc423b | |||
| f9a5d582d4 | |||
| 684e00d594 | |||
| 3cd2df0b50 | |||
| 02197f4ee6 | |||
| f9049a3fea | |||
| 462bddc271 | |||
| f79000cf39 | |||
| 1d95273f4d | |||
| 6c4579f434 | |||
| 4ef56ade21 | |||
| 7c1369d6e9 | |||
| 533d54b106 | |||
| cce0ca6560 | |||
| e87ce2593c | |||
| 431dc8b667 | |||
| 5caf614bf7 | |||
| ecf9703570 | |||
| e7641393a0 | |||
| 2201f6ff5a | |||
| 557e1ce293 | |||
| cbe9a6b9a5 | |||
| 9bbcb038d4 | |||
| 3602204420 | |||
| 6f485e5589 | |||
| 2f46a3dfaf | |||
| 267845bba3 | |||
| 6f33a8eebf | |||
| b0d2b09a2e | |||
| c699b6b16b | |||
| 1789bac28d | |||
| 60c05f615f | |||
| bd84523671 | |||
| eb21b9c770 | |||
| ff3ac11afb | |||
| 1ef8c3d02b | |||
| 2ebaf6279b | |||
| a5ee40e184 | |||
| b17a97eac7 | |||
| 63908bfaf6 | |||
| 3f9a419a19 | |||
| bae691e33e | |||
| 91539346ee | |||
| 4842ca81b3 | |||
| 9c77a1a4ab | |||
| 4af284be42 | |||
| 6aec68bb3c | |||
| d4e2b0834f | |||
| 24c2702f96 | |||
| 4691fc9bad | |||
| 8c6c60b6f1 | |||
| bc482407fe | |||
| ff05593db8 | |||
| 3d304d9374 | |||
| 1734f0c2f1 | |||
| 1b25e5df85 | |||
| ea8eb32b0b | |||
| 614a1f95de | |||
| d36bc28914 | |||
| deec48d7c1 | |||
| b318ec8d39 | |||
| b4b0e2befc | |||
| 51d3fe13da | |||
| 58220216d3 | |||
| cac75cca42 | |||
| 47f247907f | |||
| 81e04b7322 | |||
| 56a964b700 | |||
| 458341d79f | |||
| d1d212b075 | |||
| 59c9996489 | |||
| bf8221a2f1 | |||
| 787a11a040 | |||
| 05d114be2f | |||
| 3c04a7dbac | |||
| 1673e1148d | |||
| de416b035d | |||
| 08aaf2989d | |||
| a50964060c | |||
| 54b6108719 | |||
| 585e5c254a | |||
| 477808c9bb | |||
| 6c58a2b688 | |||
| c9854bf30f | |||
| cfed4bbd41 | |||
| 2dd6485b0e | |||
| bf1dd5b860 | |||
| 765c373f7d | |||
| 32d752e82b | |||
| 4623e438fa | |||
| 8a44ff396f | |||
| 086d7ecae4 | |||
| d6adebb711 | |||
| 8325fe7b3c | |||
| 7cf83f878b | |||
| 597ba26424 | |||
| 7bccea47f5 | |||
| 5770116779 | |||
| 0679144f69 | |||
| c9fd288b52 | |||
| 9ae449fcfd | |||
| 249f67f796 | |||
| e91c0bb554 | |||
| 5e306d9598 | |||
| 42ebc81cbb | |||
| f624c37db5 | |||
| 22b6f42936 | |||
| 760c66cac8 | |||
| 1d91e9da03 | |||
| 7eac409ec6 | |||
| 128558420c | |||
| ca3e664690 | |||
| 7eb37462d7 | |||
| 31e02c2d39 | |||
| 003a68b9b8 | |||
| f418708389 | |||
| d23a564035 | |||
| 7fe586244c | |||
| f1a597cdc6 | |||
| 9b68c8f58c | |||
| be5b57ea71 | |||
| 425c82f26d | |||
| 942421c1fb | |||
| b1184f6928 | |||
| ffeb6d1b98 | |||
| b2718b56b7 | |||
| 455f834957 | |||
| 8a14c80ff8 | |||
| e268e733c7 | |||
| 8933a8dfb3 | |||
| 9796cc525c | |||
| cdbf9a9190 | |||
| c26792292d | |||
| 4698e0ee03 | |||
| 68afcb2f4b | |||
| e8f61e46e3 | |||
| 317bb2b7c8 | |||
| d1b3c6b468 | |||
| b35eccc984 | |||
| a780c92047 | |||
| 5fc65698ba | |||
| c923b5ec4c | |||
| 609b2b9a7b | |||
| a257278004 | |||
| 273daed634 | |||
| a6862d8c58 | |||
| 323e5b4ea7 | |||
| 89217a5308 | |||
| a45e995d2f | |||
| 8700b4c8ca | |||
| 1f7f5fb488 | |||
| afde8ee864 | |||
| 3884c26b15 | |||
| 24dce7eae9 | |||
| 1db4e9b771 | |||
| b2ed7eae00 | |||
| 3169fd67e8 | |||
| 773ceb1396 | |||
| 8c62ee1720 | |||
| 5fa1f52922 | |||
| d2180c010c | |||
| b73df7b2ce | |||
| 971f737846 | |||
| a393353907 | |||
| 751f609554 | |||
| e8cd5c6552 | |||
| 86e387b270 | |||
| 32f15aa621 | |||
| bfc889a9e5 | |||
| bd907625a8 | |||
| 60004926d7 | |||
| ac751dfd1a | |||
| 6828eee17f | |||
| 19c97f397b | |||
| 0167a8bdd8 | |||
| 93e5044603 | |||
| 024d930677 | |||
| 98873446a8 | |||
| 5318b7a406 | |||
| 4a6c3cbcd2 | |||
| ac0a39c202 | |||
| 88d39345a5 | |||
| 7aa9cb07b2 | |||
| ef30c8d28d | |||
| 2727f690b4 | |||
| 5945c24301 | |||
| 7b6aff1f95 | |||
| cb0fe3aadd | |||
| 4f9d69f9c2 | |||
| c18aeabe06 | |||
| 550742323a | |||
| c71f789a08 | |||
| a9b4b195bf | |||
| 52e8177f42 | |||
| b0743efc48 | |||
| 6dfd652dac | |||
| 3f93cb2e6d | |||
| 8f7b9b7f19 | |||
| abff89ab6b | |||
| d4f03f743a | |||
| c3714f6651 | |||
| 9b4d0ddf2f | |||
| 2c9ac2f549 | |||
| c1292de2a0 | |||
| 21d5e4cd29 | |||
| a9495a3e15 | |||
| bff5b3d765 | |||
| a4ff37eecc | |||
| 460209f486 | |||
| 96c68c86a4 | |||
| 8b152fdff8 | |||
| 25c9a52873 | |||
| 44302d903c | |||
| c7b8668609 | |||
| 7d60df6266 | |||
| b7f898a5e5 | |||
| 04c4dbe4b8 | |||
| 8d04c494df | |||
| a6aadf76f3 | |||
| a685ef97bf | |||
| d46c29689f | |||
| 65ce07395b | |||
| cc1542fe95 | |||
| b70d57d878 | |||
| 5aa857362b | |||
| c92fc34051 | |||
| b01e66f12a | |||
| a88d20784a | |||
| 63486ed6cf | |||
| 3ceec773f2 | |||
| 817fa56ec4 | |||
| 088fb21a90 | |||
| 79c755a469 | |||
| a091d3f011 | |||
| c7c01a5d7c | |||
| cdc0f48973 | |||
| e884f6b962 | |||
| 485a9bea71 | |||
| f3c3b667ca | |||
| 3b0c4f31b6 | |||
| 5e54600766 | |||
| c3e54f69b7 | |||
| c4022d1c9b | |||
| 6e13a78a24 | |||
| c7cacd9727 | |||
| a77110f704 | |||
| 83a6069de5 | |||
| e9a1890e54 | |||
| bf928aa06e | |||
| b2dc50590c | |||
| 229e53ac32 | |||
| 51e8a47615 | |||
| e80b58a412 | |||
| 48ced8b079 | |||
| c07e2aea1e | |||
| f3194aa30e | |||
| cb3e4cd951 | |||
| f5d8d029ea | |||
| 7c946c4126 | |||
| ded4ea0d69 | |||
| c180c549fe | |||
| 1f30f1168f | |||
| 9446f15922 | |||
| e13b2c9cd9 | |||
| e9e14e0292 | |||
| added19656 | |||
| 4fa3c4d479 | |||
| 690738de9a | |||
| cb31d27e68 | |||
| e6658df123 | |||
| 0b7154a14c | |||
| 02c1838de5 | |||
| fc455fceb8 | |||
| 8d40cdd234 | |||
| 40145c669a | |||
| 34d2fc233f | |||
| 670ec0381a | |||
| 2128f255fe | |||
| b717bd9a9a | |||
| 8aab9311f5 | |||
| ff3e16ea67 | |||
| 1de039c315 | |||
| d05e1786d7 | |||
| e34b5a7372 | |||
| a1b3d1b508 | |||
| 1ebccdf420 |
@@ -0,0 +1 @@
|
||||
commands/code/apply-issue-main.md
|
||||
@@ -46,4 +46,4 @@ Focus on discrepancies and gaps:
|
||||
Report differences in wire types, adapter logic, parser handling, or dialect-specific quirks.
|
||||
Prioritize new capabilities that improve user experience (reasoning visibility, better tool use, etc.).
|
||||
|
||||
When making changes, add comments with date: `// [OpenRouter, 2025-MM-DD]: explanation`
|
||||
When making changes, add comments with date: `// [OpenRouter, 2026-MM-DD]: explanation`
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: Sync xAI Responses API implementation with latest upstream documentation
|
||||
argument-hint: specific feature to check
|
||||
---
|
||||
|
||||
Review the xAI Responses API implementation:
|
||||
- xAI wire types: `src/modules/aix/server/dispatch/wiretypes/xai.wiretypes.ts` (xAI-specific request schema, tools)
|
||||
- Request adapter: `src/modules/aix/server/dispatch/chatGenerate/adapters/xai.responsesCreate.ts` (AIX → xAI Responses API)
|
||||
- Response parser: `src/modules/aix/server/dispatch/chatGenerate/parsers/openai.responses.parser.ts` (shared with OpenAI Responses)
|
||||
- Dispatch routing: `src/modules/aix/server/dispatch/chatGenerate/chatGenerate.dispatch.ts` (dialect='xai' routing)
|
||||
- OpenAI shared types: `src/modules/aix/server/dispatch/wiretypes/openai.wiretypes.ts` (InputItem/OutputItem schemas reused by xAI)
|
||||
|
||||
IMPORTANT context:
|
||||
- We use ONLY the xAI Responses API (`POST /v1/responses`). We do NOT use the Chat Completions API (`/v1/chat/completions`) for xAI anymore.
|
||||
- xAI's Responses API is similar to OpenAI's but has key differences - the skill should find what changed since our last sync.
|
||||
- Response streaming/parsing reuses the OpenAI Responses parser since the format is compatible.
|
||||
- We do NOT implement: Files API, Collections Search, Remote MCP tools, Voice Agent API, Image/Video generation, Batch API, or Deferred Completions.
|
||||
|
||||
Then take a look at the newest API information available. Try these sources, and be creative if some are blocked:
|
||||
|
||||
**Primary Sources (guide pages work well with WebFetch despite being JS-rendered):**
|
||||
- Responses API Guide: https://docs.x.ai/docs/guides/chat
|
||||
- Stateful Responses: https://docs.x.ai/docs/guides/responses-api
|
||||
- Tools Overview: https://docs.x.ai/docs/guides/tools/overview
|
||||
- Search Tools (web_search, x_search): https://docs.x.ai/docs/guides/tools/search-tools
|
||||
- Code Execution Tool: https://docs.x.ai/docs/guides/tools/code-execution-tool
|
||||
- Function Calling: https://docs.x.ai/docs/guides/function-calling
|
||||
- Streaming: https://docs.x.ai/docs/guides/streaming-response
|
||||
- Reasoning: https://docs.x.ai/docs/guides/reasoning
|
||||
- Structured Outputs: https://docs.x.ai/docs/guides/structured-outputs
|
||||
- Models & Pricing: https://docs.x.ai/developers/models
|
||||
- Release Notes: https://docs.x.ai/developers/release-notes
|
||||
- API Reference: https://docs.x.ai/developers/api-reference#create-new-response
|
||||
|
||||
**Alternative Sources if primary blocked:**
|
||||
- xAI Python SDK: https://github.com/xai-org/xai-sdk-python
|
||||
- Web Search for "xai grok api changelog 2026" or "xai responses api new features"
|
||||
|
||||
**If all blocked:** Explain what you attempted and ask user to provide documentation manually.
|
||||
|
||||
$ARGUMENTS
|
||||
Check carefully for discrepancies between our implementation and the current API docs:
|
||||
|
||||
1. **Request fields**: Compare `XAIWire_API_Responses.Request_schema` against current docs - any new, changed, or deprecated parameters?
|
||||
2. **Tool definitions**: Compare `XAIWire_Responses_Tools` - any new parameters on web_search/x_search/code_interpreter? Any new hosted tool types?
|
||||
3. **Input/Output item types**: Any xAI-specific output items not handled by the shared OpenAI parser (e.g., x_search_call, web_search_call, code_interpreter_call)?
|
||||
4. **Streaming events**: Any xAI-specific SSE event types beyond what the OpenAI Responses parser handles?
|
||||
5. **Response shape**: Usage reporting differences, new fields in the response object?
|
||||
6. **Adapter logic**: Message role mapping, content type handling, system message approach - still correct?
|
||||
7. **Include options**: Any new values for the `include` array?
|
||||
8. **Reasoning config**: Which models support it and with what values?
|
||||
|
||||
Prioritize breaking changes and new capabilities that would improve the user experience.
|
||||
When making changes, add comments with date: `// [xAI, 2026-MM-DD]: explanation`
|
||||
|
||||
**Self-update this skill**: After completing the sync, if your research reveals that assumptions in THIS skill file (`.claude/commands/aix/sync-xai-api.md`) are wrong or outdated - e.g., new APIs we now implement, new tool types added, URLs moved, file paths changed - update this skill file to stay accurate for next time.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: Review in-flight changes for coherence, completeness, and quality
|
||||
---
|
||||
|
||||
Review the current in-flight changes in the big-agi-private repository (dev branch, continuously rebased ~1800 commits on top of main).
|
||||
|
||||
**Step 1: Scope and read**
|
||||
|
||||
`git diff --stat` + `git status` for breadth. Then full `git diff` (if empty: `git diff --cached`, then `git diff HEAD~1`).
|
||||
For every file in the diff, read surrounding context in the actual source file - the diff alone hides bugs in adjacent untouched code.
|
||||
|
||||
**Step 2: Reverse-engineer the intent**
|
||||
|
||||
From the diff, determine the **what**, **how**, and **why**. Present this concisely so the author can confirm or correct,
|
||||
but don't stop here, continue to the full review in the same response.
|
||||
|
||||
**Step 3: Validate**
|
||||
|
||||
Run `tsc --noEmit --pretty` and `npm run lint` (in parallel). Report any errors with the review.
|
||||
If the diff removes/renames identifiers, grep the codebase for stale references to the OLD names. This catches broken guards, stale imports, and incomplete migrations.
|
||||
|
||||
**Step 4: Deep review**
|
||||
|
||||
Evaluate every file in the diff.
|
||||
Leave no rocks unturned - correctness, coherence, completeness, excess, generalization, maintenance burden,
|
||||
codebase consistency, etc.
|
||||
|
||||
**Step 5: Prioritized next steps**
|
||||
|
||||
Think about what happens when the next developer touches this code.
|
||||
Rank findings by severity (bug > correctness > cleanup > cosmetic). Be specific about what to change and where.
|
||||
|
||||
Remember: design values for this codebase: orthogonal features, features that generalize well, modularized and reusable code,
|
||||
type-discriminated data, optimized code, zero maintenance burden. Minimize future pain, etc.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
description: Sync LLM parameter options between full model dialog and chat side panel
|
||||
---
|
||||
|
||||
Audit and sync LLM parameter configurations between the two UI editors. Goal: identical `value` fields in option arrays + equivalent onChange logic. Labels/descriptions can differ for UI space.
|
||||
|
||||
**Files to Compare:**
|
||||
1. **Full Model Dialog**: `src/modules/llms/models-modal/LLMParametersEditor.tsx` (main branch)
|
||||
2. **Chat Side Panel**: `src/apps/chat/components/layout-panel/ChatPanelModelParameters.tsx` (main derived branches only)
|
||||
|
||||
**Reference Documentation:**
|
||||
- Parameter system: `kb/systems/LLM-parameters-system.md`
|
||||
- Parameter registry: `src/common/stores/llms/llms.parameters.ts`
|
||||
|
||||
**Task: Perform a comprehensive audit**
|
||||
|
||||
1. **Read both files** and extract all option arrays (e.g., `_reasoningEffortOptions`, `_antEffortOptions`, `_geminiThinkingLevelOptions`, etc.)
|
||||
|
||||
2. **Check for missing parameters:**
|
||||
- Parameters handled in `LLMParametersEditor.tsx` but NOT in `ChatPanelModelParameters.tsx`
|
||||
- Parameters in `ChatPanelModelParameters.tsx`'s `_interestingParameters` array but missing UI controls
|
||||
- Note: The side panel intentionally shows only "interesting" parameters - focus on those listed in `_interestingParameters`
|
||||
|
||||
3. **Check for value mismatches** between corresponding option arrays:
|
||||
- Different number of options (e.g., 3 vs 4 options)
|
||||
- Same label but different `value` (this causes the bug in issue #926)
|
||||
- Different labels for the same `value`
|
||||
- Missing `_UNSPECIFIED`/Default option in one but not the other
|
||||
|
||||
4. **Check onChange handler consistency:**
|
||||
- Both should remove parameter on `_UNSPECIFIED` selection
|
||||
- Both should set explicit values the same way
|
||||
- Watch for conditions like `value === 'high'` that may differ
|
||||
|
||||
**Output Format:**
|
||||
|
||||
```
|
||||
## Parameter Sync Audit Report
|
||||
|
||||
### Missing Parameters
|
||||
- [ ] `llmVndXyz` - In full dialog, missing from side panel
|
||||
|
||||
### Value Mismatches
|
||||
- [ ] `_xyzOptions`:
|
||||
- Full dialog: [values...]
|
||||
- Side panel: [values...]
|
||||
- Issue: [description]
|
||||
|
||||
### Handler Inconsistencies
|
||||
- [ ] `llmVndXyz` onChange differs: [explanation]
|
||||
|
||||
### Recommended Fixes
|
||||
1. [Specific fix with code snippet if needed]
|
||||
```
|
||||
|
||||
**Fix Direction:** Full dialog is source of truth. Update side panel to match its values when mismatched.
|
||||
|
||||
**Notes:**
|
||||
- Side panel uses shorter descriptions (space-constrained) - that's fine
|
||||
- Variable names may differ (e.g., `_anthropicEffortOptions` vs `_antEffortOptions`) - that's fine, but same is better
|
||||
- `value` fields must be identical sets
|
||||
- `_UNSPECIFIED` must mean the same thing in both
|
||||
- onChange: remove on `_UNSPECIFIED`, set explicit value otherwise
|
||||
@@ -4,17 +4,46 @@ description: Update Anthropic model definitions with latest pricing and capabili
|
||||
|
||||
Update `src/modules/llms/server/anthropic/anthropic.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.
|
||||
Reference files (for context only, do not modify):
|
||||
- `src/modules/llms/server/llm.server.types.ts`
|
||||
- `src/modules/llms/server/models.mappings.ts`
|
||||
- `src/common/stores/llms/llms.parameters.ts`
|
||||
|
||||
**Primary Sources:**
|
||||
- Models: https://docs.claude.com/en/docs/about-claude/models/overview
|
||||
- Pricing: https://claude.com/pricing#api
|
||||
- Deprecations: https://docs.claude.com/en/docs/about-claude/model-deprecations
|
||||
**Workflow: Start with recent changes, then verify the full model list.**
|
||||
|
||||
**Fallbacks if blocked:** Check Anthropic TypeScript SDK at https://github.com/anthropics/anthropic-sdk-typescript, search "anthropic models latest pricing", "anthropic latest models", or search GitHub for latest model prices and context windows
|
||||
**Primary Sources (append `.md` to any path for clean markdown):**
|
||||
1. Recent changes: https://platform.claude.com/docs/en/release-notes/overview.md
|
||||
2. Models & IDs: https://platform.claude.com/docs/en/about-claude/models/overview.md
|
||||
3. Pricing (base, cache, batch, long context): https://platform.claude.com/docs/en/about-claude/pricing.md
|
||||
4. Deprecations & retirement dates: https://platform.claude.com/docs/en/about-claude/model-deprecations.md
|
||||
|
||||
**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
|
||||
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
|
||||
|
||||
**Fallback web pages** (crawl if `.md` paths break or structure changes):
|
||||
- https://platform.claude.com/docs/en/about-claude/models/overview
|
||||
- https://platform.claude.com/docs/en/about-claude/pricing
|
||||
- https://platform.claude.com/docs/en/release-notes/overview
|
||||
- https://claude.com/pricing
|
||||
|
||||
**Fallbacks if blocked:** Check the Anthropic TypeScript SDK at
|
||||
https://github.com/anthropics/anthropic-sdk-typescript, or web-search
|
||||
for "anthropic models latest pricing" / "anthropic latest models".
|
||||
|
||||
**Important:**
|
||||
- Review the full model list for additions, removals, and price changes
|
||||
- For new models: check which `parameterSpecs` are needed (thinking mode,
|
||||
effort levels, 1M context, skills, web tools) by reading the linked
|
||||
feature docs and comparing with existing model entries
|
||||
- When thinking/effort semantics change between generations
|
||||
(e.g. adaptive vs manual thinking), document in comments
|
||||
- Minimize whitespace/comment changes, focus on content
|
||||
- Preserve comments to make diffs easy to review
|
||||
- Flag broken links or unexpected content
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
description: Update/validate dynamic vendor model parsers (OpenRouter, TogetherAI, Alibaba, Azure, Novita, ChutesAI, FireworksAI, TLUS, LM Studio, LocalAI, FastAPI)
|
||||
---
|
||||
|
||||
Validate that the dynamic (API-fetched) vendor model parsers are up to date and not silently broken.
|
||||
|
||||
These vendors do NOT have hardcoded model lists - they fetch models from APIs at runtime. But their parsers, filters, heuristic detection, and capability mapping can break if upstream APIs change. This skill covers all dynamic vendors NOT covered by the other `llms:update-models-{vendor}` skills.
|
||||
|
||||
## Vendors to Validate
|
||||
|
||||
### High Risk
|
||||
|
||||
**OpenRouter** - `src/modules/llms/server/openai/models/openrouter.models.ts`
|
||||
- Most complex parser. Vendor-specific parameter inheritance (Anthropic thinking variants, Gemini thinking/image, OpenAI reasoning effort, xAI/DeepSeek reasoning).
|
||||
- Hardcoded family ordering list (lines ~24-37) - check if new leading vendors are missing.
|
||||
- Hardcoded old/deprecated model hiding list (lines ~39-49) - check if stale.
|
||||
- Cache pricing detection (Anthropic-style vs OpenAI-style) - verify format still valid.
|
||||
- Variant injection for Anthropic thinking/non-thinking - verify still correct.
|
||||
- Reference: https://openrouter.ai/docs/models
|
||||
|
||||
### Medium Risk
|
||||
|
||||
**Novita** - `src/modules/llms/server/openai/models/novita.models.ts`
|
||||
- Features array mapping (`function-calling`, `reasoning`, `structured-outputs`) and input modalities parsing.
|
||||
- Pricing unit conversion (hundredths of cent per million → dollars per 1K).
|
||||
- Hostname heuristic: `novita.ai`.
|
||||
|
||||
**ChutesAI** - `src/modules/llms/server/openai/models/chutesai.models.ts`
|
||||
- Custom `max_model_len` field for context window.
|
||||
- Assumes all models support Vision + Functions (aggressive).
|
||||
- Hostname heuristic: `.chutes.ai`.
|
||||
|
||||
**FireworksAI** - `src/modules/llms/server/openai/models/fireworksai.models.ts`
|
||||
- Relies on provider capability flags: `supports_chat`, `supports_image_input`, `supports_tools`.
|
||||
- Hostname heuristic: `fireworks.ai/`.
|
||||
|
||||
**TogetherAI** - `src/modules/llms/server/openai/models/together.models.ts`
|
||||
- Type allow-list (`type: 'chat'`), vision detection by string match.
|
||||
- Custom wire schema with pricing conversion.
|
||||
|
||||
**TLUS** - `src/modules/llms/server/openai/models/tlusapi.models.ts`
|
||||
- Detected by response structure (`total_models`, `free_models`, `pro_models` fields).
|
||||
- Capability enum mapping (`text`, `vision`, `audio`, `tool-calling`, `reasoning`, `websearch`).
|
||||
- Tier-based pricing (`free` vs paid).
|
||||
|
||||
**Alibaba** - `src/modules/llms/server/openai/models/alibaba.models.ts`
|
||||
- Model list was cleared (dynamic-only). Exclusion patterns for non-chat models.
|
||||
- Assumes 128K context and Vision+Functions for all models (overly permissive).
|
||||
- Check if hardcoded data should be restored now that naming has stabilized.
|
||||
|
||||
### Low Risk (local/generic - validate only if issues reported)
|
||||
|
||||
**Azure** - `src/modules/llms/server/openai/models/azure.models.ts`
|
||||
- Custom deployments API, not `/v1/models`. User-specific. Deployment name fallback logic.
|
||||
|
||||
**LM Studio** - `src/modules/llms/server/openai/models/lmstudio.models.ts`
|
||||
- Local service, native API (`/api/v1/models`). GGUF metadata parsing, capability flags.
|
||||
|
||||
**LocalAI** - `src/modules/llms/server/openai/models/localai.models.ts`
|
||||
- Local service. String-based hide list, vision/reasoning detection by name pattern.
|
||||
|
||||
**FastAPI** - `src/modules/llms/server/openai/models/fastapi.models.ts`
|
||||
- Generic passthrough. Detected by `owned_by === 'fastchat'`. Minimal parsing.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
For each vendor (prioritize High > Medium > Low):
|
||||
|
||||
1. **Read the parser file** and check for:
|
||||
- Deny/allow lists that may be stale (new model families missing)
|
||||
- Capability assumptions that may be wrong (e.g. "all models support vision")
|
||||
- Field names that may have changed upstream
|
||||
- Pricing conversion math that may use wrong units
|
||||
|
||||
2. **Check upstream docs** (where available) for:
|
||||
- API response schema changes
|
||||
- New model types or capability fields
|
||||
- Deprecated fields
|
||||
|
||||
3. **Cross-reference with OpenRouter** (aggregator):
|
||||
- OpenRouter surfaces models from many of these vendors
|
||||
- If OpenRouter shows capabilities that a vendor's parser misses, the parser is stale
|
||||
|
||||
4. **Fix issues found** - update parsers, filters, deny lists as needed.
|
||||
|
||||
5. Run `tsc --noEmit` after changes.
|
||||
|
||||
**Important:**
|
||||
- Do NOT convert dynamic vendors to hardcoded lists - the dynamic approach is intentional
|
||||
- Focus on parser correctness, not model coverage
|
||||
- Flag any vendor whose API response format seems to have changed substantially
|
||||
@@ -6,11 +6,11 @@ Update `src/modules/llms/server/openai/models/groq.models.ts` with latest model
|
||||
|
||||
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: https://console.groq.com/docs/models
|
||||
**Primary Source:**
|
||||
- Fetch https://console.groq.com/docs/models.md directly (markdown format, no search needed)
|
||||
- Pricing: https://groq.com/pricing/
|
||||
|
||||
**Fallbacks if blocked:** Search "groq models latest pricing", "groq latest models", "groq api models", or search GitHub for latest model prices and context windows
|
||||
**Do NOT use web search.** The `.md` endpoint provides structured markdown content directly.
|
||||
|
||||
**Important:**
|
||||
- Review the full model list for additions, removals, and price changes
|
||||
|
||||
@@ -6,11 +6,11 @@ Update `src/modules/llms/server/openai/models/moonshot.models.ts` with latest mo
|
||||
|
||||
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:**
|
||||
**Primary Sources (fetch directly, no search needed):**
|
||||
- Pricing: https://platform.moonshot.ai/docs/pricing/chat
|
||||
- API Reference: https://platform.moonshot.ai/docs/api/chat
|
||||
|
||||
**Fallbacks if blocked:** Search "moonshot kimi models latest pricing", "kimi k2 models", "moonshot api models", or search GitHub for latest model prices and context windows
|
||||
**Do NOT use web search.** Fetch the URLs directly, or ask the user to provide data, if unaccessible.
|
||||
|
||||
**Important:**
|
||||
- Review the full model list for additions, removals, and price changes
|
||||
|
||||
@@ -8,8 +8,8 @@ Reference `src/modules/llms/server/llm.server.types.ts` and `src/modules/llms/se
|
||||
|
||||
**Automated Workflow:**
|
||||
```bash
|
||||
# 1. Fetch the HTML
|
||||
curl -s "https://ollama.com/library?sort=featured" -o /tmp/ollama-featured.html
|
||||
# 1. Fetch the HTML (sorted by newest for stable ordering)
|
||||
curl -s "https://ollama.com/library?sort=newest" -o /tmp/ollama-newest.html
|
||||
|
||||
# 2. Parse it with the script
|
||||
node .claude/scripts/parse-ollama-models.js > /tmp/ollama-parsed.txt 2>&1
|
||||
@@ -22,15 +22,18 @@ The parser outputs: `modelName|pulls|capabilities|sizes`
|
||||
- Example: `deepseek-r1|66200000|tools,thinking|1.5b,7b,8b,14b,32b,70b,671b`
|
||||
|
||||
**Primary Sources:**
|
||||
- Model Library: https://ollama.com/library?sort=featured
|
||||
- Model Library: https://ollama.com/library?sort=newest
|
||||
- Parser script: `.claude/scripts/parse-ollama-models.js`
|
||||
|
||||
**Fallbacks if blocked:** Check https://github.com/ollama/ollama, search "ollama featured models", "ollama latest models", or search GitHub for latest model info
|
||||
|
||||
**Important:**
|
||||
- Skip models below 50,000 pulls (parser does this automatically)
|
||||
- Skip embedding models (parser does not do this automatically)
|
||||
- Sort them in the EXACT same order as the source (featured models)
|
||||
- Parser filtering rules:
|
||||
- Top 30 newest models are always included (regardless of pull count)
|
||||
- After top 30, only models with 50K+ pulls are included
|
||||
- Models with 'cloud' capability are automatically excluded
|
||||
- Models with 'embedding' capability are automatically excluded
|
||||
- Sort them in the EXACT same order as the source (newest first, for stable ordering)
|
||||
- Extract tags: 'tools' → hasTools, 'vision' → hasVision, 'embedding' → isEmbeddings (note the 's'), 'thinking' → tags only
|
||||
- Extract 'b' tags (1.5b, 7b, 32b) to tags field
|
||||
- Set today's date (YYYYMMDD format) for newly added models only
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
description: Verify model parameterSpecs match API-validated sweep data
|
||||
argument-hint: openai | anthropic | gemini | xai (or empty for all)
|
||||
---
|
||||
|
||||
# Verify LLM Parameters
|
||||
|
||||
Compare model `parameterSpecs` in definition files against API-validated sweep data.
|
||||
|
||||
If `$ARGUMENTS` provided, verify only that dialect, which includes reading the pair of sweep results and model defintions. Otherwise verify all four, and read the pairs in sequence.
|
||||
|
||||
## Files
|
||||
|
||||
**Sweep results** (source of truth for select parameters):
|
||||
- `tools/develop/llm-parameter-sweep/llm-{dialect}-parameters-sweep.json`
|
||||
By the time you see these files, the repo owner has already updated them via `tools/develop/llm-parameter-sweep/sweep.sh` (very long running, 15 min per vendor).
|
||||
|
||||
**Model definitions (source of truth for model defintions for the user and application, including constants, interfaces, supported parameters and sometimes allowed parameter values)**:
|
||||
- OpenAI: `src/modules/llms/server/openai/models/openai.models.ts`
|
||||
- Anthropic: `src/modules/llms/server/anthropic/anthropic.models.ts`
|
||||
- Gemini: `src/modules/llms/server/gemini/gemini.models.ts`
|
||||
- xAI: `src/modules/llms/server/openai/models/xai.models.ts`
|
||||
|
||||
## Task
|
||||
|
||||
The sweep data is the source of truth for allowed model parameter values or value ranges.
|
||||
|
||||
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
|
||||
- etc.
|
||||
|
||||
Report models where the definition doesn't match the sweep.
|
||||
|
||||
## Parameter Mapping
|
||||
|
||||
Example parameter mapping. Note that new parameters may have been added to both the definition, and the sweep.
|
||||
The objective of the sweep is to hint at model definition values, but the model definitions are what matters for Big-AGI,
|
||||
and need to be carefully updated, otherwise thousands of clients may break.
|
||||
|
||||
| Dialect | Sweep Key | Model paramId |
|
||||
|-----------|--------------------------|------------------------------|
|
||||
| OpenAI | `oai-reasoning-effort` | `llmVndOaiEffort` |
|
||||
| OpenAI | `oai-verbosity` | `llmVndOaiVerbosity` |
|
||||
| OpenAI | `oai-image-generation` | `llmVndOaiImageGeneration` |
|
||||
| OpenAI | `oai-web-search` | `llmVndOaiWebSearchContext` |
|
||||
| Anthropic | `ant-effort` | `llmVndAntEffort` |
|
||||
| Anthropic | `ant-thinking-budget` | `llmVndAntThinkingBudget` |
|
||||
| Gemini | `gemini-thinking-level` | `llmVndGemEffort` |
|
||||
| Gemini | `gemini-thinking-budget` | `llmVndGeminiThinkingBudget` |
|
||||
| xAI | `xai-web-search` | `llmVndXaiWebSearch` |
|
||||
|
||||
## Output
|
||||
|
||||
Report first for every model the expected values from the sweep, then the actual values from the definition, then the mismatches.
|
||||
|
||||
Finally make one table for each dialect listing all models with mismatches and the specific issues.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: Generate changelog bullets for big-agi.com/changes
|
||||
argument-hint: date like "2026-01-10" or empty for auto-detect
|
||||
---
|
||||
|
||||
Generate changelog bullets for a single entry in https://big-agi.com/changes
|
||||
|
||||
**Step 1: Find the starting date**
|
||||
|
||||
IMPORTANT: This repo rebases frequently, so commits are INTERLEAVED throughout history.
|
||||
New commits can appear at line 10, 500, or 1800. Use AUTHOR DATE (`%ad`) to filter - it's preserved during rebases.
|
||||
|
||||
If `$ARGUMENTS` provided, use it as the cutoff date.
|
||||
|
||||
If NO argument:
|
||||
1. Fetch https://big-agi.com/changes to get the most recent changelog date
|
||||
2. Use that date as the cutoff
|
||||
|
||||
**Step 2: Get commits by author date**
|
||||
|
||||
Filter commits by author date to catch ALL new commits regardless of position in history:
|
||||
|
||||
```bash
|
||||
# For commits after Jan 10, 2026 (adjust date pattern as needed)
|
||||
git log --oneline --no-merges --format="%h %ad %s" --date=short | grep "2026-01-1[1-9]\|2026-01-2\|2026-02"
|
||||
|
||||
# Verify interleaving by checking line numbers
|
||||
git log --oneline --no-merges --format="%h %ad %s" --date=short | grep -n "2026-01-1[1-9]"
|
||||
```
|
||||
|
||||
The line numbers prove commits are scattered (e.g., lines 14, 638, 1156, 1803 = interleaved).
|
||||
|
||||
**Step 3: Write bullets**
|
||||
|
||||
Real examples from big-agi.com/changes:
|
||||
- "Gemini 3 Flash support with 4-level thinking: high, medium, low, minimal"
|
||||
- "Cloud Sync launched! - long awaited and top requested"
|
||||
- "Deepseek V3.2 Speciale comes with almost Gemini 3 Pro performance but 20 times cheaper"
|
||||
- "Anthropic Opus 4.5 with controls for effort (speed tradeoff), thinking budget, search"
|
||||
- "Login with email, via magic link"
|
||||
- "Mobile UX fixes for popups drag/interaction"
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. **Order by importance** - most significant changes first, minor fixes last
|
||||
2. **Feature-first, no verb prefixes** - "Gemini 3 support" not "Add Gemini 3 support"
|
||||
3. **Model names lead** when it's about LLMs
|
||||
4. **Specific details** - "4-level thinking: high, medium, low, minimal" not "multiple thinking levels"
|
||||
5. **One-liners** - short, no fluff
|
||||
6. **Consolidate commits** - 10 persona editor commits = 1 bullet
|
||||
7. **No corporate speak** - no "enhanced", "streamlined", "robust", "leverage"
|
||||
|
||||
**Skip:** WIP, internal refactors, KB docs, automation, review cleanups, trivial fixes, deps bumps, CI changes.
|
||||
|
||||
**Output:** Just bullets, ready to paste. 2-5 bullets but adapt depending on scope, especially
|
||||
in relation to the usual https://big-agi.com/changes entries.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
description: Execute the Big-AGI release process
|
||||
argument-hint: version like "2.0.4" or empty to auto-increment patch
|
||||
---
|
||||
|
||||
Execute the release process for Big-AGI. Go step-by-step, waiting for user approval between major steps.
|
||||
|
||||
## Step 1: Determine Version
|
||||
|
||||
If `$ARGUMENTS` provided, use it. Otherwise, read `package.json` and increment patch version.
|
||||
|
||||
## Step 2: Update Files
|
||||
|
||||
1. **package.json** - Update `version` field
|
||||
2. **src/common/app.release.ts** - Increment `Monotonics.NewsVersion` (e.g., 203 → 204)
|
||||
3. **src/apps/news/news.data.tsx** - Add new entry at top of `NewsItems` array
|
||||
|
||||
For the news entry, ask user for release name and key highlights.
|
||||
|
||||
**News entry style** - Draft is a starting point, user will refine:
|
||||
- Models lead when model-heavy, grouped together
|
||||
- Callout features get own bullet with colon explanation
|
||||
- UX items grouped, minimal bold
|
||||
- Fixes last, brief
|
||||
- Release name stays subtle - don't oversell the theme
|
||||
|
||||
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
|
||||
|
||||
Update `README.md`:
|
||||
- Line ~46: Update model examples if new flagship models
|
||||
- Line ~147: Add release bullet above previous version
|
||||
|
||||
**Style:** `- Open X.Y.Z: **Name** feature1, feature2, feature3`
|
||||
|
||||
## Step 4: Git Operations
|
||||
|
||||
User commits changes, then:
|
||||
```bash
|
||||
git tag vX.Y.Z
|
||||
git push opensource vX.Y.Z
|
||||
```
|
||||
|
||||
## Step 5: GitHub Release
|
||||
|
||||
Create release with `gh release create`. Structure:
|
||||
|
||||
```
|
||||
# Big-AGI X.Y.Z - Name
|
||||
|
||||
## What's New
|
||||
|
||||
### **Headline Feature**
|
||||
1-2 sentences explaining the main theme. Then bullet points for specifics.
|
||||
|
||||
### **Also New**
|
||||
- Bullet list of other features
|
||||
- Keep it scannable
|
||||
|
||||
**Full Changelog**: https://github.com/enricoros/big-AGI/compare/vPREV...vNEW
|
||||
|
||||
## Get Started
|
||||
Available now at [big-agi.com](https://big-agi.com), via Docker, or self-host from source.
|
||||
```
|
||||
|
||||
## Step 6: Announcements
|
||||
|
||||
Draft for user to post:
|
||||
|
||||
**Twitter** - Thematic, not feature dumps. Talk about what it means, not what it lists:
|
||||
```
|
||||
Big-AGI Open X.Y.Z is out!
|
||||
|
||||
[Theme - e.g., "Lots of love to models: native support, latest protocols, total configuration - puts you in control."]
|
||||
|
||||
[One more angle, natural prose]
|
||||
|
||||
[Optional link]
|
||||
```
|
||||
|
||||
**Discord** - Structured with bold headers:
|
||||
```
|
||||
## :partyblob: Big-AGI **Open** X.Y.Z
|
||||
|
||||
**Category:** Items
|
||||
**Category:** Items
|
||||
**More:** Count of commits/fixes
|
||||
```
|
||||
|
||||
## Tone Guide
|
||||
|
||||
**Good:**
|
||||
- "Lots of love to models: native support, latest protocols, total configuration"
|
||||
- "UX quality of life improvements, from Google Drive to message reorder"
|
||||
- "Gemini 3 Flash support with 4-level thinking: high, medium, low, minimal"
|
||||
|
||||
**Bad:**
|
||||
- "Rolling out the red carpet for top models!" (too salesy)
|
||||
- "Enhanced and streamlined the robust model experience" (corporate speak)
|
||||
- "Added support for Gemini 3 Flash model with multiple thinking levels" (verb prefix, vague)
|
||||
|
||||
## Reference
|
||||
|
||||
Find previous copy at:
|
||||
- **GitHub releases:** https://github.com/enricoros/big-AGI/releases
|
||||
- **News entries:** `src/apps/news/news.data.tsx`
|
||||
- **README:** `README.md` release notes section
|
||||
- **Changelog:** https://big-agi.com/changes
|
||||
|
||||
Match the existing tone - professional but human, specific not generic, features not marketing.
|
||||
@@ -1,23 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Parse Ollama featured models from HTML
|
||||
* Parse Ollama models from HTML (sorted by newest for stable ordering)
|
||||
*
|
||||
* Usage:
|
||||
* 1. Fetch HTML: curl -s "https://ollama.com/library?sort=featured" -o /tmp/ollama-featured.html
|
||||
* 1. Fetch HTML: curl -s "https://ollama.com/library?sort=newest" -o /tmp/ollama-newest.html
|
||||
* 2. Parse: node .claude/scripts/parse-ollama-models.js
|
||||
*
|
||||
* Outputs: pipe-delimited format: modelName|pulls|capabilities|sizes
|
||||
* Example: deepseek-r1|66200000|tools,thinking|1.5b,7b,8b,14b,32b,70b,671b
|
||||
*
|
||||
* Filtering rules:
|
||||
* - Top 30 newest models are always included (regardless of pull count)
|
||||
* - After top 30, only models with 50K+ pulls are included
|
||||
* - Models with 'cloud' capability are always excluded
|
||||
* - Models with 'embedding' capability are always excluded
|
||||
*
|
||||
* Pull counts are rounded to significant figures for stable diffs:
|
||||
* - >=10M: round to 100K (e.g., 109,123,456 -> 109,100,000)
|
||||
* - >=1M: round to 10K (e.g., 5,432,100 -> 5,430,000)
|
||||
* - <1M: round to 1K (e.g., 88,700 -> 89,000)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const htmlPath = process.argv[2] || '/tmp/ollama-featured.html';
|
||||
const htmlPath = process.argv[2] || '/tmp/ollama-newest.html';
|
||||
const TOP_N_ALWAYS_INCLUDE = 30;
|
||||
const MIN_PULLS_THRESHOLD = 50000;
|
||||
|
||||
if (!fs.existsSync(htmlPath)) {
|
||||
console.error(`Error: HTML file not found at ${htmlPath}`);
|
||||
console.error('Please fetch it first with:');
|
||||
console.error(' curl -s "https://ollama.com/library?sort=featured" -o /tmp/ollama-featured.html');
|
||||
console.error(' curl -s "https://ollama.com/library?sort=newest" -o /tmp/ollama-newest.html');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -25,7 +38,7 @@ const html = fs.readFileSync(htmlPath, 'utf8');
|
||||
|
||||
// Split into model sections - each starts with <a href="/library/
|
||||
const modelSections = html.split(/<a href="\/library\//);
|
||||
const models = [];
|
||||
const allParsedModels = [];
|
||||
|
||||
for (let i = 1; i < modelSections.length; i++) {
|
||||
const section = modelSections[i].substring(0, 5000); // Large enough window to capture all data
|
||||
@@ -65,10 +78,27 @@ for (let i = 1; i < modelSections.length; i++) {
|
||||
sizes.push(sizeMatch[1].trim());
|
||||
}
|
||||
|
||||
// Only include models with 50K+ pulls
|
||||
if (pulls >= 50000) {
|
||||
models.push({ name, pulls, capabilities, sizes });
|
||||
// Skip models with 'cloud' or 'embedding' capability
|
||||
if (capabilities.includes('cloud') || capabilities.includes('embedding')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allParsedModels.push({ name, pulls: roundPulls(pulls), capabilities, sizes });
|
||||
}
|
||||
|
||||
// Apply filtering: top 30 always included, rest need 50K+ pulls
|
||||
const models = allParsedModels.filter((model, index) => {
|
||||
return index < TOP_N_ALWAYS_INCLUDE || model.pulls >= MIN_PULLS_THRESHOLD;
|
||||
});
|
||||
|
||||
/**
|
||||
* Round pulls to significant figures for stable output.
|
||||
* This reduces churn from daily fluctuations while preserving magnitude.
|
||||
*/
|
||||
function roundPulls(pulls) {
|
||||
if (pulls >= 10000000) return Math.round(pulls / 100000) * 100000; // >=10M: round to 100K
|
||||
if (pulls >= 1000000) return Math.round(pulls / 10000) * 10000; // >=1M: round to 10K
|
||||
return Math.round(pulls / 1000) * 1000; // <1M: round to 1K
|
||||
}
|
||||
|
||||
// Output in pipe-delimited format (in the order they appear on the page)
|
||||
@@ -78,4 +108,6 @@ models.forEach(m => {
|
||||
console.log(`${m.name}|${m.pulls}|${caps}|${tags}`);
|
||||
});
|
||||
|
||||
console.error(`\nTotal models with 50K+ pulls: ${models.length}`);
|
||||
const topNCount = Math.min(TOP_N_ALWAYS_INCLUDE, allParsedModels.length);
|
||||
const thresholdCount = models.length - topNCount;
|
||||
console.error(`\nTotal models: ${models.length} (top ${topNCount} newest + ${thresholdCount} with ${MIN_PULLS_THRESHOLD / 1000}K+ pulls)`);
|
||||
|
||||
@@ -4,12 +4,17 @@
|
||||
"Bash(cat:*)",
|
||||
"Bash(cp:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(eslint:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(gh issue list:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(git cherry-pick:*)",
|
||||
"Bash(git describe:*)",
|
||||
"Bash(git grep:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git ls-tree:*)",
|
||||
"Bash(git mv:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(ls:*)",
|
||||
@@ -18,13 +23,13 @@
|
||||
"Bash(npm install)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(tsc:*)",
|
||||
"Read(//tmp/**)",
|
||||
"Skill(llms:update-models*)",
|
||||
"WebFetch",
|
||||
"WebFetch(domain:big-agi.com)",
|
||||
"WebSearch",
|
||||
|
||||
+15
-40
@@ -1,43 +1,18 @@
|
||||
# big-AGI non-code files
|
||||
/docs/
|
||||
/dist/
|
||||
README.md
|
||||
*
|
||||
|
||||
# Ignore build and log files
|
||||
Dockerfile
|
||||
/.dockerignore
|
||||
!app/
|
||||
!kb/
|
||||
!pages/
|
||||
!public/
|
||||
!src/
|
||||
!tools/
|
||||
|
||||
# Node build artifacts
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
!*.mjs
|
||||
!middleware_BASIC_AUTH.ts
|
||||
!middleware.ts
|
||||
!next.config.ts
|
||||
!package*.json
|
||||
!tsconfig.json
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# versioning
|
||||
.git/
|
||||
.github/
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
!LICENSE
|
||||
!README.md
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
ignore:
|
||||
- dependency-name: "node"
|
||||
versions: [">=25", "<26"] # Node 25 breaks the build because of a dummy localStorage object
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Disabled npm updates for now - will need precise package pinning, as some packages changed behavior upstream
|
||||
# - package-ecosystem: npm
|
||||
# directory: /
|
||||
# schedule:
|
||||
# interval: weekly
|
||||
# commit-message:
|
||||
# prefix: "chore(deps)"
|
||||
# cooldown:
|
||||
# semver-patch: 3
|
||||
# semver-minor: 7
|
||||
# semver-major: 14
|
||||
# # Ignore packages intentionally pinned due to upstream issues
|
||||
# ignore:
|
||||
# # Issue #857: v11.6+ breaks streaming; tried 11.4.4/11.6/11.7, only 11.5.1 works
|
||||
# - dependency-name: "@trpc/*"
|
||||
# versions: [">=11.5.1", "<12"]
|
||||
# # Pinned during tRPC #857 debugging - may be safe to unpin, test first
|
||||
# - dependency-name: "@tanstack/react-query"
|
||||
# versions: [">=5.90.10", "<6"]
|
||||
# # Pinned because 5.0.8 changes signatures so return set({ .. }) != void;
|
||||
# - dependency-name: "zustand"
|
||||
# versions: [">=5.0.7", "<6"]
|
||||
# groups:
|
||||
# next:
|
||||
# patterns:
|
||||
# - "@next/*"
|
||||
# - "eslint-config-next"
|
||||
# - "next"
|
||||
# react:
|
||||
# patterns:
|
||||
# - "react"
|
||||
# - "react-dom"
|
||||
# - "@types/react"
|
||||
# - "@types/react-dom"
|
||||
# emotion:
|
||||
# patterns:
|
||||
# - "@emotion/*"
|
||||
# mui:
|
||||
# patterns:
|
||||
# - "@mui/*"
|
||||
# dnd-kit:
|
||||
# patterns:
|
||||
# - "@dnd-kit/*"
|
||||
# prisma:
|
||||
# patterns:
|
||||
# - "@prisma/*"
|
||||
# - "prisma"
|
||||
# vercel:
|
||||
# patterns:
|
||||
# - "@vercel/*"
|
||||
@@ -12,27 +12,30 @@ on:
|
||||
|
||||
jobs:
|
||||
claude-dm:
|
||||
# Only allow repository owner to trigger DMs with @claude (blocks other users and bots)
|
||||
if: |
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) ||
|
||||
github.actor == 'enricoros' &&
|
||||
github.triggering_actor == 'enricoros' &&
|
||||
((github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) ||
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude'))
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')))
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
contents: write # Required for code creation and commits
|
||||
issues: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
id-token: write # required to use OIDC to authenticate to Claude Code API
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0 # 1 -> 0: full history helps with git blame, etc.
|
||||
|
||||
- name: Run Claude Code DM Response
|
||||
id: claude
|
||||
@@ -41,6 +44,7 @@ jobs:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# Security: Only users with write access can trigger (DMs allow code execution)
|
||||
# Note: contents:write permission enables code creation and commits
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
@@ -49,9 +53,7 @@ jobs:
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
# disabling opus for now claude-opus-4-1-20250805
|
||||
claude_args: |
|
||||
--model claude-sonnet-4-5-20250929
|
||||
--model claude-opus-4-6
|
||||
--max-turns 100
|
||||
--allowedTools "Edit,Read,Write,WebFetch,WebSearch,Bash(cat:*),Bash(cp:*),Bash(find:*),Bash(git branch:*),Bash(grep:*),Bash(ls:*),Bash(mkdir:*),Bash(npm install),Bash(npm install:*),Bash(npm run:*),Bash(gh issue:*),Bash(gh search:*),Bash(gh label:*),Bash(gh pr:*),mcp__chrome-devtools,SlashCommand"
|
||||
--allowedTools "Edit,Read,Write,WebFetch,WebSearch,Bash(cat:*),Bash(cp:*),Bash(find:*),Bash(git branch:*),Bash(grep:*),Bash(ls:*),Bash(mkdir:*),Bash(npm run:*),Bash(gh issue:*),Bash(gh search:*),Bash(gh label:*),Bash(gh pr:*),SlashCommand"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Claude Code Auto-Triage Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [ opened, assigned ]
|
||||
types: [ opened ]
|
||||
|
||||
jobs:
|
||||
claude-issue-triage:
|
||||
@@ -17,15 +17,15 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
pull-requests: read # was write, but we're not altering PRs here
|
||||
actions: read
|
||||
id-token: write # required to use OIDC to authenticate to Claude Code API
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0 # 1 -> 0: full history helps with git blame, etc.
|
||||
|
||||
- name: Analyze issue and provide help
|
||||
uses: anthropics/claude-code-action@v1
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
# track_progress: true # Enables tracking comments
|
||||
show_full_output: ${{ github.event.repository.private }} # security: do not log verbosely in private repo
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
@@ -54,9 +55,11 @@ jobs:
|
||||
**Use web search**: When potentially outside Big-AGI (e.g. user configuration), search the web for similar errors or related issues
|
||||
**Provide a solution**:
|
||||
- Provide multiple solutions if uncertain, and say so
|
||||
- If you can fix it in code, propose the fix
|
||||
- If possible also suggest fixes or workarounds for immediate relief
|
||||
- Analyze the code and suggest specific fixes with code examples
|
||||
- If possible also suggest fixes or workarounds for immediate relief
|
||||
- Reference specific files and line numbers
|
||||
- Suggest workarounds for immediate relief if applicable
|
||||
- Use web search to find similar issues and solutions
|
||||
- Test selectively and even npm install and run build if needed to verify the solution
|
||||
2. Always add the 'claude-triage' issue label to indicate this issue was triaged by Claude
|
||||
3. Comment with:
|
||||
@@ -65,13 +68,16 @@ jobs:
|
||||
- Next steps or clarification needed
|
||||
- Link duplicates if found
|
||||
|
||||
Remember: design values for this codebase: orthogonal features, features that generalize well, modularized and reusable code,
|
||||
type-discriminated data, optimized code, zero maintenance burden. Minimize future pain, etc.
|
||||
|
||||
IMPORTANT: You are in READ-ONLY triage mode. Analyze and suggest solutions in your comment, but do NOT attempt to push code changes.
|
||||
If you're uncertain, say so and suggest next steps.
|
||||
If you write any code make sure that it compiles and that you push it.
|
||||
Be welcoming, helpful, professional, solution-focused and no-BS.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
claude_args: |
|
||||
--model claude-sonnet-4-5-20250929
|
||||
--model claude-opus-4-6
|
||||
--max-turns 75
|
||||
--allowedTools "Edit,Read,Write,WebFetch,WebSearch,Bash(cat:*),Bash(cp:*),Bash(find:*),Bash(git branch:*),Bash(grep:*),Bash(ls:*),Bash(mkdir:*),Bash(npm install),Bash(npm install:*),Bash(npm run:*),Bash(gh issue:*),Bash(gh search:*),Bash(gh label:*),Bash(gh pr:*),mcp__chrome-devtools,SlashCommand"
|
||||
--allowedTools "Edit,Read,Write,WebFetch,WebSearch,Bash(cat:*),Bash(cp:*),Bash(find:*),Bash(git branch:*),Bash(grep:*),Bash(ls:*),Bash(mkdir:*),Bash(npm run:*),Bash(gh issue:*),Bash(gh search:*),Bash(gh label:*),Bash(gh pr:*),SlashCommand"
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
name: Claude Code PR Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ opened, synchronize, ready_for_review ]
|
||||
|
||||
# Limit branches
|
||||
branches: [ main, dev, v1 ]
|
||||
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
|
||||
jobs:
|
||||
claude-pr-review:
|
||||
# Skip draft PRs
|
||||
# Optional: filter authors: github.event.pull_request.user.login != 'enricoros'
|
||||
if: |
|
||||
github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run PR Review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# Security: Allow any user to trigger reviews (read-only PR analysis is safe)
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
# track_progress: true # Enables tracking comments
|
||||
|
||||
# This setting allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Potential bugs or issues
|
||||
- Adherence to Big-AGI architecture and design patterns
|
||||
- Code quality and best practices, including TypeScript types, error handling, and edge cases
|
||||
- Performance considerations: bundle size, React patterns, streaming efficiency
|
||||
- Security concerns if applicable
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
Use `gh pr review comment` for inline suggestions on specific lines.
|
||||
|
||||
IMPORTANT: After completing your review, always add the 'claude-review' label to the PR to indicate it was reviewed by Claude:
|
||||
gh pr edit ${{ github.event.pull_request.number }} --add-label "claude-review"
|
||||
|
||||
Be constructive, helpful, no-BS, and specific with file:line references.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
claude_args: |
|
||||
--model claude-sonnet-4-5-20250929
|
||||
--max-turns 100
|
||||
--allowedTools "Edit,Read,Write,WebFetch,WebSearch,Bash(cat:*),Bash(cp:*),Bash(find:*),Bash(git branch:*),Bash(grep:*),Bash(ls:*),Bash(mkdir:*),Bash(npm install),Bash(npm install:*),Bash(npm run:*),Bash(gh issue:*),Bash(gh search:*),Bash(gh label:*),Bash(gh pr:*),mcp__chrome-devtools"
|
||||
@@ -20,29 +20,122 @@ env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60 # Max 1 hour (expected: ~25min)
|
||||
# Build job: runs on native runners for each platform (no QEMU emulation)
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
name: Build ${{ matrix.platform }}
|
||||
timeout-minutes: 30
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Big-AGI Open
|
||||
org.opencontainers.image.description=Big-AGI Open - Multi-model AI workspace for experts who need to think broader, decide smarter, and build with confidence.
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.documentation=https://big-agi.com
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
NEXT_PUBLIC_BUILD_HASH=${{ github.sha }}
|
||||
NEXT_PUBLIC_BUILD_REF_NAME=${{ github.ref_name }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true
|
||||
provenance: false
|
||||
cache-from: type=gha,scope=${{ github.repository }}-${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=${{ github.repository }}-${{ matrix.platform }},mode=max
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Merge job: combines platform-specific images into a unified multi-arch manifest
|
||||
merge:
|
||||
name: Merge manifests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: build
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -50,7 +143,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -66,28 +159,18 @@ jobs:
|
||||
# Version tags (v2.0.0, 2.0.0)
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Big-AGI Open
|
||||
org.opencontainers.image.description=Big-AGI Open - Multi-model AI workspace for experts who need to think broader, decide smarter, and build with confidence.
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.documentation=https://big-agi.com
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
NEXT_PUBLIC_BUILD_HASH=${{ github.sha }}
|
||||
NEXT_PUBLIC_BUILD_REF_NAME=${{ github.ref_name }}
|
||||
# Enable build cache (future)
|
||||
#cache-from: type=gha
|
||||
#cache-to: type=gha,mode=max
|
||||
# Enable provenance and SBOM (future)
|
||||
#provenance: true
|
||||
#sbom: true
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
--annotation='index:org.opencontainers.image.title=Big-AGI Open' \
|
||||
--annotation='index:org.opencontainers.image.description=Big-AGI Open - Multi-model AI workspace for experts who need to think broader, decide smarter, and build with confidence.' \
|
||||
--annotation='index:org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}' \
|
||||
--annotation='index:org.opencontainers.image.documentation=https://big-agi.com' \
|
||||
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ steps.meta.outputs.version }}
|
||||
@@ -53,3 +53,6 @@ next-env.d.ts
|
||||
.env*.local
|
||||
/.run/dev (ENV).run.xml
|
||||
/src/modules/3rdparty/aider/scratch*
|
||||
|
||||
# Ignore temporary CC files
|
||||
/tmpclaude*
|
||||
@@ -1,22 +1,41 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# Targeted Code Quality (safe while dev server runs)
|
||||
npx tsc --noEmit # Type check without building
|
||||
npx eslint src/path/to/file.ts # Lint specific file
|
||||
npm run lint # Lint entire project
|
||||
```
|
||||
|
||||
## 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.
|
||||
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
|
||||
|
||||
# Database & External Services
|
||||
# npm run supabase:local-update-types # Generate TypeScript types
|
||||
# npm run stripe:listen # Listen for Stripe webhooks
|
||||
```
|
||||
|
||||
### Git/GitHub remotes
|
||||
|
||||
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
|
||||
|
||||
### 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).
|
||||
**ISSUE ALL COMMANDS FROM THE ROOT, OMITTING 'cd' COMMANDS. DO NOT CHAIN CD AND OTHER COMMANDS**
|
||||
The directory structure is as follows:
|
||||
|
||||
```
|
||||
/app/api/ # Next.js App Router (API routes only, mostly -> /src/server/)
|
||||
/pages/ # Next.js Pages Router (file-based, mostly -> /src/apps/)
|
||||
@@ -31,11 +50,11 @@ Big-AGI is a Next.js 15 application with a modular architecture built for advanc
|
||||
### Key Technologies
|
||||
|
||||
- **Frontend**: Next.js 15, React 18, Material-UI Joy, Emotion (CSS-in-JS)
|
||||
- **State Management**: Zustand with localStorge/IndexedDB (single cell) persistence
|
||||
- **API Layer**: tRPC with React Query for type-safe communication
|
||||
- **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`)
|
||||
@@ -51,20 +70,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 16 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
|
||||
|
||||
@@ -73,15 +92,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:
|
||||
@@ -89,7 +117,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
|
||||
@@ -101,16 +128,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)
|
||||
@@ -122,6 +139,7 @@ Located in `/src/common/layout/optima/`
|
||||
2. **Per-Instance Stores** (Vanilla Zustand)
|
||||
- `store-beam_vanilla`: Beam scatter/gather state
|
||||
- `store-perchat_vanilla`: Chat overlay state
|
||||
- `store-attachment-drafts_vanilla`: Attachment drafts
|
||||
- High-performance, no React integration
|
||||
|
||||
3. **Module Stores**
|
||||
@@ -131,94 +149,57 @@ 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
|
||||
|
||||
Architecture and system documentation is available in the `/kb/` knowledge base:
|
||||
|
||||
@kb/KB.md
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Testing & Quality
|
||||
- Run `npm run lint` before committing
|
||||
- Type-check with `npx tsc --noEmit`
|
||||
- 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 `env.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(
|
||||
llmId,
|
||||
request,
|
||||
{ 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,
|
||||
// ... 14 more vendors
|
||||
};
|
||||
```
|
||||
|
||||
## Server Architecture
|
||||
|
||||
@@ -226,9 +207,13 @@ 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)
|
||||
- **External Services** - ElevenLabs (TTS), Google Search, YouTube transcripts
|
||||
- **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`
|
||||
|
||||
@@ -240,3 +225,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.
|
||||
|
||||
+19
-10
@@ -1,5 +1,8 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# check=skip=CopyIgnoredFile
|
||||
|
||||
# Base
|
||||
FROM node:22-alpine AS base
|
||||
FROM node:24-alpine AS base
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Dependencies
|
||||
@@ -39,19 +42,20 @@ ENV NEXT_PUBLIC_GA4_MEASUREMENT_ID=${NEXT_PUBLIC_GA4_MEASUREMENT_ID}
|
||||
ARG NEXT_PUBLIC_POSTHOG_KEY
|
||||
ENV NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
|
||||
# Optional argument to configure Google Drive Picker at build time (can reuse AUTH_GOOGLE_ID value)
|
||||
ARG NEXT_PUBLIC_GOOGLE_DRIVE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_GOOGLE_DRIVE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_DRIVE_CLIENT_ID}
|
||||
|
||||
# Copy development deps and source
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# link ssl3 for latest Alpine
|
||||
RUN sh -c '[ ! -e /lib/libssl.so.3 ] && ln -s /usr/lib/libssl.so.3 /lib/libssl.so.3 || echo "Link already exists"'
|
||||
|
||||
# Build the application
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# Reduce installed packages to production-only
|
||||
RUN npm prune --production
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
|
||||
# Runner
|
||||
@@ -59,18 +63,23 @@ FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# As user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs \
|
||||
&& apk add --no-cache openssl
|
||||
|
||||
# Copy Built app
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src/server/prisma ./src/server/prisma
|
||||
# Instead of `COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next`, we only extract some parts, excluding .next/cache which is build time only:
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/BUILD_ID ./.next/
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/server ./.next/server
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/types ./.next/types
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/*.json ./.next/
|
||||
|
||||
# Minimal ENV for production
|
||||
ENV NODE_ENV=production
|
||||
ENV PATH=$PATH:/app/node_modules/.bin
|
||||
|
||||
# Run as non-root user
|
||||
USER nextjs
|
||||
@@ -79,4 +88,4 @@ USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["next", "start"]
|
||||
CMD ["/app/node_modules/.bin/next", "start"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023-2025 Enrico Ros
|
||||
Copyright (c) 2023-2026 Enrico Ros
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[](https://discord.gg/MkH4qj2Jp9)
|
||||
<br/>
|
||||
[](https://github.com/enricoros/big-agi/commits)
|
||||
[](https://github.com/enricoros/big-AGI/pkgs/container/big-agi)
|
||||
[](https://github.com/enricoros/big-AGI/pkgs/container/big-agi)
|
||||
[](https://github.com/enricoros/big-AGI/graphs/contributors)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
<br/>
|
||||
@@ -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, Kimi K2 or GPT 5.1 -
|
||||
**Intelligence**: with [Beam & Merge](https://big-agi.com/beam) for multi-model de-hallucination, native search, and bleeding-edge AI models like Opus 4.6, Nano Banana Pro, Kimi K2.5 or GPT 5.2 -
|
||||
**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.
|
||||
|
||||
@@ -144,7 +144,8 @@ 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.1: **Opus 4.5** full support, **Gemini 3 Pro** w/ code exec, **Nano Banana Pro**, **Grok 4.1**, **GPT-5.1**, **Kimi K2 Thinking** + 280 fixes
|
||||
- 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
|
||||
|
||||
### What's New in 2.0 · Oct 31, 2025 · Open
|
||||
|
||||
@@ -312,7 +313,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)
|
||||
|
||||
|  |  |  |  |  |
|
||||
@@ -323,16 +324,16 @@ Delightful UX with latest models exclusive features like Beam for **multi-model
|
||||
|
||||
### AI Models & Vendors
|
||||
|
||||
Configure 100s of AI models from 18+ 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) |
|
||||
| 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/) |
|
||||
| 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) (Voice synthesis / cloning) |
|
||||
| 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
|
||||
|
||||
@@ -388,4 +389,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-2025** · Enrico Ros × [Big-AGI](https://big-agi.com)
|
||||
**2023-2026** · Enrico Ros × [Big-AGI](https://big-agi.com)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
#
|
||||
# For more examples, such running big-AGI alongside a web browsing service, see the `docs/docker` folder.
|
||||
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
big-agi:
|
||||
image: ghcr.io/enricoros/big-agi:latest
|
||||
@@ -11,4 +9,3 @@ services:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
command: [ "next", "start", "-p", "3000" ]
|
||||
+4
-2
@@ -21,6 +21,7 @@ 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),
|
||||
@@ -29,7 +30,8 @@ How to set up AI models and features in big-AGI.
|
||||
[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](http://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
|
||||
@@ -43,7 +45,7 @@ How to set up AI models and features in big-AGI.
|
||||
- **[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
|
||||
- **Voice Synthesis**: ElevenLabs API for voice generation
|
||||
- **Voice Synthesis**: ElevenLabs, Inworld, OpenAI TTS, LocalAI, or browser Web Speech API
|
||||
|
||||
## Deployment & Customization
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Google Drive Integration
|
||||
|
||||
Attach files from Google Drive directly in the chat composer.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Enable APIs
|
||||
|
||||
In [Google Cloud Console](https://console.cloud.google.com/):
|
||||
|
||||
1. Go to **APIs & Services > Library**
|
||||
2. Enable **Google Drive API** and **Google Picker API**
|
||||
|
||||
### 2. Configure OAuth
|
||||
|
||||
1. Go to **APIs & Services > OAuth consent screen**
|
||||
2. Create consent screen (External or Internal)
|
||||
3. Add scope: `https://www.googleapis.com/auth/drive.file`
|
||||
4. Add test users if in testing mode
|
||||
|
||||
### 3. Create Credentials
|
||||
|
||||
1. Go to **APIs & Services > Credentials**
|
||||
2. Create **OAuth client ID** (Web application)
|
||||
3. Add JavaScript origins:
|
||||
- `http://localhost:3000` (dev)
|
||||
- `https://your-domain.com` (prod)
|
||||
|
||||
### 4. Set Environment Variable
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_GOOGLE_DRIVE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
- Click **Drive** button in attachment menu
|
||||
|
||||
## Supported Files
|
||||
|
||||
| Type | Export Format |
|
||||
|-----------------|---------------------|
|
||||
| Regular files | Downloaded directly |
|
||||
| Google Docs | Markdown (.md) |
|
||||
| Google Sheets | CSV (.csv) |
|
||||
| Google Slides | PDF (.pdf) |
|
||||
| Google Drawings | SVG (.svg) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Picker won't open**: Check `NEXT_PUBLIC_GOOGLE_DRIVE_CLIENT_ID` is set and APIs are enabled.
|
||||
|
||||
**OAuth errors**: Verify your domain is in authorized JavaScript origins. Add yourself as test user if app is in testing mode.
|
||||
|
||||
**Download fails**: Check file permissions and that Drive API is enabled.
|
||||
@@ -19,7 +19,6 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- PUPPETEER_WSS_ENDPOINT=ws://browserless:3000
|
||||
command: [ "next", "start", "-p", "3000" ]
|
||||
depends_on:
|
||||
- browserless
|
||||
|
||||
|
||||
@@ -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=
|
||||
@@ -66,8 +71,9 @@ HTTP_BASIC_AUTH_PASSWORD=
|
||||
# Frontend variables
|
||||
NEXT_PUBLIC_MOTD=
|
||||
NEXT_PUBLIC_GA4_MEASUREMENT_ID=
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
NEXT_PUBLIC_GOOGLE_DRIVE_CLIENT_ID=
|
||||
NEXT_PUBLIC_PLANTUML_SERVER_URL=
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
```
|
||||
|
||||
## Backend Variables
|
||||
@@ -99,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 |
|
||||
@@ -132,10 +143,11 @@ Enable the app to Talk, Draw, and Google things up.
|
||||
|
||||
| Variable | Description |
|
||||
|:---------------------------|:------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Text-To-Speech** | [ElevenLabs](https://elevenlabs.io/) is a high quality speech synthesis service |
|
||||
| **Text-To-Speech** | ElevenLabs, Inworld, OpenAI TTS, LocalAI, and browser Web Speech API are supported |
|
||||
| `ELEVENLABS_API_KEY` | ElevenLabs API Key - used for calls, etc. |
|
||||
| `ELEVENLABS_API_HOST` | Custom host for ElevenLabs |
|
||||
| `ELEVENLABS_VOICE_ID` | Default voice ID for ElevenLabs |
|
||||
| | *Note: OpenAI TTS and LocalAI TTS reuse credentials from your configured LLM services (no separate env vars needed)* |
|
||||
| **Google Custom Search** | [Google Programmable Search Engine](https://programmablesearchengine.google.com/about/) produces links to pages |
|
||||
| `GOOGLE_CLOUD_API_KEY` | Google Cloud API Key, used with the '/react' command - [Link to GCP](https://console.cloud.google.com/apis/credentials) |
|
||||
| `GOOGLE_CSE_ID` | Google Custom/Programmable Search Engine ID - [Link to PSE](https://programmablesearchengine.google.com/) |
|
||||
@@ -154,8 +166,9 @@ The value of these variables are passed to the frontend (Web UI) - make sure the
|
||||
| `NEXT_PUBLIC_DEBUG_BREAKS` | (optional, development) When set to 'true', enables automatic debugger breaks on DEV/error/critical logs in development builds |
|
||||
| `NEXT_PUBLIC_MOTD` | Message of the Day - displays a dismissible banner at the top of the app (see [customizations](customizations.md) for the template variables). Example: 🔔 Welcome to our deployment! Version {{app_build_pkgver}} built on {{app_build_time}}. |
|
||||
| `NEXT_PUBLIC_GA4_MEASUREMENT_ID` | (optional) The measurement ID for Google Analytics 4. (see [deploy-analytics](deploy-analytics.md)) |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | (optional) Key for PostHog analytics. (see [deploy-analytics](deploy-analytics.md)) |
|
||||
| `NEXT_PUBLIC_GOOGLE_DRIVE_CLIENT_ID` | (optional) Google OAuth Client ID for Drive Picker. Can reuse `AUTH_GOOGLE_ID`. See [Google Drive](config-feature-google-drive.md) |
|
||||
| `NEXT_PUBLIC_PLANTUML_SERVER_URL` | The URL of the PlantUML server, used for rendering UML diagrams. Allows using custom local servers. |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | (optional) Key for PostHog analytics. (see [deploy-analytics](deploy-analytics.md)) |
|
||||
|
||||
> Important: these variables must be set at build time, which is required by Next.js to pass them to the frontend.
|
||||
> This is in contrast to the backend variables, which can be set when starting the local server/container.
|
||||
|
||||
@@ -136,11 +136,6 @@ Deploy big-AGI on a Kubernetes cluster for enhanced scalability and management.
|
||||
|
||||
For more detailed instructions on Kubernetes deployment, including updating and troubleshooting, refer to our [Kubernetes Deployment Guide](deploy-k8s.md).
|
||||
|
||||
### Midori AI Subsystem for Docker Deployment
|
||||
|
||||
Follow the instructions found on [Midori AI Subsystem Site](https://io.midori-ai.xyz/subsystem/manager/)
|
||||
for your host OS. After completing the setup process, install the Big-AGI docker backend to the Midori AI Subsystem.
|
||||
|
||||
## Enterprise-Grade Installation
|
||||
|
||||
For businesses seeking a fully-managed, scalable solution, consider our managed installations.
|
||||
|
||||
@@ -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
|
||||
@@ -23,16 +23,16 @@ Internal documentation for Big-AGI architecture and systems, for use by AI agent
|
||||
- **[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
|
||||
|
||||
## 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
|
||||
|
||||
@@ -37,6 +37,7 @@ Built with tRPC, it manages the lifecycle of AI-generated content from request t
|
||||
| Perplexity | ✅ | ❌ (rejected) | | ✅ | Yes + 📦 | |
|
||||
| TogetherAI | ✅ | ✅ | | ✅ | Yes + 📦 | |
|
||||
| xAI | | | | | | |
|
||||
| Z.ai | ✅ | ✅ | Img: ✅ | ✅ | Yes + 📦 | Thinking mode |
|
||||
| Ollama (2) | ❌ (broken) | ? | | | | |
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -13,12 +13,9 @@ The LLM parameters system operates across five layers that transform parameters
|
||||
|
||||
The `DModelParameterRegistry` defines all available parameters with their constraints and metadata. Each parameter includes type information, validation rules, and default behavior.
|
||||
|
||||
**Example**: `llmVndOaiReasoningEffort4` defines a 4-value enum with 'medium' as the required fallback.
|
||||
|
||||
**Default Value System**: The registry supports multiple default mechanisms:
|
||||
- `initialValue` - Parameter's base default (e.g., `llmVndOaiRestoreMarkdown: true`)
|
||||
- `requiredFallback` - Fallback for required parameters (e.g., `llmTemperature: 0.5`)
|
||||
- `nullable` - Parameters that can be explicitly null to skip API transmission
|
||||
- `initialValue` - Parameter's base default (e.g., `llmVndOaiRestoreMarkdown: true`)
|
||||
|
||||
### Layer 2: Model Specifications
|
||||
**File**: `src/modules/llms/server/llm.server.types.ts`
|
||||
@@ -27,7 +24,6 @@ Models declare which parameters they support through `parameterSpecs` arrays. Ea
|
||||
|
||||
```typescript
|
||||
parameterSpecs: [
|
||||
{ paramId: 'llmVndOaiReasoningEffort4' },
|
||||
{ paramId: 'llmVndAntThinkingBudget', initialValue: 1024 }, // Override default
|
||||
{ paramId: 'llmVndGeminiThinkingBudget', rangeOverride: [0, 8192] }, // Custom range
|
||||
]
|
||||
@@ -51,20 +47,14 @@ Shows only parameters that are:
|
||||
- Not marked as `hidden`
|
||||
|
||||
**Value Resolution**: Both UIs use `getAllModelParameterValues()` to merge:
|
||||
1. **Fallback values** - Required parameters get their `requiredFallback` values
|
||||
1. **Fallback values** - Implicit parameters get their `runtimeFallback` values
|
||||
2. **Initial values** - Model's `initialParameters` (populated during model creation)
|
||||
3. **User values** - User's `userParameters` (highest priority)
|
||||
|
||||
### Layer 4: AIX Translation
|
||||
**File**: `src/modules/aix/client/aix.client.ts`
|
||||
|
||||
The AIX client transforms DLLM parameters to wire protocol format. This layer handles parameter precedence rules and name transformations:
|
||||
|
||||
```
|
||||
// Parameter precedence: newer 4-value version takes priority over 3-value
|
||||
...((llmVndOaiReasoningEffort4 || llmVndOaiReasoningEffort) ?
|
||||
{ vndOaiReasoningEffort: llmVndOaiReasoningEffort4 || llmVndOaiReasoningEffort } : {})
|
||||
```
|
||||
The AIX client transforms DLLM parameters to wire protocol format. This layer handles parameter precedence rules and name transformations.
|
||||
|
||||
**Client Options**: The system supports parameter overrides through `llmOptionsOverride` and complete replacement via `llmUserParametersReplacement`.
|
||||
|
||||
@@ -73,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**: `vndOaiReasoningEffort` → `reasoning_effort`
|
||||
- **OpenAI**: `vndEffort` → `reasoning_effort`
|
||||
- **Perplexity**: Reuses OpenAI parameter format
|
||||
- **OpenAI Responses API**: Maps to structured reasoning config with additional logic
|
||||
|
||||
@@ -82,7 +72,7 @@ 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:
|
||||
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:
|
||||
@@ -105,7 +95,7 @@ When a model is loaded:
|
||||
The system maintains type safety through:
|
||||
- `DModelParameterId` union from registry keys
|
||||
- `DModelParameterValue<T>` conditional types for values
|
||||
- `DModelParameterSpec<T>` interfaces for specifications
|
||||
- `DModelParameterSpecAny` interfaces for specifications
|
||||
- Runtime validation via Zod schemas at API boundaries
|
||||
|
||||
## Model Variant Pattern
|
||||
@@ -117,7 +107,6 @@ Some vendors use model variants to enable features, for instance:
|
||||
## Migration and Compatibility
|
||||
|
||||
The architecture supports parameter evolution:
|
||||
- **Version Coexistence**: Both `llmVndOaiReasoningEffort` and `llmVndOaiReasoningEffort4` exist simultaneously
|
||||
- **Precedence Rules**: Newer parameters take priority during AIX translation
|
||||
- **Graceful Degradation**: Unknown parameters log warnings but don't break functionality
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Client-Side Fetch (CSF) enables direct browser-to-API communication, bypassing t
|
||||
|
||||
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 16 supported vendors (OpenAI, Anthropic, Gemini, Ollama, LocalAI, Deepseek, Groq, Mistral, xAI, OpenRouter, Perplexity, Together AI, Alibaba, Moonshot, OpenPipe, LM Studio) 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 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.
|
||||
|
||||
## 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.
|
||||
Generated
+1633
-956
File diff suppressed because it is too large
Load Diff
+25
-21
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"author": "Enrico Ros <enrico.ros@gmail.com>",
|
||||
"repository": "https://github.com/enricoros/big-agi",
|
||||
@@ -12,6 +12,7 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"postinstall": "prisma generate --no-hints",
|
||||
"gen:icon-sprites": "node tools/develop/gen-icon-sprites/generate-llm-sprites.ts",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local",
|
||||
@@ -29,38 +30,40 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@googleworkspace/drive-picker-react": "^0.2.0",
|
||||
"@mui/icons-material": "^5.18.0",
|
||||
"@mui/joy": "^5.0.0-beta.52",
|
||||
"@next/bundle-analyzer": "~15.1.8",
|
||||
"@next/bundle-analyzer": "~15.1.12",
|
||||
"@prisma/client": "~5.22.0",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@trpc/client": "11.5.1",
|
||||
"@trpc/next": "11.5.1",
|
||||
"@trpc/react-query": "11.5.1",
|
||||
"@trpc/server": "11.5.1",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
"@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",
|
||||
"dexie": "~4.0.11",
|
||||
"dexie-react-hooks": "~1.1.7",
|
||||
"diff": "^8.0.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"diff": "^8.0.3",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"mammoth": "^1.11.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "~15.1.8",
|
||||
"next": "~15.1.12",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "5.4.54",
|
||||
"posthog-js": "^1.298.1",
|
||||
"posthog-node": "^5.14.0",
|
||||
"posthog-js": "^1.341.0",
|
||||
"posthog-node": "^5.24.10",
|
||||
"prismjs": "^1.30.0",
|
||||
"puppeteer-core": "^24.31.0",
|
||||
"puppeteer-core": "^24.36.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^3.4.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
@@ -71,29 +74,30 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"superjson": "^2.2.6",
|
||||
"tesseract.js": "^6.0.1",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"tiktoken": "^1.0.22",
|
||||
"turndown": "^7.2.2",
|
||||
"zod": "^4.1.13",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@posthog/nextjs-config": "^1.6.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@posthog/nextjs-config": "~1.6.4",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react": "^19.2.11",
|
||||
"@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.1",
|
||||
"eslint-config-next": "~15.1.8",
|
||||
"prettier": "^3.6.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "~15.1.12",
|
||||
"prettier": "^3.8.1",
|
||||
"prisma": "~5.22.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^26.0.0 || ^24.0.0 || ^22.0.0 || ^20.0.0"
|
||||
"node": "^24.0.0 || ^22.0.0 || ^20.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
|
||||
|
||||
// derived state
|
||||
const outOfTheBlue = !props.conversationId;
|
||||
const overriddenEmptyChat = chatEmptyOverride || !chatIsEmpty;
|
||||
const overriddenEmptyChat = outOfTheBlue || chatEmptyOverride || !chatIsEmpty;
|
||||
const overriddenRecognition = recognitionOverride || recognition.mayWork;
|
||||
const synthesisShallWork = !!speexGlobalEngine;
|
||||
const allGood = overriddenEmptyChat && overriddenRecognition && synthesisShallWork;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { OptimaPanelGroupedList } from '~/common/layout/optima/panel/OptimaPanel
|
||||
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';
|
||||
import { createDMessageFromFragments, createDMessageTextContent, DMessage, messageFragmentsReduceText, messageWasInterruptedAtStart } from '~/common/stores/chat/chat.message';
|
||||
import { createErrorContentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
@@ -360,7 +361,7 @@ export function Telephone(props: {
|
||||
|
||||
<ScrollToBottom stickToBottomInitial>
|
||||
|
||||
<Box sx={{ minHeight: '100%', p: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box onCopy={clipboardInterceptCtrlCForCleanup} sx={{ minHeight: '100%', p: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
|
||||
{/* Call Messages [] */}
|
||||
{callMessages.map((message) =>
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, useTheme } from '@mui/joy';
|
||||
|
||||
import { DEV_MODE_SETTINGS } from '../settings-modal/UxLabsSettings';
|
||||
|
||||
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
import type { TradeConfig } from '~/modules/trade/TradeModal';
|
||||
import { downloadSingleChat, importConversationsFromFilesAtRest, openConversationsAtRestPicker } from '~/modules/trade/trade.client';
|
||||
@@ -40,8 +38,6 @@ import { useModelDomain } from '~/common/stores/llms/hooks/useModelDomain';
|
||||
import { useOverlayComponents } from '~/common/layout/overlays/useOverlayComponents';
|
||||
import { useRouterQuery } from '~/common/app.routes';
|
||||
import { useUIComplexityIsMinimal } from '~/common/stores/store-ui';
|
||||
import { useUXLabsStore } from '~/common/stores/store-ux-labs';
|
||||
|
||||
import { ChatPane } from './components/layout-pane/ChatPane';
|
||||
import { ChatBarBeam } from './components/layout-bar/ChatBarBeam';
|
||||
import { ChatBarAltTitle } from './components/layout-bar/ChatBarAltTitle';
|
||||
@@ -151,8 +147,6 @@ export function AppChat() {
|
||||
|
||||
const intent = useRouterQuery<Partial<AppChatIntent>>();
|
||||
|
||||
const showAltTitleBar = useUXLabsStore(state => DEV_MODE_SETTINGS && state.labsChatBarAlt === 'title');
|
||||
|
||||
const { domainModelId: chatLLMId } = useModelDomain('primaryChat');
|
||||
const chatLLM = useLLM(chatLLMId) ?? null;
|
||||
|
||||
@@ -463,7 +457,7 @@ export function AppChat() {
|
||||
|
||||
// Pluggable Optima components
|
||||
|
||||
const barAltTitle = showAltTitleBar ? focusedChatTitle ?? 'No Chat' : null;
|
||||
const barAltTitle = null;
|
||||
|
||||
const focusedBarContent = React.useMemo(() => beamOpenStoreInFocusedPane
|
||||
? <ChatBarBeam conversationTitle={focusedChatTitle ?? 'No Chat'} beamStore={beamOpenStoreInFocusedPane} isMobile={isMobile} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ConversationHandler } from '~/common/chat-overlay/ConversationHand
|
||||
import type { DLLMContextTokens } from '~/common/stores/llms/llms.types';
|
||||
import { DConversationId, excludeSystemMessages } from '~/common/stores/chat/chat.conversation';
|
||||
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 { createTextContentFragment, DMessageFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
@@ -323,7 +324,7 @@ export function ChatMessageList(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<List role='chat-messages-list' sx={listSx}>
|
||||
<List role='chat-messages-list' sx={listSx} onCopy={clipboardInterceptCtrlCForCleanup}>
|
||||
|
||||
{props.isMessageSelectionMode && (
|
||||
<MessagesSelectionHeader
|
||||
|
||||
@@ -220,7 +220,7 @@ export function CameraCaptureModal(props: {
|
||||
backdropFilter: 'none', // using none because this is heavy
|
||||
// backdropFilter: 'blur(4px)',
|
||||
// backgroundColor: 'rgba(11 13 14 / 0.75)',
|
||||
backgroundColor: 'rgba(var(--joy-palette-neutral-darkChannel) / 0.5)',
|
||||
backgroundColor: 'rgba(var(--joy-palette-neutral-darkChannel) / 0.67)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -17,7 +17,8 @@ import { useChatAutoSuggestAttachmentPrompts, useChatMicTimeoutMsValue } from '.
|
||||
import { useAgiAttachmentPrompts } from '~/modules/aifn/agiattachmentprompts/useAgiAttachmentPrompts';
|
||||
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { DLLM, getLLMContextTokens, getLLMPricing, LLM_IF_OAI_Vision } from '~/common/stores/llms/llms.types';
|
||||
import { DLLM, getLLMContextTokens, LLM_IF_OAI_Vision } from '~/common/stores/llms/llms.types';
|
||||
import { llmChatPricing_adjusted } from '~/common/stores/llms/llms.pricing';
|
||||
import { AudioGenerator } from '~/common/util/audio/AudioGenerator';
|
||||
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
import { ButtonAttachFilesMemo, openFileForAttaching } from '~/common/components/ButtonAttachFiles';
|
||||
@@ -34,7 +35,7 @@ import { copyToClipboard, supportsClipboardRead } from '~/common/util/clipboardU
|
||||
import { createTextContentFragment, DMessageAttachmentFragment, DMessageContentFragment, duplicateDMessageFragments } from '~/common/stores/chat/chat.fragments';
|
||||
import { glueForMessageTokens, marshallWrapDocFragments } from '~/common/stores/chat/chat.tokens';
|
||||
import { isValidConversation, useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { getModelParameterValueOrThrow } from '~/common/stores/llms/llms.parameters';
|
||||
import { getModelParameterValueWithFallback } from '~/common/stores/llms/llms.parameters';
|
||||
import { launchAppCall, removeQueryParam, useRouterQuery } from '~/common/app.routes';
|
||||
import { lineHeightTextareaMd, themeBgAppChatComposer } from '~/common/app.theme';
|
||||
import { optimaOpenPreferences } from '~/common/layout/optima/useOptima';
|
||||
@@ -63,8 +64,10 @@ import { chatExecuteModeCanAttach, useChatExecuteMode } from '../../execute-mode
|
||||
|
||||
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';
|
||||
@@ -197,7 +200,7 @@ export function Composer(props: {
|
||||
const showChatAttachments = chatExecuteModeCanAttach(chatExecuteMode, props.capabilityHasT2IEdit);
|
||||
const {
|
||||
/* items */ attachmentDrafts,
|
||||
/* append */ attachAppendClipboardItems, attachAppendDataTransfer, attachAppendEgoFragments, attachAppendFile, attachAppendUrl,
|
||||
/* append */ attachAppendClipboardItems, attachAppendCloudFile, attachAppendDataTransfer, attachAppendEgoFragments, attachAppendFile, attachAppendUrl,
|
||||
/* take */ attachmentsRemoveAll, attachmentsTakeAllFragments, attachmentsTakeFragmentsByType,
|
||||
} = useAttachmentDrafts(conversationOverlayStore, enableLoadURLsInComposer, chatLLMSupportsImages, handleFilterAGIFile, showChatAttachments === 'only-images');
|
||||
|
||||
@@ -231,9 +234,9 @@ export function Composer(props: {
|
||||
if (props.chatLLM && tokensComposer > 0)
|
||||
tokensComposer += glueForMessageTokens(props.chatLLM);
|
||||
const tokensHistory = _historyTokenCount;
|
||||
const tokensResponseMax = getModelParameterValueOrThrow('llmResponseTokens', props.chatLLM?.initialParameters, props.chatLLM?.userParameters, 0) ?? 0;
|
||||
const tokensResponseMax = getModelParameterValueWithFallback('llmResponseTokens', props.chatLLM?.initialParameters, props.chatLLM?.userParameters, 0) ?? 0 /* if null, assume 0*/;
|
||||
const tokenLimit = getLLMContextTokens(props.chatLLM) ?? 0;
|
||||
const tokenChatPricing = getLLMPricing(props.chatLLM)?.chat;
|
||||
const tokenChatPricing = React.useMemo(() => llmChatPricing_adjusted(props.chatLLM), [props.chatLLM]);
|
||||
|
||||
|
||||
// Effect: load initial text if queued up (e.g. by /link/share_targetF)
|
||||
@@ -545,6 +548,9 @@ export function Composer(props: {
|
||||
|
||||
// Enter: primary action
|
||||
if (e.key === 'Enter') {
|
||||
// Skip if composing (e.g., CJK input methods) - issue #784
|
||||
if (e.nativeEvent.isComposing)
|
||||
return;
|
||||
|
||||
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
|
||||
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
@@ -620,6 +626,8 @@ export function Composer(props: {
|
||||
|
||||
const { openWebInputDialog, webInputDialogComponent } = useWebInputModal(handleAttachWebLinks, composeText);
|
||||
|
||||
const { openGoogleDrivePicker, googleDrivePickerComponent } = useGoogleDrivePicker(attachAppendCloudFile, isMobile);
|
||||
|
||||
|
||||
// Attachments Down
|
||||
|
||||
@@ -799,6 +807,11 @@ export function Composer(props: {
|
||||
<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} />
|
||||
@@ -828,6 +841,9 @@ export function Composer(props: {
|
||||
{/* 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} />}
|
||||
|
||||
@@ -1123,6 +1139,9 @@ export function Composer(props: {
|
||||
{/* Camera (when open) */}
|
||||
{cameraCaptureComponent}
|
||||
|
||||
{/* Google Drive Picker (when open) */}
|
||||
{googleDrivePickerComponent}
|
||||
|
||||
{/* Web Input Dialog (when open) */}
|
||||
{webInputDialogComponent}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, ColorPaletteProp, IconButton, Tooltip } from '@mui/joy';
|
||||
import AddToDriveRoundedIcon from '@mui/icons-material/AddToDriveRounded';
|
||||
|
||||
import { buttonAttachSx } from '~/common/components/ButtonAttachFiles';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
|
||||
|
||||
export const ButtonAttachGoogleDriveMemo = React.memo(ButtonAttachGoogleDrive);
|
||||
|
||||
function ButtonAttachGoogleDrive(props: {
|
||||
color?: ColorPaletteProp,
|
||||
isMobile?: boolean,
|
||||
disabled?: boolean,
|
||||
fullWidth?: boolean,
|
||||
noToolTip?: boolean,
|
||||
onOpenGoogleDrivePicker: () => void,
|
||||
}) {
|
||||
|
||||
const button = props.isMobile ? (
|
||||
<IconButton color={props.color} disabled={props.disabled} onClick={props.onOpenGoogleDrivePicker}>
|
||||
<AddToDriveRoundedIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button
|
||||
variant={props.color ? 'soft' : 'plain'}
|
||||
color={props.color || 'neutral'}
|
||||
disabled={props.disabled}
|
||||
fullWidth={props.fullWidth}
|
||||
startDecorator={<AddToDriveRoundedIcon />}
|
||||
onClick={props.onOpenGoogleDrivePicker}
|
||||
sx={buttonAttachSx.desktop}
|
||||
>
|
||||
Drive
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (props.noToolTip || props.isMobile) ? button : (
|
||||
<Tooltip arrow disableInteractive placement='top-start' title={
|
||||
<Box sx={buttonAttachSx.tooltip}>
|
||||
<b>Add from Google Drive</b><br />
|
||||
Attach files from your Drive
|
||||
</Box>
|
||||
}>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -91,8 +91,11 @@ function InputErrorIndicator() {
|
||||
|
||||
const converterTypeToIconMap: { [key in AttachmentDraftConverterType]: React.ComponentType<any> | null } = {
|
||||
'text': TextFieldsIcon,
|
||||
'text-cleaner': CodeIcon,
|
||||
'text-markdown': TextFieldsIcon,
|
||||
'rich-text': CodeIcon,
|
||||
'rich-text-cleaner': CodeIcon,
|
||||
'rich-text-markdown': TextFieldsIcon,
|
||||
'rich-text-table': PivotTableChartIcon,
|
||||
'image-original': ImageOutlinedIcon,
|
||||
'image-resized-high': PhotoSizeSelectLargeOutlinedIcon,
|
||||
@@ -100,8 +103,10 @@ const converterTypeToIconMap: { [key in AttachmentDraftConverterType]: React.Com
|
||||
'image-to-default': ImageOutlinedIcon,
|
||||
'image-caption': AbcIcon,
|
||||
'image-ocr': AbcIcon,
|
||||
'pdf-auto': PictureAsPdfIcon,
|
||||
'pdf-text': PictureAsPdfIcon,
|
||||
'pdf-images': PermMediaOutlinedIcon,
|
||||
'pdf-images-ocr': AbcIcon,
|
||||
'pdf-text-and-images': PermMediaOutlinedIcon,
|
||||
'docx-to-html': DescriptionOutlinedIcon,
|
||||
'url-page-text': TextFieldsIcon, // was LanguageIcon
|
||||
@@ -199,13 +204,21 @@ function attachmentIcons(attachmentDraft: AttachmentDraft, noTooltips: boolean,
|
||||
|
||||
function attachmentLabelText(attachmentDraft: AttachmentDraft): string {
|
||||
const converter = attachmentDraft.converters.find(c => c.isActive) ?? null;
|
||||
if (converter && attachmentDraft.label === 'Rich Text') {
|
||||
if (converter.id === 'rich-text-table')
|
||||
return 'Rich Table';
|
||||
if (converter.id === 'rich-text-cleaner')
|
||||
if (converter && attachmentDraft.label === 'Text') {
|
||||
if (converter.id === 'text-markdown')
|
||||
return 'Markdown';
|
||||
if (converter.id === 'text-cleaner')
|
||||
return 'Clean HTML';
|
||||
}
|
||||
if (converter && attachmentDraft.label === 'Rich Text') {
|
||||
if (converter.id === 'rich-text')
|
||||
return 'Rich HTML';
|
||||
if (converter.id === 'rich-text-markdown')
|
||||
return 'Markdown';
|
||||
if (converter.id === 'rich-text-cleaner')
|
||||
return 'Clean HTML';
|
||||
if (converter.id === 'rich-text-table')
|
||||
return 'Rich Table';
|
||||
}
|
||||
return ellipsizeFront(attachmentDraft.label, 22);
|
||||
}
|
||||
@@ -228,9 +241,10 @@ function LLMAttachmentButton(props: {
|
||||
const isUnconvertible = !draft.converters.length;
|
||||
const isOutputLoading = draft.outputsConverting;
|
||||
const isOutputMissing = !draft.outputFragments.length;
|
||||
const isOutputWarned = !!draft.outputWarnings?.length;
|
||||
const hasLiveFiles = draft.outputFragments.some(_f => _f.liveFileId);
|
||||
|
||||
const showWarning = isUnconvertible || (isOutputMissing || !llmSupportsAllFragments);
|
||||
const showWarning = isUnconvertible || (isOutputMissing || !llmSupportsAllFragments) || isOutputWarned;
|
||||
|
||||
|
||||
// handlers
|
||||
@@ -257,6 +271,17 @@ function LLMAttachmentButton(props: {
|
||||
if (isInputLoading)
|
||||
return <InputLoadingPlaceholder label={draft.label} />;
|
||||
|
||||
// tooltip for truncated filenames (only show when menu is closed)
|
||||
const displayedLabel = attachmentLabelText(draft);
|
||||
const showFilenameTooltip = !props.menuShown && !isOutputLoading && displayedLabel !== draft.label;
|
||||
|
||||
// label element (reused with/without tooltip)
|
||||
const labelElement = (
|
||||
<Typography level='title-sm' sx={{ whiteSpace: 'nowrap' }}>
|
||||
{isOutputLoading ? 'Converting... ' : displayedLabel}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -280,10 +305,11 @@ function LLMAttachmentButton(props: {
|
||||
{/* Icons: Web Page Screenshot, Converter[s] */}
|
||||
{attachmentIcons(draft, props.menuShown, props.onViewImageRefPart)}
|
||||
|
||||
{/* Label */}
|
||||
<Typography level='title-sm' sx={{ whiteSpace: 'nowrap' }}>
|
||||
{isOutputLoading ? 'Converting... ' : attachmentLabelText(draft)}
|
||||
</Typography>
|
||||
{/* Label (with tooltip for truncated filenames) */}
|
||||
{showFilenameTooltip
|
||||
? <TooltipOutlined title={<span style={{ wordBreak: 'break-all' }}>{draft.label}</span>}>{labelElement}</TooltipOutlined>
|
||||
: labelElement
|
||||
}
|
||||
|
||||
{/* Is Converting icon */}
|
||||
{isOutputLoading && <CircularProgress color='success' size='sm' />}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Checkbox, Chip, CircularProgress, LinearProgress, ListDivider, ListItem, ListItemDecorator, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
import AttachmentIcon from '@mui/icons-material/Attachment';
|
||||
import { Box, Button, ButtonGroup, Checkbox, Chip, CircularProgress, Divider, LinearProgress, ListDivider, ListItem, ListItemDecorator, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
|
||||
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
|
||||
import ReadMoreIcon from '@mui/icons-material/ReadMore';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
|
||||
@@ -18,6 +17,7 @@ import { CloseablePopup } from '~/common/components/CloseablePopup';
|
||||
import { DMessageAttachmentFragment, DMessageDocPart, DMessageImageRefPart, isDocPart, isImageRefPart, isZyncAssetImageReferencePartWithLegacyDBlob } from '~/common/stores/chat/chat.fragments';
|
||||
import { LiveFileIcon } from '~/common/livefile/liveFile.icons';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { humanReadableBytes } from '~/common/util/textUtils';
|
||||
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
|
||||
import { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
|
||||
@@ -32,12 +32,20 @@ const DEFAULT_DETAILS_OPEN = true;
|
||||
const SHOW_INLINING_OPERATIONS = false;
|
||||
|
||||
|
||||
const indicatorSx = {
|
||||
fontSize: '1rem',
|
||||
} as const;
|
||||
// const indicatorSx = {
|
||||
// fontSize: '1rem',
|
||||
// } as const;
|
||||
//
|
||||
// const indicatorGapSx: SxProps = {
|
||||
// paddingLeft: '1.375rem',
|
||||
// };
|
||||
|
||||
const indicatorGapSx: SxProps = {
|
||||
paddingLeft: '1.375rem',
|
||||
const actionButtonsSx: SxProps = {
|
||||
ml: 'auto',
|
||||
minHeight: 0,
|
||||
borderRadius: '1rem',
|
||||
backgroundColor: 'background.surface',
|
||||
'& button': { fontSize: 'xs', fontWeight: 'md', py: 0, minWidth: 0, minHeight: 0 },
|
||||
};
|
||||
|
||||
|
||||
@@ -82,9 +90,10 @@ export function LLMAttachmentMenu(props: {
|
||||
const isUnconvertible = !draft.converters.length;
|
||||
const isOutputMissing = !draft.outputFragments.length;
|
||||
const isOutputMultiple = draft.outputFragments.length > 1;
|
||||
const isOutputWarned = !!draft.outputWarnings?.length;
|
||||
const hasLiveFiles = draft.outputFragments.some(_f => _f.liveFileId);
|
||||
|
||||
const showWarning = isUnconvertible || isOutputMissing || !llmSupportsAllFragments;
|
||||
const showWarning = isUnconvertible || isOutputMissing || !llmSupportsAllFragments || isOutputWarned;
|
||||
|
||||
|
||||
// hooks
|
||||
@@ -157,6 +166,8 @@ export function LLMAttachmentMenu(props: {
|
||||
minWidth={260}
|
||||
noTopPadding
|
||||
placement='top'
|
||||
placementOffset={[0, 15]}
|
||||
boxShadow='lg'
|
||||
zIndex={themeZIndexOverMobileDrawer /* was not set, but the Attachment Menu can be used from the Personas Modal */}
|
||||
>
|
||||
|
||||
@@ -187,9 +198,10 @@ export function LLMAttachmentMenu(props: {
|
||||
<ListItem sx={{ fontSize: 'sm', my: 0.75 }}>
|
||||
Attach {draftSource.media === 'url' ? 'web page'
|
||||
: draftSource.media === 'file' ? 'file'
|
||||
: draftSource.media === 'text'
|
||||
? (draftSource.method === 'drop' ? 'drop' : draftSource.method === 'clipboard-read' ? 'clipboard' : draftSource.method === 'paste' ? 'paste' : '')
|
||||
: ''} as:
|
||||
: draftSource.media === 'cloud' ? 'cloud file'
|
||||
: draftSource.media === 'text'
|
||||
? (draftSource.method === 'drop' ? 'drop' : draftSource.method === 'clipboard-read' ? 'clipboard' : draftSource.method === 'paste' ? 'paste' : '')
|
||||
: ''} as:
|
||||
{uiComplexityMode === 'extra' && (
|
||||
<Chip component='span' size='sm' color='neutral' variant='outlined' startDecorator={<ContentCopyIcon />} onClick={(event) => handleCopyLabelToClipboard(event, draft.label)} sx={{ ml: 'auto' }}>
|
||||
copy name
|
||||
@@ -197,6 +209,17 @@ export function LLMAttachmentMenu(props: {
|
||||
)}
|
||||
</ListItem>
|
||||
)}
|
||||
{/* Auto-heuristics message, with explanation */}
|
||||
{!!draft.outputsHeuristic?.isAuto && (
|
||||
<ListItem color={draft.outputsHeuristic.isAuto ? 'primary' : undefined} sx={{ fontSize: 'sm', fontWeight: 'lg', mb: 0.5 }}>
|
||||
{draft.outputsHeuristic.isAuto ? 'Auto: ' : ''}
|
||||
{draft.outputsHeuristic.actualConverterId === 'pdf-text' && 'Text'}
|
||||
{draft.outputsHeuristic.actualConverterId === 'pdf-images-ocr' && 'OCR'}
|
||||
{draft.outputsHeuristic.actualConverterId === 'pdf-images' && 'Images'}
|
||||
{draft.outputsHeuristic.actualConverterId === 'pdf-text-and-images' && 'Text + Images'}
|
||||
{draft.outputsHeuristic.explain && ` (${draft.outputsHeuristic.explain})`}
|
||||
</ListItem>
|
||||
)}
|
||||
{!isUnconvertible && draft.converters.map((c, idx) =>
|
||||
<MenuItem
|
||||
disabled={c.disabled || isConverting}
|
||||
@@ -213,7 +236,9 @@ export function LLMAttachmentMenu(props: {
|
||||
</ListItemDecorator>
|
||||
{c.unsupported
|
||||
? <Box>Unsupported 🤔 <Typography level='body-xs'>{c.name}</Typography></Box>
|
||||
: c.name}
|
||||
: (/* auto-converted */ draft.outputsHeuristic?.isAuto && c.id === draft.outputsHeuristic.actualConverterId)
|
||||
? <Box component='span' sx={{ fontWeight: 'lg', color: 'primary.softColor' }}>{c.name}</Box>
|
||||
: c.name}
|
||||
</MenuItem>,
|
||||
)}
|
||||
{/*{!isUnconvertible && <ListDivider sx={{ mb: 0 }} />}*/}
|
||||
@@ -261,11 +286,19 @@ export function LLMAttachmentMenu(props: {
|
||||
<Typography color={isInputError ? 'danger' : 'warning'} level='title-sm'>
|
||||
{isInputError ? 'Loading Issue' : 'Warning'}
|
||||
</Typography>
|
||||
|
||||
{/* Only show 1 warning, excluding lower priorities */}
|
||||
{isInputError ? <div>{draft.inputError}</div>
|
||||
: isUnconvertible ? <div>Attachments of type {draft.input?.mimeType} are not supported yet. You can request this on GitHub.</div>
|
||||
: isOutputMissing ? <div>File not supported. Please try another format.</div>
|
||||
: !llmSupportsAllFragments ? <div>May not be compatible with the current model. Please try another format.</div>
|
||||
: <>Unknown warning</>}
|
||||
: draft.outputWarnings?.length ? '' /* printed below */
|
||||
: <>Unknown warning</>}
|
||||
|
||||
{/* Explicit output warnings */}
|
||||
{!!draft.outputWarnings?.length && draft.outputWarnings.map((w, widx) =>
|
||||
<Box key={'ow-' + widx} sx={{ fontSize: 'sm', color: 'warning.softColor', py: 1 }}>⚠️ {w}</Box>)
|
||||
}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
@@ -294,24 +327,24 @@ export function LLMAttachmentMenu(props: {
|
||||
Details
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ my: 0.5 }}>
|
||||
<Box sx={{ my: 1 }}>
|
||||
|
||||
{/* <- inputs */}
|
||||
{showInputs && !!draftInput && (
|
||||
<Typography level='body-sm' textColor='text.primary' startDecorator={<AttachmentIcon sx={indicatorSx} />}>
|
||||
{draftInput.mimeType}{typeof draftInput.dataSize === 'number' ? ` · ${draftInput.dataSize.toLocaleString()} bytes` : ''}
|
||||
<Typography level='body-sm' textColor='success.softColor'>
|
||||
Input: {draftInput.mimeType}{typeof draftInput.dataSize === 'number' ? ` · ${humanReadableBytes(draftInput.dataSize)}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
{showInputs && !!draftInput?.altMimeType && (
|
||||
<Typography level='body-sm' sx={indicatorGapSx}>
|
||||
{draftInput.altMimeType} · {draftInput.altData?.length.toLocaleString()}
|
||||
<Typography level='body-sm' textColor='success.softColor'>
|
||||
Input: {draftInput.altMimeType}{!draftInput.altData?.length ? '' : ` · ${humanReadableBytes(draftInput.altData.length)}`}
|
||||
</Typography>
|
||||
)}
|
||||
{showInputs && !!draftInput?.urlImage && (
|
||||
<Typography level='body-sm' sx={indicatorGapSx}>
|
||||
{draftInput.urlImage.mimeType} · {draftInput.urlImage.width} x {draftInput.urlImage.height} · {draftInput.urlImage.imgDataUrl?.length.toLocaleString()}
|
||||
{' · '}
|
||||
<Chip component='span' size='sm' color='primary' variant='outlined' startDecorator={<VisibilityIcon />} onClick={(event) => {
|
||||
<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) => {
|
||||
if (draftInput?.urlImage?.imgDataUrl) {
|
||||
// Invoke the viewer but with a virtual 'temp' part description to see this preview image
|
||||
handleViewImageRefPart(event, {
|
||||
@@ -325,8 +358,8 @@ export function LLMAttachmentMenu(props: {
|
||||
height: draftInput.urlImage.height || undefined,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
view
|
||||
}} sx={{ ml: 'auto' }}>
|
||||
view input
|
||||
</Chip>
|
||||
</Typography>
|
||||
)}
|
||||
@@ -335,45 +368,79 @@ export function LLMAttachmentMenu(props: {
|
||||
{/* Converters: {draft.converters.map(((converter, idx) => ` ${converter.id}${converter.isActive ? '*' : ''}`)).join(', ')}*/}
|
||||
{/*</Typography>*/}
|
||||
|
||||
{/* Downward arrow */}
|
||||
<Divider color='success'>
|
||||
<KeyboardArrowDownIcon color='success' />
|
||||
</Divider>
|
||||
|
||||
{/* -> Outputs */}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Box>
|
||||
{isOutputMissing ? (
|
||||
<Typography level='body-sm' startDecorator={<ReadMoreIcon sx={indicatorSx} />}>...</Typography>
|
||||
<Typography level='body-sm' color={isConverting ? 'primary' : 'danger'}>{isConverting ? '...' : '... nothing ...'}</Typography>
|
||||
) : (
|
||||
draft.outputFragments.map(({ part }, index) => {
|
||||
if (isDocPart(part)) {
|
||||
return (
|
||||
<Typography key={index} level='body-sm' sx={{ color: 'text.primary' }} startDecorator={<ReadMoreIcon sx={indicatorSx} />}>
|
||||
<span>{part.data.mimeType /* part.type: big-agi type, not source mime */} · {part.data.text.length.toLocaleString()} bytes · </span>
|
||||
<Chip component='span' size='sm' color='primary' variant='outlined' startDecorator={<VisibilityIcon />} onClick={(event) => handleViewDocPart(event, part)}>
|
||||
view
|
||||
</Chip>
|
||||
<Chip component='span' size='sm' color='success' variant='outlined' startDecorator={<ContentCopyIcon />} onClick={(event) => handleCopyToClipboard(event, part.data.text)}>
|
||||
copy
|
||||
</Chip>
|
||||
<Typography key={index} component='div' level='body-sm' textColor='primary.softColor' sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span>{part.data.mimeType /* part.type: big-agi type, not source mime */} · {humanReadableBytes(part.data.text.length)} </span>
|
||||
{/*<Chip component='span' size='sm' color='primary' variant='outlined' startDecorator={<VisibilityIcon />} onClick={(event) => handleViewDocPart(event, part)} sx={{ ml: 'auto' }}>*/}
|
||||
{/* view*/}
|
||||
{/*</Chip>*/}
|
||||
{/*<Chip component='span' size='sm' color='primary' variant='outlined' startDecorator={<ContentCopyIcon />} onClick={(event) => handleCopyToClipboard(event, part.data.text)}>*/}
|
||||
{/* copy*/}
|
||||
{/*</Chip>*/}
|
||||
<ButtonGroup size='sm' color='primary' variant='outlined' sx={actionButtonsSx}>
|
||||
<Button startDecorator={<VisibilityIcon sx={{ fontSize: 'md' }} />} onClick={(event) => handleViewDocPart(event, part)}>
|
||||
view
|
||||
</Button>
|
||||
<Button onClick={(event) => handleCopyToClipboard(event, part.data.text)}/* endDecorator={<ContentCopyIcon />} */>
|
||||
copy
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Typography>
|
||||
);
|
||||
} else if (isZyncAssetImageReferencePartWithLegacyDBlob(part) || isImageRefPart(part)) {
|
||||
// Unified Image Reference handling (both Zync Asset References with legacy fallback and legacy image_ref)
|
||||
const legacyImageRefPart = isZyncAssetImageReferencePartWithLegacyDBlob(part) ? part._legacyImageRefPart! : part;
|
||||
const { dataRef, width, height } = legacyImageRefPart;
|
||||
const resolution = width && height ? `${width} x ${height}` : 'no resolution';
|
||||
const resolution = width && height ? `${width}x${height}` : 'no resolution';
|
||||
const mime = dataRef.reftype === 'dblob' ? dataRef.mimeType : 'unknown image';
|
||||
return (
|
||||
<Typography key={index} level='body-sm' sx={{ color: 'text.primary' }} startDecorator={<ReadMoreIcon sx={indicatorSx} />}>
|
||||
<span>{mime /*.replace('image/', 'img: ')*/} · {resolution} · {dataRef.reftype === 'dblob' ? (dataRef.bytesSize?.toLocaleString() || 'no size') : '(remote)'} · </span>
|
||||
<Chip component='span' size={isOutputMultiple ? 'sm' : 'md'} color='primary' variant='outlined' startDecorator={<VisibilityIcon />}
|
||||
onClick={(event) => handleViewImageRefPart(event, legacyImageRefPart)}>
|
||||
view
|
||||
</Chip>
|
||||
{isOutputMultiple && <Chip component='span' size={isOutputMultiple ? 'sm' : 'md'} color='danger' variant='outlined' startDecorator={<DeleteForeverIcon />} onClick={(event) => handleDeleteOutputFragment(event, index)}>
|
||||
del
|
||||
</Chip>}
|
||||
<Typography key={index} component='div' level='body-sm' textColor='primary.softColor' sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span>{mime /*.replace('image/', 'img: ')*/} · {resolution} · {
|
||||
dataRef.reftype !== 'dblob' ? '(remote)'
|
||||
: !dataRef.bytesSize ? 'no size'
|
||||
: humanReadableBytes(dataRef.bytesSize)} </span>
|
||||
{/*<Chip component='span' size={isOutputMultiple ? 'sm' : 'md'} color='primary' variant='outlined' startDecorator={<VisibilityIcon />}*/}
|
||||
{/* onClick={(event) => handleViewImageRefPart(event, legacyImageRefPart)}>*/}
|
||||
{/* view*/}
|
||||
{/*</Chip>*/}
|
||||
{/*{isOutputMultiple && <Chip component='span' size={isOutputMultiple ? 'sm' : 'md'} color='danger' variant='outlined' startDecorator={<DeleteForeverIcon />} onClick={(event) => handleDeleteOutputFragment(event, index)}>*/}
|
||||
{/* del*/}
|
||||
{/*</Chip>}*/}
|
||||
<ButtonGroup size='sm' color='primary' variant='outlined' sx={actionButtonsSx}>
|
||||
<Button
|
||||
startDecorator={<VisibilityIcon sx={{ fontSize: 'md' }} />}
|
||||
onClick={(event) => handleViewImageRefPart(event, legacyImageRefPart)}
|
||||
>
|
||||
view
|
||||
</Button>
|
||||
{isOutputMultiple && (
|
||||
<Button
|
||||
color='warning'
|
||||
endDecorator={<DeleteOutlineIcon sx={{ fontSize: 'md' }} />}
|
||||
onClick={(event) => handleDeleteOutputFragment(event, index)}
|
||||
// sx={{ width: 48 }}
|
||||
>
|
||||
del
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Typography key={index} level='body-sm' sx={{ color: 'text.primary' }} startDecorator={<ReadMoreIcon sx={indicatorSx} />}>
|
||||
<Typography key={index} level='body-sm' textColor='primary.softColor'>
|
||||
{(part as DMessageAttachmentFragment['part']).pt}: (other)
|
||||
</Typography>
|
||||
);
|
||||
@@ -381,8 +448,8 @@ export function LLMAttachmentMenu(props: {
|
||||
})
|
||||
)}
|
||||
{!!llmTokenCountApprox && (
|
||||
<Typography level='body-xs' mt={0.5} sx={indicatorGapSx}>
|
||||
~{llmTokenCountApprox.toLocaleString()} tokens
|
||||
<Typography level='body-xs' mt={0.5} textColor='primary.softColor'>
|
||||
~ {llmTokenCountApprox.toLocaleString()} tokens
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -8,7 +8,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import { findModelVendor } from '~/modules/llms/vendors/vendors.registry';
|
||||
|
||||
import type { DModelsServiceId } from '~/common/stores/llms/llms.service.types';
|
||||
import { DLLM, DLLMId, isLLMVisible } from '~/common/stores/llms/llms.types';
|
||||
import { DLLM, DLLMId, getLLMLabel, isLLMVisible } from '~/common/stores/llms/llms.types';
|
||||
import { DebouncedInputMemo } from '~/common/components/DebouncedInput';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
@@ -65,7 +65,7 @@ function LLMDropdown(props: {
|
||||
return true;
|
||||
|
||||
// filter-out models that don't contain the search string
|
||||
if (lcFilterString && !llm.label.toLowerCase().includes(lcFilterString))
|
||||
if (lcFilterString && !getLLMLabel(llm).toLowerCase().includes(lcFilterString))
|
||||
return false;
|
||||
|
||||
// filter-out hidden models from the dropdown
|
||||
@@ -89,7 +89,7 @@ function LLMDropdown(props: {
|
||||
|
||||
// add the model item
|
||||
llmItems[llm.id] = {
|
||||
title: llm.label,
|
||||
title: getLLMLabel(llm),
|
||||
...(llm.userStarred ? { symbol: '⭐' } : {}),
|
||||
// icon: llm.id.startsWith('some vendor') ? <VendorIcon /> : undefined,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useModuleBeamStore } from '~/modules/beam/store-module-beam';
|
||||
import type { DFolder } from '~/common/stores/folders/store-chat-folders';
|
||||
import { DMessage, DMessageUserFlag, MESSAGE_FLAG_STARRED, messageFragmentsReduceText, messageHasUserFlag, messageUserFlagToEmoji } from '~/common/stores/chat/chat.message';
|
||||
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { getLocalMidnightInUTCTimestamp, getTimeBucketEn } from '~/common/util/timeUtils';
|
||||
import { createTimeBucketClassifierEn } from '~/common/util/timeUtils';
|
||||
import { isAttachmentFragment, isContentOrAttachmentFragment, isDocPart, isImageRefPart, isZyncAssetImageReferencePart } from '~/common/stores/chat/chat.fragments';
|
||||
import { shallowEquals } from '~/common/util/hooks/useShallowObject';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
@@ -235,14 +235,14 @@ export function useChatDrawerRenderItems(
|
||||
break;
|
||||
}
|
||||
|
||||
const midnightTime = getLocalMidnightInUTCTimestamp();
|
||||
const getTimeBucket = createTimeBucketClassifierEn();
|
||||
const grouped = chatNavItems.reduce((acc, item) => {
|
||||
|
||||
// derive the bucket name
|
||||
let bucket: string;
|
||||
switch (grouping) {
|
||||
case 'date':
|
||||
bucket = getTimeBucketEn(item.updatedAt || midnightTime, midnightTime);
|
||||
bucket = getTimeBucket(item.updatedAt || Date.now());
|
||||
break;
|
||||
case 'persona':
|
||||
bucket = item.systemPurposeId;
|
||||
|
||||
@@ -6,7 +6,6 @@ import AddIcon from '@mui/icons-material/Add';
|
||||
import ArchiveOutlinedIcon from '@mui/icons-material/ArchiveOutlined';
|
||||
import CleaningServicesOutlinedIcon from '@mui/icons-material/CleaningServicesOutlined';
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
import EngineeringIcon from '@mui/icons-material/Engineering';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
@@ -20,9 +19,7 @@ import { CodiconSplitVertical } from '~/common/components/icons/CodiconSplitVert
|
||||
import { CodiconSplitVerticalRemove } from '~/common/components/icons/CodiconSplitVerticalRemove';
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { OptimaPanelGroupedList, OptimaPanelGroupGutter } from '~/common/layout/optima/panel/OptimaPanelGroupedList';
|
||||
import { optimaActions } from '~/common/layout/optima/useOptima';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats'; // may be replaced with a dedicated hook for the chat pane
|
||||
import { useLabsDevMode } from '~/common/stores/store-ux-labs';
|
||||
|
||||
import { useChatShowSystemMessages } from '../../store-app-chat';
|
||||
import { panesManagerActions, usePaneDuplicateOrClose } from '../panes/store-panes-manager';
|
||||
@@ -55,7 +52,6 @@ export function ChatPane(props: {
|
||||
// external state
|
||||
const { canAddPane, isMultiPane } = usePaneDuplicateOrClose();
|
||||
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
|
||||
const labsDevMode = useLabsDevMode();
|
||||
|
||||
const { isArchived, setArchived } = useChatStore(useShallow((state) => {
|
||||
const conversation = state.conversations.find(_c => _c.id === props.conversationId);
|
||||
@@ -213,15 +209,5 @@ export function ChatPane(props: {
|
||||
</ListItemButton>
|
||||
</OptimaPanelGroupedList>
|
||||
|
||||
{/* [DEV] Development */}
|
||||
{labsDevMode && (
|
||||
<OptimaPanelGroupedList title='[Developers]'>
|
||||
<MenuItem onClick={optimaActions().openAIXDebugger}>
|
||||
<ListItemDecorator><EngineeringIcon /></ListItemDecorator>
|
||||
AIX: Show Last Request...
|
||||
</MenuItem>
|
||||
</OptimaPanelGroupedList>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import { Release } from '~/common/app.release';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
import { adjustContentScaling, themeScalingMap, themeZIndexChatBubble } from '~/common/app.theme';
|
||||
import { avatarIconSx, makeMessageAvatarIcon, messageBackground, useMessageAvatarLabel } from '~/common/util/dMessageUtils';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { clipboardCopyDOMSelectionOrFallback, copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
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';
|
||||
@@ -69,7 +69,7 @@ const ENABLE_BUBBLE = true;
|
||||
export const BUBBLE_MIN_TEXT_LENGTH = 3;
|
||||
|
||||
// Enable the hover button to copy the whole message. The Copy button is also available in Blocks, or in the Avatar Menu.
|
||||
const ENABLE_COPY_MESSAGE_OVERLAY: boolean = false;
|
||||
// const ENABLE_COPY_MESSAGE_OVERLAY: boolean = false;
|
||||
|
||||
|
||||
const messageBodySx: SxProps = {
|
||||
@@ -314,11 +314,17 @@ export function ChatMessage(props: {
|
||||
|
||||
const handleCloseOpsMenu = React.useCallback(() => setOpsMenuAnchor(null), []);
|
||||
|
||||
const handleOpsCopy = (e: React.MouseEvent) => {
|
||||
copyToClipboard(textSubject, 'Text');
|
||||
const handleOpsMessageCopySrc = React.useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// copy full source text (ops menu) - bypasses DOM, always gets pre-collapsed content
|
||||
copyToClipboard(fragmentFlattenedText, 'Message');
|
||||
handleCloseOpsMenu();
|
||||
closeContextMenu();
|
||||
}, [fragmentFlattenedText, handleCloseOpsMenu]);
|
||||
|
||||
const handleBubbleCopyDOM = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// copy cleaned DOM selection (bubble) - rich text for pasting into Google Docs, etc.
|
||||
clipboardCopyDOMSelectionOrFallback(blocksRendererRef.current, textSubject, 'Selection');
|
||||
closeBubble();
|
||||
};
|
||||
|
||||
@@ -893,18 +899,18 @@ export function ChatMessage(props: {
|
||||
|
||||
|
||||
{/* Overlay copy icon */}
|
||||
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditingText && (
|
||||
<Tooltip title={messagePendingIncomplete ? null : (fromAssistant ? 'Copy message' : 'Copy input')} variant='solid'>
|
||||
<IconButton
|
||||
variant='outlined' onClick={handleOpsCopy}
|
||||
sx={{
|
||||
position: 'absolute', ...(fromAssistant ? { right: { xs: 12, md: 28 } } : { left: { xs: 12, md: 28 } }), zIndex: 10,
|
||||
opacity: 0, transition: 'opacity 0.16s cubic-bezier(.17,.84,.44,1)',
|
||||
}}>
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/*{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditingText && (*/}
|
||||
{/* <Tooltip title={messagePendingIncomplete ? null : (fromAssistant ? 'Copy message' : 'Copy input')} variant='solid'>*/}
|
||||
{/* <IconButton*/}
|
||||
{/* variant='outlined' onClick={handleOpsMessageCopySrc}*/}
|
||||
{/* sx={{*/}
|
||||
{/* position: 'absolute', ...(fromAssistant ? { right: { xs: 12, md: 28 } } : { left: { xs: 12, md: 28 } }), zIndex: 10,*/}
|
||||
{/* opacity: 0, transition: 'opacity 0.16s cubic-bezier(.17,.84,.44,1)',*/}
|
||||
{/* }}>*/}
|
||||
{/* <ContentCopyIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/* </Tooltip>*/}
|
||||
{/*)}*/}
|
||||
|
||||
|
||||
{/* Message Operations Menu (3 dots) */}
|
||||
@@ -934,7 +940,7 @@ export function ChatMessage(props: {
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* Copy */}
|
||||
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
|
||||
<MenuItem onClick={handleOpsMessageCopySrc} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy
|
||||
</MenuItem>
|
||||
@@ -1162,7 +1168,7 @@ export function ChatMessage(props: {
|
||||
|
||||
{/* Bubble Copy */}
|
||||
<Tooltip disableInteractive arrow placement='top' title='Copy Selection'>
|
||||
<IconButton onClick={handleOpsCopy}>
|
||||
<IconButton onClick={handleBubbleCopyDOM}>
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -1181,7 +1187,7 @@ export function ChatMessage(props: {
|
||||
minWidth={220}
|
||||
placement='bottom-start'
|
||||
>
|
||||
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1, alignItems: 'center' }}>
|
||||
<MenuItem onClick={(e) => { handleOpsMessageCopySrc(e); closeContextMenu(); }} sx={{ flex: 1, alignItems: 'center' }}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy
|
||||
</MenuItem>
|
||||
|
||||
+19
-5
@@ -24,6 +24,15 @@ export const DocSelColor: ColorPaletteProp = 'primary';
|
||||
const DocUnselColor: ColorPaletteProp = 'primary';
|
||||
|
||||
|
||||
const _styles = {
|
||||
label: {
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 'md',
|
||||
minWidth: 48,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
export function buttonIconForFragment(part: DMessageAttachmentFragment['part']): React.ComponentType<any> {
|
||||
const pt = part.pt;
|
||||
switch (pt) {
|
||||
@@ -146,10 +155,14 @@ export function DocAttachmentFragmentButton(props: {
|
||||
if (!isDocPart(fragment.part))
|
||||
return 'Unexpected: ' + fragment.part.pt;
|
||||
|
||||
const buttonText = ellipsizeMiddle(fragment.part.l1Title || fragment.title || 'Document', 28 /* totally arbitrary length */);
|
||||
|
||||
const Icon = isSelected ? EditRoundedIcon : buttonIconForFragment(fragment.part);
|
||||
|
||||
const fullTitle = fragment.part.l1Title || fragment.title || 'Document';
|
||||
const buttonText = ellipsizeMiddle(fullTitle, 28 /* totally arbitrary length */);
|
||||
const showFilenameTooltip = fullTitle !== buttonText;
|
||||
|
||||
const labelContent = <Box sx={_styles.label}>{buttonText}</Box>;
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={props.contentScaling === 'md' ? 'md' : 'sm'}
|
||||
@@ -171,9 +184,10 @@ export function DocAttachmentFragmentButton(props: {
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', paddingX: '0.5rem' }}>
|
||||
<Box sx={{ whiteSpace: 'nowrap', fontWeight: 'md', minWidth: 48 }}>
|
||||
{buttonText}
|
||||
</Box>
|
||||
{showFilenameTooltip
|
||||
? <TooltipOutlined title={<span style={{ wordBreak: 'break-all' }}>{fullTitle}</span>}>{labelContent}</TooltipOutlined>
|
||||
: labelContent
|
||||
}
|
||||
{/*<Box sx={{ fontSize: 'xs', fontWeight: 'sm' }}>*/}
|
||||
{/* {fragment.caption}*/}
|
||||
{/*</Box>*/}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { BlocksTextarea } from '~/modules/blocks/BlocksContainers';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
@@ -96,6 +98,8 @@ export function BlockEdit_TextFragment(props: {
|
||||
|
||||
const handleEditKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.nativeEvent.isComposing)
|
||||
return;
|
||||
const withControl = e.ctrlKey;
|
||||
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -120,6 +124,32 @@ export function BlockEdit_TextFragment(props: {
|
||||
{ key: ShortcutKey.Esc, description: 'Cancel', level: 3, action: onEscapePressed },
|
||||
], [isControlled, isEdited, isFocused, onEscapePressed, onSubmit, props.enableRestart]));
|
||||
|
||||
|
||||
// memo style
|
||||
const sx = React.useMemo((): SxProps | undefined => {
|
||||
// check sources of custom, and early outs
|
||||
const isXS = props.contentScaling === 'xs';
|
||||
const isSquareTop = !!props.squareTopBorder;
|
||||
if (!isXS && !isSquareTop) return undefined;
|
||||
if (isSquareTop && !isXS) return _styles.squareTop;
|
||||
|
||||
return {
|
||||
// scaling note: in Chat, this can go xs/sm/md, while in Beam, this is xs/xs/sm
|
||||
...(isXS && {
|
||||
fontSize: 'xs',
|
||||
lineHeight: 'md', // was 1.75 on all
|
||||
// '--Textarea-paddingBlock': 'calc(0.25rem - 0.5px - var(--variant-borderWidth, 0px))', // not used, overridden in BlocksTextarea
|
||||
'--Textarea-paddingInline': '6px',
|
||||
'--Textarea-minHeight': '1.75rem', // was 2rem on 'sm'
|
||||
'--Icon-fontSize': 'lg', // was 'xl' on 'sm'
|
||||
'--Textarea-focusedThickness': '1px',
|
||||
boxShadow: 'none', // too small to show this
|
||||
}),
|
||||
...(isSquareTop && _styles.squareTop),
|
||||
};
|
||||
}, [props.contentScaling, props.squareTopBorder]);
|
||||
|
||||
|
||||
return (
|
||||
<BlocksTextarea
|
||||
variant={/*props.invertedColors ? 'plain' :*/ 'soft'}
|
||||
@@ -140,7 +170,7 @@ export function BlockEdit_TextFragment(props: {
|
||||
onKeyDown={handleEditKeyDown}
|
||||
slotProps={enterIsNewline ? _textAreaSlotPropsEnter : _textAreaSlotPropsDone}
|
||||
// endDecorator={props.endDecorator}
|
||||
sx={!props.squareTopBorder ? undefined : _styles.squareTop}
|
||||
sx={sx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,17 @@ export function ContentFragments(props: {
|
||||
// Content Fragments Edit Zero-State: button to create a new TextContentFragment
|
||||
if (isEditingText && !props.contentFragments.some(isTextContentFragment))
|
||||
return !props.onFragmentAddBlank ? null : (
|
||||
<Button aria-label='message body empty' variant='plain' color='neutral' onClick={props.onFragmentAddBlank} sx={{ justifyContent: 'flex-start' }}>
|
||||
<Button
|
||||
aria-label='message body empty'
|
||||
color={fromAssistant ? 'neutral' : 'primary'}
|
||||
variant='outlined'
|
||||
onClick={props.onFragmentAddBlank}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
backgroundColor: fromAssistant ? 'neutral.softBg' : 'primary.softBg',
|
||||
'&:hover': { backgroundColor: fromAssistant ? 'neutral.softHoverBg' : 'primary.softHoverBg' },
|
||||
}}
|
||||
>
|
||||
add text ...
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -23,10 +23,20 @@ const propGridSx: SxProps = {
|
||||
alignItems: 'center',
|
||||
columnGap: 2,
|
||||
rowGap: 1,
|
||||
// labels
|
||||
'& > :nth-of-type(odd)': {
|
||||
color: 'text.secondary',
|
||||
fontSize: 'xs',
|
||||
},
|
||||
// values
|
||||
'& > :nth-of-type(even)': {
|
||||
// fontWeight: 'bold',
|
||||
color: 'text.primary',
|
||||
// agi-ellipsize
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
};
|
||||
|
||||
const textPageSx: SxProps = {
|
||||
|
||||
@@ -171,6 +171,7 @@ export function BlockPartModelAnnotations(props: {
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-agi-no-copy // do not copy these buttons: has its own copy functionality
|
||||
sx={{ mx: 1.5 }}
|
||||
>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useOverlayComponents } from '~/common/layout/overlays/useOverlayCompone
|
||||
|
||||
|
||||
// configuration
|
||||
const ENABLE_MARKDOWN_DETECTION = false;
|
||||
const ENABLE_MARKDOWN_DETECTION = true;
|
||||
// const REASONING_COLOR = '#ca74b8'; // '#f22a85' (folder-aligned), '#ca74b8' (emoji-aligned)
|
||||
const REASONING_COLOR: ColorPaletteProp = 'success';
|
||||
const ANTHROPIC_REDACTED_EXPLAINER = // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#example-streaming-with-redacted-thinking
|
||||
@@ -29,7 +29,7 @@ const _styles = {
|
||||
|
||||
block: {
|
||||
mx: 1.5,
|
||||
} as const,
|
||||
},
|
||||
|
||||
chip: {
|
||||
px: 1.5,
|
||||
@@ -38,24 +38,24 @@ const _styles = {
|
||||
outline: '1px solid',
|
||||
outlineColor: `${REASONING_COLOR}.solidBg`, // .outlinedBorder
|
||||
boxShadow: `1px 2px 4px -3px var(--joy-palette-${REASONING_COLOR}-solidBg)`,
|
||||
} as const,
|
||||
},
|
||||
|
||||
chipDisabled: {
|
||||
px: 1.5,
|
||||
py: 0.375,
|
||||
my: '1px', // to not crop the outline on mobile, or on beam
|
||||
} as const,
|
||||
},
|
||||
|
||||
chipIcon: {
|
||||
fontSize: '1rem',
|
||||
mr: 0.5,
|
||||
} as const,
|
||||
},
|
||||
|
||||
chipIconPending: {
|
||||
fontSize: '1rem',
|
||||
mr: 0.5,
|
||||
animation: `${animationSpinHalfPause} 2s ease-in-out infinite`,
|
||||
} as const,
|
||||
},
|
||||
|
||||
chipExpanded: {
|
||||
mt: '1px', // need to copy the `chip` mt
|
||||
@@ -63,14 +63,14 @@ const _styles = {
|
||||
py: 0.375,
|
||||
// borderRadius: 'sm',
|
||||
// transition: 'border-radius 0.2s ease-in-out',
|
||||
} as const,
|
||||
},
|
||||
|
||||
text: {
|
||||
borderRadius: '12px',
|
||||
borderRadius: 'sm', // was: 12px
|
||||
border: '1px solid',
|
||||
borderColor: `${REASONING_COLOR}.outlinedColor`,
|
||||
backgroundColor: `rgb(var(--joy-palette-${REASONING_COLOR}-lightChannel) / 15%)`, // similar to success.50
|
||||
boxShadow: 'inset 1px 1px 3px -3px var(--joy-palette-neutral-solidBg)',
|
||||
// boxShadow: 'inset 1px 1px 3px -3px var(--joy-palette-neutral-solidBg)',
|
||||
mt: 1,
|
||||
p: 1,
|
||||
|
||||
@@ -81,13 +81,19 @@ const _styles = {
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
} as const,
|
||||
},
|
||||
|
||||
textUndoWhitespace: {
|
||||
// for markdown content, we want to allow it to control the whitespace and line breaks, so we undo the plain text styles that break on whitespace
|
||||
overflowWrap: 'normal',
|
||||
whiteSpace: 'normal',
|
||||
},
|
||||
|
||||
buttonInline: {
|
||||
outline: 'none',
|
||||
// borderRadius: 'sm',
|
||||
// fontSize: 'xs',
|
||||
} as const,
|
||||
},
|
||||
|
||||
} as const;
|
||||
|
||||
@@ -97,6 +103,8 @@ function _maybeMarkdownReasoning(trimmed: string): boolean {
|
||||
// const trimmed = text.trimStart();
|
||||
return trimmed.startsWith('**')
|
||||
|| trimmed.startsWith('# ')
|
||||
// || trimmed.startsWith('* ')
|
||||
// || trimmed.startsWith('- ')
|
||||
|| /^#{2,6}\s/.test(trimmed);
|
||||
}
|
||||
|
||||
@@ -124,8 +132,12 @@ export function BlockPartModelAux(props: {
|
||||
|
||||
// memo
|
||||
const scaledTypographySx = useScaledTypographySx(adjustContentScaling(props.contentScaling, -1), false, false);
|
||||
const textSx = React.useMemo(() => ({ ..._styles.text, ...scaledTypographySx }), [scaledTypographySx]);
|
||||
const maybeMarkdown = React.useMemo(() => !ENABLE_MARKDOWN_DETECTION || neverExpanded ? false : _maybeMarkdownReasoning(props.auxText), [neverExpanded, props.auxText]);
|
||||
const textSx = React.useMemo(() => ({
|
||||
..._styles.text,
|
||||
...scaledTypographySx,
|
||||
...(maybeMarkdown ? _styles.textUndoWhitespace : {}),
|
||||
}), [maybeMarkdown, scaledTypographySx]);
|
||||
|
||||
let typeText = props.auxType === 'reasoning' ? 'Reasoning' : 'Auxiliary';
|
||||
|
||||
@@ -182,7 +194,7 @@ export function BlockPartModelAux(props: {
|
||||
return <Box sx={_styles.block}>
|
||||
|
||||
{/* Chip to expand/collapse */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<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'}
|
||||
@@ -200,7 +212,7 @@ export function BlockPartModelAux(props: {
|
||||
Show {typeText}
|
||||
</Chip>
|
||||
|
||||
{expanded && (showInline || showDelete) && !!props.auxText && (
|
||||
{expanded && !props.messagePendingIncomplete && (showInline || showDelete) && !!props.auxText && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
|
||||
{/* Make inline */}
|
||||
@@ -208,10 +220,10 @@ export function BlockPartModelAux(props: {
|
||||
color={REASONING_COLOR}
|
||||
variant='soft'
|
||||
size='sm'
|
||||
disabled={!onFragmentReplace || props.messagePendingIncomplete}
|
||||
disabled={!onFragmentReplace /* || props.messagePendingIncomplete */}
|
||||
onClick={!onFragmentReplace ? undefined : handleInline}
|
||||
endDecorator={<TextFieldsIcon />}
|
||||
sx={(!onFragmentReplace || props.messagePendingIncomplete) ? _styles.chipDisabled : _styles.chip}
|
||||
sx={(!onFragmentReplace /* || props.messagePendingIncomplete */) ? _styles.chipDisabled : _styles.chip}
|
||||
>
|
||||
Make Regular Text
|
||||
</Chip>}
|
||||
@@ -221,10 +233,10 @@ export function BlockPartModelAux(props: {
|
||||
color={REASONING_COLOR}
|
||||
variant='soft'
|
||||
size='sm'
|
||||
disabled={!onFragmentDelete || props.messagePendingIncomplete}
|
||||
disabled={!onFragmentDelete /* || props.messagePendingIncomplete */}
|
||||
onClick={!onFragmentDelete ? undefined : handleDelete}
|
||||
endDecorator={<DeleteOutlineIcon />}
|
||||
sx={(!onFragmentDelete || props.messagePendingIncomplete) ? _styles.chipDisabled : _styles.chip}
|
||||
sx={(!onFragmentDelete /* || props.messagePendingIncomplete */) ? _styles.chipDisabled : _styles.chip}
|
||||
>
|
||||
Delete
|
||||
</Chip>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AixChatGenerateContent_DMessageGuts, aixChatGenerateContent_DMessage_FromConversation } from '~/modules/aix/client/aix.client';
|
||||
import { aixChatGenerateContent_DMessage_FromConversation, AixChatGenerateContent_DMessageGuts } from '~/modules/aix/client/aix.client';
|
||||
import { autoChatFollowUps } from '~/modules/aifn/auto-chat-follow-ups/autoChatFollowUps';
|
||||
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function runPersonaOnConversationHead(
|
||||
const parallelViewCount = getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount();
|
||||
|
||||
// ai follow-up operations (fire/forget)
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions, autoTitleChat, chatKeepLastThinkingOnly } = getChatAutoAI();
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions, autoTitleChat, chatThinkingPolicy } = getChatAutoAI();
|
||||
|
||||
// AutoSpeak
|
||||
const autoSpeaker: PersonaProcessorInterface | null = autoSpeak !== 'off' ? new PersonaChatMessageSpeak(autoSpeak) : null;
|
||||
@@ -129,8 +129,10 @@ export async function runPersonaOnConversationHead(
|
||||
if (!hasBeenAborted && (autoSuggestDiagrams || autoSuggestHTMLUI || autoSuggestQuestions))
|
||||
void autoChatFollowUps(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions);
|
||||
|
||||
if (chatKeepLastThinkingOnly)
|
||||
cHandler.historyKeepLastThinkingOnly();
|
||||
if (chatThinkingPolicy === 'last-only')
|
||||
cHandler.historyStripThinking(1);
|
||||
else if (chatThinkingPolicy === 'discard-all')
|
||||
cHandler.historyStripThinking(0);
|
||||
|
||||
// return true if this succeeded
|
||||
return messageStatus.outcome === 'success';
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Is } from '~/common/util/pwaUtils';
|
||||
|
||||
export type ChatAutoSpeakType = 'off' | 'firstLine' | 'all';
|
||||
|
||||
export type ChatThinkingPolicy = 'last-only' | 'all' | 'discard-all';
|
||||
|
||||
export type TokenCountingMethod = 'accurate' | 'approximate';
|
||||
|
||||
|
||||
@@ -38,8 +40,8 @@ interface AppChatStore {
|
||||
autoVndAntBreakpoints: boolean;
|
||||
setAutoVndAntBreakpoints: (autoVndAntBreakpoints: boolean) => void;
|
||||
|
||||
chatKeepLastThinkingOnly: boolean,
|
||||
setChatKeepLastThinkingOnly: (chatKeepLastThinkingOnly: boolean) => void;
|
||||
chatThinkingPolicy: ChatThinkingPolicy,
|
||||
setChatThinkingPolicy: (chatThinkingPolicy: ChatThinkingPolicy) => void;
|
||||
|
||||
tokenCountingMethod: TokenCountingMethod;
|
||||
setTokenCountingMethod: (tokenCountingMethod: TokenCountingMethod) => void;
|
||||
@@ -110,8 +112,8 @@ const useAppChatStore = create<AppChatStore>()(persist(
|
||||
autoVndAntBreakpoints: true, // 2024-08-24: on as it saves user's money
|
||||
setAutoVndAntBreakpoints: (autoVndAntBreakpoints: boolean) => _set({ autoVndAntBreakpoints }),
|
||||
|
||||
chatKeepLastThinkingOnly: true,
|
||||
setChatKeepLastThinkingOnly: (chatKeepLastThinkingOnly: boolean) => _set({ chatKeepLastThinkingOnly }),
|
||||
chatThinkingPolicy: 'last-only',
|
||||
setChatThinkingPolicy: (chatThinkingPolicy: ChatThinkingPolicy) => _set({ chatThinkingPolicy }),
|
||||
|
||||
tokenCountingMethod: Is.Desktop ? 'accurate' : 'approximate',
|
||||
setTokenCountingMethod: (tokenCountingMethod: TokenCountingMethod) => _set({ tokenCountingMethod }),
|
||||
@@ -189,7 +191,7 @@ export const useChatAutoAI = () => useAppChatStore(useShallow(state => ({
|
||||
autoSuggestQuestions: state.autoSuggestQuestions,
|
||||
autoTitleChat: state.autoTitleChat,
|
||||
autoVndAntBreakpoints: state.autoVndAntBreakpoints,
|
||||
chatKeepLastThinkingOnly: state.chatKeepLastThinkingOnly,
|
||||
chatThinkingPolicy: state.chatThinkingPolicy,
|
||||
tokenCountingMethod: state.tokenCountingMethod,
|
||||
setAutoSpeak: state.setAutoSpeak,
|
||||
setAutoSuggestAttachmentPrompts: state.setAutoSuggestAttachmentPrompts,
|
||||
@@ -198,7 +200,7 @@ export const useChatAutoAI = () => useAppChatStore(useShallow(state => ({
|
||||
setAutoSuggestQuestions: state.setAutoSuggestQuestions,
|
||||
setAutoTitleChat: state.setAutoTitleChat,
|
||||
setAutoVndAntBreakpoints: state.setAutoVndAntBreakpoints,
|
||||
setChatKeepLastThinkingOnly: state.setChatKeepLastThinkingOnly,
|
||||
setChatThinkingPolicy: state.setChatThinkingPolicy,
|
||||
setTokenCountingMethod: state.setTokenCountingMethod,
|
||||
})));
|
||||
|
||||
@@ -210,7 +212,7 @@ export const getChatAutoAI = (): {
|
||||
autoSuggestQuestions: boolean,
|
||||
autoTitleChat: boolean,
|
||||
autoVndAntBreakpoints: boolean,
|
||||
chatKeepLastThinkingOnly: boolean,
|
||||
chatThinkingPolicy: ChatThinkingPolicy,
|
||||
} => useAppChatStore.getState();
|
||||
|
||||
export const useChatAutoSuggestHTMLUI = (): boolean =>
|
||||
|
||||
@@ -101,6 +101,10 @@ export function PromptComposer(props: {
|
||||
if (e.key !== 'Enter')
|
||||
return;
|
||||
|
||||
// Skip if composing (e.g., CJK input methods) - issue #784
|
||||
if (e.nativeEvent.isComposing)
|
||||
return;
|
||||
|
||||
// Shift: toggles the 'enter is newline'
|
||||
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
|
||||
if (userHasText)
|
||||
|
||||
@@ -71,6 +71,20 @@ export const DevNewsItem: NewsItem = {
|
||||
|
||||
// news and feature surfaces
|
||||
export const NewsItems: NewsItem[] = [
|
||||
{
|
||||
versionCode: '2.0.3',
|
||||
versionName: 'Red Carpet',
|
||||
versionDate: new Date('2026-02-03T12:00:00Z'),
|
||||
items: [
|
||||
{ text: <><B>Kimi K2.5</B>, <B>Gemini 3 Flash</B>, <B>GPT Image 1.5</B>, <B>GPT 5.2 Codex</B>, <B issue={921}>Novita.ai</B> models, and xAI search and code execution</> },
|
||||
{ text: <><B issue={943}>Google Drive</B>: attach docs, sheets, images with optimal LLM conversion</> },
|
||||
{ text: <>Speech: new <B href='https://inworld.ai'>Inworld</B> support, cancelable, unlimited length</> },
|
||||
{ text: <>Copy as-seen, reorder messages, AI Injector, PDF auto-OCR</> },
|
||||
{ text: <>Models: <B issue={941}>duplication</B>, improved parameters, cleaner UI</> },
|
||||
{ text: <>Fixes, security patches, CJK/IME input</> },
|
||||
{ text: <>Developers: new Docker build, faster, and smaller containers, AI request injection capabilities in the inspector</>, dev: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionCode: '2.0.2',
|
||||
versionName: 'Heavy Critters',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { ContentScaling } from '~/common/app.theme';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { agiUuid } from '~/common/util/idUtils';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { getLLMLabel } from '~/common/stores/llms/llms.types';
|
||||
import { useFormEditTextArray } from '~/common/components/forms/useFormEditTextArray';
|
||||
import { useLLMSelect, useLLMSelectLocalState } from '~/common/components/forms/useLLMSelect';
|
||||
import { useToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
@@ -255,7 +256,7 @@ export function Creator(props: { display: boolean }) {
|
||||
Embodying Persona ...
|
||||
</Typography>
|
||||
<Typography level='title-sm' sx={{ mt: 1 }}>
|
||||
Using: {personaLlm?.label}
|
||||
Using: {personaLlm ? getLLMLabel(personaLlm) : 'Loading model...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
|
||||
@@ -14,6 +14,37 @@ import { InlineError } from '~/common/components/InlineError';
|
||||
import type { SimplePersonaProvenance } from '../store-app-personas';
|
||||
|
||||
|
||||
// configuration
|
||||
const TEMP_DISABLE_YOUTUBE_TRANSCRIPT = true;
|
||||
|
||||
|
||||
function YouTubeDisabledCard() {
|
||||
return (
|
||||
<Card
|
||||
variant='soft'
|
||||
color='primary'
|
||||
invertedColors
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.solidBg',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||
Temporarily Disabled
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 2 }}>
|
||||
YouTube transcript extraction is currently unavailable due to API changes.
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Download transcripts manually and use the "From Text" option instead.
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function YouTubeVideoTranscriptCard(props: { transcript: YTVideoTranscript, onClose: () => void, sx?: SxProps }) {
|
||||
const { transcript } = props;
|
||||
return (
|
||||
@@ -109,6 +140,13 @@ export function FromYouTube(props: {
|
||||
setVideoID(videoId);
|
||||
};
|
||||
|
||||
if (TEMP_DISABLE_YOUTUBE_TRANSCRIPT)
|
||||
return <>
|
||||
<Typography level='title-md' startDecorator={<YouTubeIcon sx={{ color: '#f00' }} />} sx={{ mb: 3 }}>
|
||||
YouTube -> Persona
|
||||
</Typography>
|
||||
<YouTubeDisabledCard />
|
||||
</>;
|
||||
|
||||
return <>
|
||||
|
||||
|
||||
@@ -3,21 +3,19 @@ import * as React from 'react';
|
||||
import { FormControl, ListDivider, Switch } from '@mui/joy';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import EngineeringIcon from '@mui/icons-material/Engineering';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
|
||||
import type { DModelDomainId } from '~/common/stores/llms/model.domains.types';
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { FormSelectControl, FormSelectOption } from '~/common/components/forms/FormSelectControl';
|
||||
import { useLLMSelect } from '~/common/components/forms/useLLMSelect';
|
||||
import { useLabsDevMode } from '~/common/stores/store-ux-labs';
|
||||
import { useModelDomain } from '~/common/stores/llms/hooks/useModelDomain';
|
||||
|
||||
import type { TokenCountingMethod } from '../chat/store-app-chat';
|
||||
import type { ChatThinkingPolicy, TokenCountingMethod } from '../chat/store-app-chat';
|
||||
import { useChatAutoAI } from '../chat/store-app-chat';
|
||||
|
||||
|
||||
const _keepThinkingBlocksOptions: FormSelectOption<'all' | 'last-only'>[] = [
|
||||
const _keepThinkingBlocksOptions: FormSelectOption<ChatThinkingPolicy>[] = [
|
||||
{
|
||||
value: 'last-only',
|
||||
label: 'Most Recent',
|
||||
@@ -28,6 +26,11 @@ const _keepThinkingBlocksOptions: FormSelectOption<'all' | 'last-only'>[] = [
|
||||
label: 'Preserve All',
|
||||
description: 'Keep all traces',
|
||||
},
|
||||
{
|
||||
value: 'discard-all',
|
||||
label: 'Discard All',
|
||||
description: 'May reduce quality',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const _tokenCountingMethodOptions: FormSelectOption<TokenCountingMethod>[] = [
|
||||
@@ -76,12 +79,10 @@ export function AppChatSettingsAI() {
|
||||
autoSuggestHTMLUI, setAutoSuggestHTMLUI,
|
||||
// autoSuggestQuestions, setAutoSuggestQuestions,
|
||||
autoTitleChat, setAutoTitleChat,
|
||||
chatKeepLastThinkingOnly, setChatKeepLastThinkingOnly,
|
||||
chatThinkingPolicy, setChatThinkingPolicy,
|
||||
tokenCountingMethod, setTokenCountingMethod,
|
||||
} = useChatAutoAI();
|
||||
|
||||
const labsDevMode = useLabsDevMode();
|
||||
|
||||
const showModelIcons = false; // useUIComplexityMode() === 'extra';
|
||||
|
||||
// callbacks
|
||||
@@ -136,15 +137,6 @@ export function AppChatSettingsAI() {
|
||||
tooltip='Vision model used to generate text descriptions of images when the Caption (Text) attachment option is selected.'
|
||||
/>
|
||||
|
||||
{labsDevMode && (
|
||||
<FormControlDomainModel
|
||||
domainId='primaryChat'
|
||||
title={<><EngineeringIcon color='warning' sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Last used model</>}
|
||||
description='Chat fallback model'
|
||||
tooltip='The last used chat model, used as default for new conversations. This is a development setting used to test out auto-detection of the most fitting initial chat model.'
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormSelectControl
|
||||
title='Token Counting'
|
||||
tooltip='Controls how tokens are counted for context limits and pricing estimates.'
|
||||
@@ -155,10 +147,10 @@ export function AppChatSettingsAI() {
|
||||
|
||||
<FormSelectControl
|
||||
title='Reasoning traces'
|
||||
tooltip='Controls how AI thinking/reasoning blocks are kept in your chat history. Keeping only in the last message (default) reduces clutter.'
|
||||
tooltip='Controls how AI thinking/reasoning blocks are kept in your chat history. "Most Recent" keeps only the last message traces (default). "Discard All" removes all traces after each response, which may reduce multi-turn quality with some providers.'
|
||||
options={_keepThinkingBlocksOptions}
|
||||
value={chatKeepLastThinkingOnly ? 'last-only' : 'all'}
|
||||
onChange={(value) => setChatKeepLastThinkingOnly(value === 'last-only')}
|
||||
value={chatThinkingPolicy}
|
||||
onChange={setChatThinkingPolicy}
|
||||
/>
|
||||
|
||||
<ListDivider inset='gutter'>Automatic AI Functions</ListDivider>
|
||||
|
||||
@@ -130,6 +130,7 @@ const _styles = {
|
||||
|
||||
// modal: undefined,
|
||||
modal: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: 'background.level1',
|
||||
} as const,
|
||||
|
||||
@@ -209,7 +210,7 @@ export function SettingsModal(props: {
|
||||
<GoodModal
|
||||
// title='Preferences' strongerTitle
|
||||
title={
|
||||
<AppBreadcrumbs size='md' rootTitle='App'>
|
||||
<AppBreadcrumbs size='md' rootTitle={isMobile ? 'App' : 'Application'}>
|
||||
<AppBreadcrumbs.Leaf><b>Preferences</b></AppBreadcrumbs.Leaf>
|
||||
</AppBreadcrumbs>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -4,25 +4,17 @@ import { FormControl, Switch, Typography } from '@mui/joy';
|
||||
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import EngineeringIcon from '@mui/icons-material/Engineering';
|
||||
import LocalAtmOutlinedIcon from '@mui/icons-material/LocalAtmOutlined';
|
||||
import ScreenshotMonitorIcon from '@mui/icons-material/ScreenshotMonitor';
|
||||
import ShortcutIcon from '@mui/icons-material/Shortcut';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import TitleIcon from '@mui/icons-material/Title';
|
||||
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
|
||||
import { Is } from '~/common/util/pwaUtils';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { useUXLabsStore } from '~/common/stores/store-ux-labs';
|
||||
|
||||
|
||||
// uncomment for more settings
|
||||
export const DEV_MODE_SETTINGS = false;
|
||||
|
||||
|
||||
export function UxLabsSettings() {
|
||||
|
||||
// external state
|
||||
@@ -30,36 +22,15 @@ export function UxLabsSettings() {
|
||||
const {
|
||||
labsAttachScreenCapture, setLabsAttachScreenCapture,
|
||||
labsCameraDesktop, setLabsCameraDesktop,
|
||||
labsChatBarAlt, setLabsChatBarAlt,
|
||||
labsEnhanceCodeBlocks, setLabsEnhanceCodeBlocks,
|
||||
labsHighPerformance, setLabsHighPerformance,
|
||||
labsShowCost, setLabsShowCost,
|
||||
labsAutoHideComposer, setLabsAutoHideComposer,
|
||||
labsShowShortcutBar, setLabsShowShortcutBar,
|
||||
labsDevMode, setLabsDevMode,
|
||||
labsDevNoStreaming, setLabsDevNoStreaming,
|
||||
} = useUXLabsStore();
|
||||
|
||||
return <>
|
||||
|
||||
{/* [DEV MODE] Settings */}
|
||||
|
||||
{(Is.Deployment.Localhost || labsDevMode) && (
|
||||
<FormSwitchControl
|
||||
title={<><EngineeringIcon color='warning' sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Developer Mode</>} description={labsDevMode ? 'Enabled' : 'Disabled'}
|
||||
checked={labsDevMode} onChange={setLabsDevMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{labsDevMode && (
|
||||
<FormSwitchControl
|
||||
title={<><EngineeringIcon color='warning' sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Disable Streaming</>} description={labsDevNoStreaming ? 'Enabled' : 'Disabled'}
|
||||
checked={labsDevNoStreaming} onChange={setLabsDevNoStreaming}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Non-Graduated Settings */}
|
||||
|
||||
<FormSwitchControl
|
||||
title={<><CodeIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Enhance Legacy Code</>} description={labsEnhanceCodeBlocks ? 'Auto-Enhance' : 'Disabled'}
|
||||
checked={labsEnhanceCodeBlocks} onChange={setLabsEnhanceCodeBlocks}
|
||||
@@ -83,11 +54,6 @@ export function UxLabsSettings() {
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
|
||||
</FormControl>
|
||||
|
||||
{DEV_MODE_SETTINGS && <FormSwitchControl
|
||||
title={<><TitleIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} />Chat Title</>} description={labsChatBarAlt === 'title' ? 'Show Title' : 'Show Models'}
|
||||
checked={labsChatBarAlt === 'title'} onChange={(on) => setLabsChatBarAlt(on ? 'title' : false)}
|
||||
/>}
|
||||
|
||||
{!isMobile && <FormSwitchControl
|
||||
title={<><ScreenshotMonitorIcon sx={{ fontSize: 'lg', mr: 0.5, mb: 0.25 }} /> Screen Capture</>} description={labsAttachScreenCapture ? 'Enabled' : 'Disabled'}
|
||||
checked={labsAttachScreenCapture} onChange={setLabsAttachScreenCapture}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { SpeexConfigureEngines } from '~/modules/speex/components/SpeexConfigureEngines';
|
||||
import { useSpeexEngines } from '~/modules/speex/store-module-speex';
|
||||
import { useSpeexEngines, useSpeexTtsCharLimit } from '~/modules/speex/store-module-speex';
|
||||
|
||||
import { ChatAutoSpeakType, useChatAutoAI } from '../chat/store-app-chat';
|
||||
|
||||
import { FormRadioOption } from '~/common/components/forms/FormRadioControl';
|
||||
import { FormChipControl } from '~/common/components/forms/FormChipControl';
|
||||
import { FormRadioOption } from '~/common/components/forms/FormRadioControl';
|
||||
import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
|
||||
|
||||
|
||||
const _autoSpeakOptions: FormRadioOption<ChatAutoSpeakType>[] = [
|
||||
@@ -21,6 +22,7 @@ export function VoiceOutSettings(props: { isMobile: boolean }) {
|
||||
|
||||
// external state
|
||||
const { autoSpeak, setAutoSpeak } = useChatAutoAI();
|
||||
const { ttsCharLimit, setTtsCharLimit } = useSpeexTtsCharLimit();
|
||||
|
||||
// external state - module
|
||||
const hasEngines = useSpeexEngines().length > 0;
|
||||
@@ -39,6 +41,15 @@ export function VoiceOutSettings(props: { isMobile: boolean }) {
|
||||
onChange={setAutoSpeak}
|
||||
/>
|
||||
|
||||
{/* TTS character limit toggle */}
|
||||
<FormSwitchControl
|
||||
title='Speak Cost Guard'
|
||||
description={ttsCharLimit !== null ? 'Max ~3 min' : 'Unlimited'}
|
||||
tooltip='Limits text sent to TTS providers, helping prevent unexpected costs with cloud services. By default the limit is 4096 characters (~3 minutes of speech).'
|
||||
checked={ttsCharLimit !== null}
|
||||
onChange={(checked) => setTtsCharLimit(checked ? 4096 : null)}
|
||||
/>
|
||||
|
||||
{/* Engine configuration */}
|
||||
<SpeexConfigureEngines isMobile={props.isMobile} />
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c)2024-2025 Enrico Ros
|
||||
* Copyright (c)2024-2026 Enrico Ros
|
||||
*
|
||||
* This file is include by both the frontend and backend, however depending on the time
|
||||
* of the build, the values may be different.
|
||||
@@ -23,8 +23,8 @@ export const Release = {
|
||||
|
||||
// this is here to trigger revalidation of data, e.g. models refresh
|
||||
Monotonics: {
|
||||
Aix: 43,
|
||||
NewsVersion: 202,
|
||||
Aix: 59,
|
||||
NewsVersion: 203,
|
||||
},
|
||||
|
||||
// Frontend: pretty features
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Attachment Cloud Files
|
||||
*
|
||||
* For future refresh capability, the output fragments should preserve:
|
||||
* - provider, fileId: to identify the file
|
||||
* - mimeType: the original cloud MIME type
|
||||
* - the converter used (stored in outputsHeuristic.actualConverterId)
|
||||
*
|
||||
* Google Workspace files (Docs, Sheets, Slides) are auto-exported during
|
||||
* input loading to standard formats (HTML, CSV, PDF) and then processed
|
||||
* by standard converters.
|
||||
*/
|
||||
|
||||
import type { AttachmentCloudProviderId } from './attachment.types';
|
||||
|
||||
|
||||
// Error handling
|
||||
|
||||
export class CloudFetchError extends Error {
|
||||
constructor(public readonly code: _CloudFetchErrorCode, public readonly details?: string) {
|
||||
super(`Cloud fetch error: ${code}${details ? ` - ${details}` : ''}`);
|
||||
this.name = 'CloudFetchError';
|
||||
}
|
||||
}
|
||||
|
||||
type _CloudFetchErrorCode = 'AUTH_EXPIRED' | 'NOT_FOUND' | 'FORBIDDEN' | 'RATE_LIMITED' | 'NETWORK_ERROR' | 'NOT_IMPLEMENTED' | 'FETCH_FAILED';
|
||||
|
||||
|
||||
// Utility functions
|
||||
|
||||
|
||||
/**
|
||||
* Google Workspace files can't be downloaded directly - they must be exported.
|
||||
* We prioritize AI-friendly formats (text > binary).
|
||||
*
|
||||
* Docs: md, docx, pdf, txt, rtf, odt, epub, html.zip
|
||||
* Sheets: xlsx, pdf, csv (1st sheet), tsv, ods
|
||||
* Slides: pptx, pdf, txt, png/jpg/svg (1st slide)
|
||||
* Drawings: png, pdf, jpg, svg
|
||||
*
|
||||
* Regular files: we'll return no conversion
|
||||
*
|
||||
* @see https://developers.google.com/workspace/drive/api/guides/ref-export-formats
|
||||
*/
|
||||
const _GOOGLE_WORKSPACE_EXPORT: Record<string, { mimeType: string; ext: string, converter: string }> = {
|
||||
'application/vnd.google-apps.document': { mimeType: 'text/markdown', ext: '.md', converter: 'Doc -> ' },
|
||||
'application/vnd.google-apps.spreadsheet': { mimeType: 'text/csv', ext: '.csv', converter: 'Sheet -> ' },
|
||||
'application/vnd.google-apps.presentation': { mimeType: 'application/pdf', ext: '.pdf', converter: 'Slides -> ' },
|
||||
'application/vnd.google-apps.drawing': { mimeType: 'image/svg+xml', ext: '.svg', converter: 'Drawing -> ' },
|
||||
};
|
||||
|
||||
export function attachmentCloudGoogleWorkspaceExportMIME(cloudMimeType: string): string | undefined {
|
||||
return _GOOGLE_WORKSPACE_EXPORT[cloudMimeType]?.mimeType;
|
||||
}
|
||||
|
||||
export function attachmentCloudConverterPrefix(cloudMimeType: string): string {
|
||||
return _GOOGLE_WORKSPACE_EXPORT[cloudMimeType]?.converter || 'Drive -> ';
|
||||
}
|
||||
|
||||
|
||||
// Fetcher
|
||||
|
||||
/**
|
||||
* Fetch a file from a cloud provider.
|
||||
*
|
||||
* @param provider - The cloud provider ID
|
||||
* @param fileId - The file ID in the provider's system
|
||||
* @param accessToken - OAuth access token
|
||||
* @param exportMimeType - For native formats (Docs/Sheets), the export format
|
||||
* @returns The file content as a Blob
|
||||
*/
|
||||
export async function attachmentCloudFetchFile(
|
||||
provider: AttachmentCloudProviderId,
|
||||
fileId: string,
|
||||
accessToken: string,
|
||||
exportMimeType?: string,
|
||||
): Promise<Blob> {
|
||||
switch (provider) {
|
||||
case 'gdrive':
|
||||
return _fetchGoogleDriveFile(fileId, accessToken, exportMimeType);
|
||||
|
||||
case 'onedrive':
|
||||
case 'dropbox':
|
||||
throw new CloudFetchError('NOT_IMPLEMENTED', `${provider} support coming soon`);
|
||||
|
||||
default:
|
||||
throw new CloudFetchError('NOT_IMPLEMENTED', `Unknown provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Google Drive API - Fetch file content
|
||||
* https://developers.google.com/drive/api/reference/rest/v3/files/get
|
||||
* https://developers.google.com/drive/api/reference/rest/v3/files/export
|
||||
*/
|
||||
async function _fetchGoogleDriveFile(
|
||||
fileId: string,
|
||||
accessToken: string,
|
||||
exportMimeType?: string,
|
||||
): Promise<Blob> {
|
||||
|
||||
// for native Google Workspace files, use export endpoint
|
||||
const url = exportMimeType
|
||||
? `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(exportMimeType)}`
|
||||
: `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?alt=media`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.log('[DEV] Network error while fetching Google Drive file:', { error });
|
||||
throw new CloudFetchError('NETWORK_ERROR', error?.message || String(error));
|
||||
});
|
||||
|
||||
// NOTE: we shall consider moving this to use fetchResponseOrTRPCThrow instead of this custom small impl..
|
||||
if (!response.ok) {
|
||||
const errorCode = _mapHttpStatusToErrorCode(response.status);
|
||||
let details = `${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
const errorBody = await response.text();
|
||||
if (errorBody) details += ` - ${errorBody.slice(0, 200)}`;
|
||||
} catch { /* ignore */
|
||||
}
|
||||
throw new CloudFetchError(errorCode, details);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
|
||||
function _mapHttpStatusToErrorCode(status: number): _CloudFetchErrorCode {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return 'AUTH_EXPIRED';
|
||||
case 403:
|
||||
return 'FORBIDDEN';
|
||||
case 404:
|
||||
return 'NOT_FOUND';
|
||||
case 429:
|
||||
return 'RATE_LIMITED';
|
||||
default:
|
||||
return 'FETCH_FAILED';
|
||||
}
|
||||
}
|
||||
@@ -59,17 +59,35 @@ export async function imageDataToImageAttachmentFragmentViaDBlob(
|
||||
origin: { // User originated
|
||||
ot: 'user',
|
||||
source: 'attachment',
|
||||
media: source.media === 'file' ? source.origin : source.media === 'url' ? 'url' : 'unknown',
|
||||
url: source.media === 'url' ? source.url : undefined,
|
||||
fileName: source.media === 'file' ? source.refPath : undefined,
|
||||
media:
|
||||
source.media === 'file' ? source.origin
|
||||
: source.media === 'url' ? 'url'
|
||||
: source.media === 'cloud' ? source.provider
|
||||
: 'unknown',
|
||||
url:
|
||||
source.media === 'url' ? source.url
|
||||
: source.media === 'cloud' ? source.webViewLink
|
||||
: undefined,
|
||||
fileName:
|
||||
source.media === 'file' ? source.refPath
|
||||
: source.media === 'cloud' ? source.fileName
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// use title if available, otherwise use the source refPath/refUrl/fileName
|
||||
const refTextSummary = title || (
|
||||
source.media === 'file' ? source.refPath
|
||||
: source.media === 'url' ? source.refUrl
|
||||
: source.media === 'cloud' ? source.fileName
|
||||
: undefined
|
||||
);
|
||||
|
||||
// Future-proof: create a Zync Image Asset reference attachment fragment, with the legacy image_ref part for compatibility for the time being
|
||||
return createZyncAssetReferenceAttachmentFragment(
|
||||
title, caption,
|
||||
nanoidToUuidV4(dblobAssetId, 'convert-dblob-to-dasset'),
|
||||
title || (source.media === 'file' ? source.refPath : source.media === 'url' ? source.refUrl : undefined), // use title if available, otherwise use the source refPath or refUrl
|
||||
refTextSummary,
|
||||
'image',
|
||||
{
|
||||
pt: 'image_ref' as const,
|
||||
@@ -77,7 +95,7 @@ export async function imageDataToImageAttachmentFragmentViaDBlob(
|
||||
...(title ? { altText: title } : {}),
|
||||
...(imageWidth ? { width: imageWidth } : {}),
|
||||
...(imageHeight ? { height: imageHeight } : {}),
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('imageAttachment: Error processing image:', error);
|
||||
|
||||
@@ -11,12 +11,14 @@ import { convert_Base64DataURL_To_Base64WithMimeType, convert_Base64WithMimeType
|
||||
import { getDomainModelConfiguration } from '~/common/stores/llms/hooks/useModelDomain';
|
||||
import { htmlTableToMarkdown } from '~/common/util/htmlTableToMarkdown';
|
||||
import { humanReadableHyphenated } from '~/common/util/textUtils';
|
||||
import { ocrImageWithProgress, ocrPdfPagesWithProgress } from '~/common/util/ocrUtils';
|
||||
import { pdfToImageDataURLs, pdfToText } from '~/common/util/pdfUtils';
|
||||
|
||||
import { createDMessageDataInlineText, createDocAttachmentFragment, DMessageAttachmentFragment, DMessageDataInline, DMessageDocPart, DVMimeType, isContentOrAttachmentFragment, isDocPart, specialContentPartToDocAttachmentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
import type { AttachmentCreationOptions, AttachmentDraft, AttachmentDraftConverter, AttachmentDraftId, AttachmentDraftInput, AttachmentDraftSource, AttachmentDraftSourceOriginFile, DraftEgoFragmentsInputData, DraftWebInputData, DraftYouTubeInputData } from './attachment.types';
|
||||
import type { AttachmentsDraftsStore } from './store-attachment-drafts_slice';
|
||||
import { attachmentCloudConverterPrefix, attachmentCloudFetchFile, attachmentCloudGoogleWorkspaceExportMIME, CloudFetchError } from './attachment.cloud';
|
||||
import { attachmentGetLiveFileId, attachmentSourceSupportsLiveFile } from './attachment.livefile';
|
||||
import { guessInputContentTypeFromMime, heuristicMimeTypeFixup, mimeTypeIsDocX, mimeTypeIsPDF, mimeTypeIsPlainText, mimeTypeIsSupportedImage, reverseLookupMimeType } from './attachment.mimetypes';
|
||||
import { imageDataToImageAttachmentFragmentViaDBlob } from './attachment.dblobs';
|
||||
@@ -27,6 +29,11 @@ const PDF_IMAGE_QUALITY = 0.5;
|
||||
const ENABLE_TEXT_AND_IMAGES = false; // [PROD] ?
|
||||
const DOCPART_DEFAULT_VERSION = 1;
|
||||
|
||||
// PDF text extraction quality thresholds
|
||||
const IMAGE_LOW_TEXT_THRESHOLD = 80; // chars per image - below this, consider the image as low-text (photo-like) rather than document-like
|
||||
const PDF_LOW_TEXT_THRESHOLD = 160; // chars per page - below this, consider the PDF as scanned/image-based
|
||||
const PDF_FALLBACK_MAX_IMAGES = 32; // max pages to convert to images when auto-falling back (to respect LLM limits)
|
||||
|
||||
|
||||
// internal mimes, only used to route data within us (source -> input -> converters)
|
||||
const INT_MIME_VND_AGI_EGO_FRAGMENTS = 'application/vnd.agi.ego.fragments';
|
||||
@@ -63,7 +70,8 @@ export function attachmentCreate(source: AttachmentDraftSource): AttachmentDraft
|
||||
export async function attachmentLoadInputAsync(source: Readonly<AttachmentDraftSource>, edit: (changes: Partial<Omit<AttachmentDraft, 'outputFragments'>>) => void) {
|
||||
edit({ inputLoading: true });
|
||||
|
||||
switch (source.media) {
|
||||
const sourceMedia = source.media;
|
||||
switch (sourceMedia) {
|
||||
|
||||
// Download URL (page, file, ..) and attach as input
|
||||
case 'url':
|
||||
@@ -141,6 +149,7 @@ export async function attachmentLoadInputAsync(source: Readonly<AttachmentDraftS
|
||||
} else
|
||||
edit({ inputError: 'No content or file found at this link' });
|
||||
} catch (error: any) {
|
||||
console.log('[DEV] Issue downloading page for attachment:', { error });
|
||||
edit({ inputError: `Issue downloading page: ${error?.message || (typeof error === 'string' ? error : JSON.stringify(error))}` });
|
||||
}
|
||||
break;
|
||||
@@ -221,6 +230,34 @@ export async function attachmentLoadInputAsync(source: Readonly<AttachmentDraftS
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cloud':
|
||||
const cloudLabel = source.fileName || 'Cloud File';
|
||||
const cloudRef = source.webViewLink || `${source.provider}:${source.fileId}`;
|
||||
edit({ label: cloudLabel, ref: cloudRef });
|
||||
|
||||
try {
|
||||
// fetch / export to the destination mime
|
||||
const exportMime = attachmentCloudGoogleWorkspaceExportMIME(source.mimeType);
|
||||
const cloudBlob = await attachmentCloudFetchFile(source.provider, source.fileId, source.accessToken, exportMime);
|
||||
|
||||
// use export mime if we exported, otherwise use source or detected mime
|
||||
const resultMime = exportMime || source.mimeType /* provided outside */ || cloudBlob.type /* connection */ || 'application/octet-stream';
|
||||
|
||||
edit({
|
||||
input: {
|
||||
mimeType: resultMime,
|
||||
data: cloudBlob,
|
||||
dataSize: cloudBlob.size,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof CloudFetchError
|
||||
? `${error.code}: ${error.details || error.message}`
|
||||
: `Failed to download: ${error instanceof Error ? error.message : String(error)}`;
|
||||
edit({ inputError: errorMessage });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ego':
|
||||
edit({
|
||||
label: source.label,
|
||||
@@ -231,6 +268,10 @@ export async function attachmentLoadInputAsync(source: Readonly<AttachmentDraftS
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
const _exhaustiveCheck: never = sourceMedia;
|
||||
break;
|
||||
}
|
||||
|
||||
edit({ inputLoading: false });
|
||||
@@ -251,6 +292,7 @@ export function attachmentDefineConverters(source: AttachmentDraftSource, input:
|
||||
const converters: AttachmentDraftConverter[] = [];
|
||||
|
||||
const autoAddImages = ENABLE_TEXT_AND_IMAGES && !!options?.hintAddImages;
|
||||
const fromCloud = source.media === 'cloud';
|
||||
|
||||
switch (true) {
|
||||
|
||||
@@ -258,6 +300,7 @@ export function attachmentDefineConverters(source: AttachmentDraftSource, input:
|
||||
case mimeTypeIsPlainText(input.mimeType):
|
||||
// handle a secondary layer of HTML 'text' origins: drop, paste, and clipboard-read
|
||||
const textOriginHtml = source.media === 'text' && input.altMimeType === 'text/html' && !!input.altData;
|
||||
const textOriginClipboard = source.media === 'text' && ['clipboard-read', 'paste'].includes(source.method);
|
||||
const isHtmlTable = !!input.altData?.startsWith('<table');
|
||||
|
||||
// p1: Tables
|
||||
@@ -265,12 +308,21 @@ export function attachmentDefineConverters(source: AttachmentDraftSource, input:
|
||||
converters.push({ id: 'rich-text-table', name: 'Markdown Table' });
|
||||
|
||||
// p2: Text
|
||||
converters.push({ id: 'text', name: attachmentSourceSupportsLiveFile(source) ? 'Text (Live)' : 'Text' });
|
||||
if (fromCloud && input.mimeType === 'text/markdown') {
|
||||
converters.push({ id: 'text', name: 'Markdown' });
|
||||
} else {
|
||||
converters.push({ id: 'text', name: attachmentSourceSupportsLiveFile(source) ? 'Text (Live)' : 'Text' });
|
||||
if (!textOriginHtml && textOriginClipboard) {
|
||||
converters.push({ id: 'text-markdown', name: 'Text -> Markdown' });
|
||||
converters.push({ id: 'text-cleaner', name: 'Text -> Clean HTML' });
|
||||
}
|
||||
}
|
||||
|
||||
// p3: Html
|
||||
// p3: Html -> Markdown, and Html
|
||||
if (textOriginHtml) {
|
||||
converters.push({ id: 'rich-text-cleaner', name: 'Cleaner HTML' });
|
||||
converters.push({ id: 'rich-text', name: 'HTML · Heavy' });
|
||||
converters.push({ id: 'rich-text-markdown', name: 'HTML -> Markdown' });
|
||||
converters.push({ id: 'rich-text-cleaner', name: 'HTML -> Clean HTML' });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -283,16 +335,18 @@ export function attachmentDefineConverters(source: AttachmentDraftSource, input:
|
||||
converters.push({ id: 'image-original', name: 'Image (original quality)', disabled: !inputImageMimeSupported });
|
||||
if (!inputImageMimeSupported)
|
||||
converters.push({ id: 'image-to-default', name: `As Image (${PLATFORM_IMAGE_MIMETYPE})` });
|
||||
converters.push({ id: 'image-caption', name: 'Caption (Text)', disabled: visionModelMissing });
|
||||
converters.push({ id: 'image-caption', name: 'AI Caption (Text)', disabled: visionModelMissing });
|
||||
converters.push({ id: 'unhandled', name: 'No Image' });
|
||||
converters.push({ id: 'image-ocr', name: 'Add Text (OCR)', isCheckbox: true });
|
||||
break;
|
||||
|
||||
// PDF
|
||||
case mimeTypeIsPDF(input.mimeType):
|
||||
converters.push({ id: 'pdf-text', name: 'PDF To Text', isActive: !autoAddImages || undefined });
|
||||
converters.push({ id: 'pdf-images', name: 'PDF To Images' });
|
||||
converters.push({ id: 'pdf-text-and-images', name: 'PDF Text & Images (best)', isActive: autoAddImages });
|
||||
converters.push({ id: 'pdf-auto', name: 'Auto', isActive: !autoAddImages });
|
||||
converters.push({ id: 'pdf-text', name: 'PDF Text' });
|
||||
converters.push({ id: 'pdf-images-ocr', name: 'PDF -> OCR (for scans)' });
|
||||
converters.push({ id: 'pdf-images', name: 'PDF -> Images' });
|
||||
converters.push({ id: 'pdf-text-and-images', name: 'PDF -> Text + Images', isActive: autoAddImages });
|
||||
break;
|
||||
|
||||
// DOCX
|
||||
@@ -337,6 +391,12 @@ export function attachmentDefineConverters(source: AttachmentDraftSource, input:
|
||||
break;
|
||||
}
|
||||
|
||||
// cosmetic for cloud: prepend cloud label prefixes
|
||||
const cloudLabelPrefix = source.media === 'cloud' ? attachmentCloudConverterPrefix(source.mimeType) : '';
|
||||
if (cloudLabelPrefix)
|
||||
for (const converter of converters)
|
||||
converter.name = cloudLabelPrefix + converter.name;
|
||||
|
||||
edit({ converters });
|
||||
}
|
||||
|
||||
@@ -380,7 +440,8 @@ function _prepareDocData(source: AttachmentDraftSource, input: Readonly<Attachme
|
||||
srcFileSize: source.fileWithHandle.size || input.dataSize,
|
||||
};
|
||||
|
||||
switch (source.origin) {
|
||||
const sourceOrigin = source.origin;
|
||||
switch (sourceOrigin) {
|
||||
case 'camera':
|
||||
fileTitle = source.refPath || _lowCollisionRefString('Camera Photo', 6);
|
||||
break;
|
||||
@@ -398,6 +459,10 @@ function _prepareDocData(source: AttachmentDraftSource, input: Readonly<Attachme
|
||||
case 'drop':
|
||||
fileTitle = source.refPath || _lowCollisionRefString('Dropped File', 6);
|
||||
break;
|
||||
default:
|
||||
const _exhaustiveCheck: never = sourceOrigin;
|
||||
fileTitle = 'File';
|
||||
break;
|
||||
}
|
||||
return {
|
||||
title: fileTitle,
|
||||
@@ -415,6 +480,25 @@ function _prepareDocData(source: AttachmentDraftSource, input: Readonly<Attachme
|
||||
refString: humanReadableHyphenated(textRef),
|
||||
};
|
||||
|
||||
// Cloud files
|
||||
case 'cloud':
|
||||
const cloudFileName = source.fileName || 'Cloud File';
|
||||
const cloudProviderLabel = source.provider === 'gdrive' ? 'Google Drive'
|
||||
: source.provider === 'onedrive' ? 'OneDrive'
|
||||
: source.provider === 'dropbox' ? 'Dropbox'
|
||||
: 'Cloud';
|
||||
const cloudRef = `${source.provider}-${source.fileName || _lowCollisionRefString('file', 6)}`;
|
||||
return {
|
||||
title: cloudFileName,
|
||||
caption: `From ${cloudProviderLabel}`,
|
||||
refString: humanReadableHyphenated(cloudRef),
|
||||
// TODO: expand this to allow future redownload - or other location but for the same purpose
|
||||
docMeta: {
|
||||
srcFileName: source.fileName,
|
||||
srcFileSize: source.fileSize || input.dataSize,
|
||||
},
|
||||
};
|
||||
|
||||
// The application attaching pieces of itself
|
||||
case 'ego':
|
||||
const egoKind = source.method === 'ego-fragments' ? 'Chat Message' : '';
|
||||
@@ -478,6 +562,8 @@ export async function attachmentPerformConversion(
|
||||
edit(attachment.id, {
|
||||
outputsConverting: true,
|
||||
outputsConversionProgress: null,
|
||||
outputWarnings: undefined,
|
||||
outputsHeuristic: undefined,
|
||||
});
|
||||
|
||||
// apply converter to the input
|
||||
@@ -490,41 +576,81 @@ export async function attachmentPerformConversion(
|
||||
|
||||
switch (converter.id) {
|
||||
|
||||
// text as-is
|
||||
// text
|
||||
case 'text':
|
||||
case 'text-cleaner':
|
||||
case 'text-markdown':
|
||||
const possibleLiveFileId = await attachmentGetLiveFileId(source);
|
||||
const textContent = await _inputDataToString(input.data, 'text');
|
||||
const textualInlineData = createDMessageDataInlineText(textContent, input.mimeType);
|
||||
let textContent = await _inputDataToString(input.data, 'text');
|
||||
let textContentMime = input.mimeType || 'text/plain';
|
||||
|
||||
switch (converter.id) {
|
||||
case 'text-cleaner':
|
||||
textContent = _cleanPossibleHtmlText(textContent);
|
||||
break;
|
||||
case 'text-markdown':
|
||||
try {
|
||||
const { convertHtmlToMarkdown } = await import('./file-converters/HtmlToMarkdown');
|
||||
textContent = convertHtmlToMarkdown(textContent);
|
||||
textContentMime = 'text/markdown';
|
||||
} catch (error) {
|
||||
console.log('[DEV] Error converting Text (HTML) to Markdown:', error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const textualInlineData = createDMessageDataInlineText(textContent, textContentMime);
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, _guessDocVDT(input.mimeType), textualInlineData, refString, DOCPART_DEFAULT_VERSION, docMeta, possibleLiveFileId));
|
||||
break;
|
||||
|
||||
// html as-is
|
||||
// html
|
||||
case 'rich-text':
|
||||
case 'rich-text-cleaner':
|
||||
case 'rich-text-markdown':
|
||||
let richText: string;
|
||||
if (input.altData)
|
||||
richText = input.altData;
|
||||
else if (input.mimeType === 'text/html')
|
||||
richText = await _inputDataToString(input.data, 'rich-text');
|
||||
else
|
||||
richText = '';
|
||||
let richTextMimeType = 'text/html';
|
||||
|
||||
// html -> cleaner/html or markdown
|
||||
switch (converter.id) {
|
||||
case 'rich-text-cleaner':
|
||||
richText = _cleanPossibleHtmlText(richText);
|
||||
richTextMimeType = 'text/html';
|
||||
break;
|
||||
case 'rich-text-markdown':
|
||||
try {
|
||||
const { convertHtmlToMarkdown } = await import('./file-converters/HtmlToMarkdown');
|
||||
richText = convertHtmlToMarkdown(richText);
|
||||
richTextMimeType = 'text/markdown';
|
||||
} catch (error) {
|
||||
console.log('[DEV] Error converting HTML to Markdown:', error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// NOTE: before we had the following: createTextAttachmentFragment(ref || '\n<!DOCTYPE html>', input.altData!), which
|
||||
// was used to wrap the HTML in a code block to facilitate AutoRenderBlocks's parser. Historic note, for future debugging.
|
||||
const richTextData = createDMessageDataInlineText(input.altData || '', input.altMimeType);
|
||||
const richTextData = createDMessageDataInlineText(richText, richTextMimeType);
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, DVMimeType.VndAgiCode, richTextData, refString, DOCPART_DEFAULT_VERSION, docMeta));
|
||||
break;
|
||||
|
||||
// html cleaned
|
||||
case 'rich-text-cleaner':
|
||||
const cleanerHtml = (input.altData || '')
|
||||
// remove class and style attributes
|
||||
.replace(/<[^>]+>/g, (tag) =>
|
||||
tag.replace(/ class="[^"]*"/g, '').replace(/ style="[^"]*"/g, ''),
|
||||
)
|
||||
// remove svg elements
|
||||
.replace(/<svg[^>]*>.*?<\/svg>/g, '');
|
||||
const cleanedHtmlData = createDMessageDataInlineText(cleanerHtml, 'text/html');
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, DVMimeType.VndAgiCode, cleanedHtmlData, refString, DOCPART_DEFAULT_VERSION, docMeta));
|
||||
break;
|
||||
|
||||
// html to markdown table
|
||||
case 'rich-text-table':
|
||||
let tableData: DMessageDataInline;
|
||||
try {
|
||||
const mdTable = htmlTableToMarkdown(input.altData!, false);
|
||||
tableData = createDMessageDataInlineText(mdTable, 'text/markdown');
|
||||
// fall back to source text if the table conversion produced empty/tiny content
|
||||
if (mdTable.replace(/[\s|:\-]/g, '').length < 2) {
|
||||
const fallbackText = await _inputDataToString(input.data, 'rich-text-table');
|
||||
tableData = createDMessageDataInlineText(fallbackText || mdTable, input.mimeType);
|
||||
} else {
|
||||
tableData = createDMessageDataInlineText(mdTable, 'text/markdown');
|
||||
}
|
||||
} catch (error) {
|
||||
// fallback to text/plain
|
||||
const fallbackText = await _inputDataToString(input.data, 'rich-text-table');
|
||||
@@ -570,23 +696,14 @@ export async function attachmentPerformConversion(
|
||||
case 'image-ocr':
|
||||
if (!_expectBlob(input.data, 'Image OCR converter')) break;
|
||||
try {
|
||||
let lastProgress = -1;
|
||||
const { recognize } = await import('tesseract.js');
|
||||
const result = await recognize(input.data, undefined, {
|
||||
errorHandler: e => console.error(e),
|
||||
logger: (message) => {
|
||||
if (message.status === 'recognizing text') {
|
||||
if (message.progress > lastProgress + 0.01) {
|
||||
lastProgress = message.progress;
|
||||
edit(attachment.id, { outputsConversionProgress: lastProgress });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
const imageText = result.data.text;
|
||||
// Image -> OCR -> Inline text doc
|
||||
const imageText = await ocrImageWithProgress(input.data, (progress) => edit(attachment.id, { outputsConversionProgress: progress }));
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, DVMimeType.TextPlain, createDMessageDataInlineText(imageText, 'text/plain'), refString, DOCPART_DEFAULT_VERSION, { ...docMeta, srcOcrFrom: 'image' }));
|
||||
// warn if very little text was extracted (likely a photo/diagram rather than text)
|
||||
if (imageText.trim().length < IMAGE_LOW_TEXT_THRESHOLD)
|
||||
edit(attachment.id, { outputWarnings: ['Very little text extracted - this image may not contain readable text.'] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('[Image OCR Error]', error);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -615,26 +732,111 @@ export async function attachmentPerformConversion(
|
||||
} catch (error: any) {
|
||||
console.log('[DEV] Failed to caption image:', error);
|
||||
const errorText = `[Captioning failed: ${error?.message || String(error)}]`;
|
||||
edit(attachment.id, { outputWarnings: [errorText] });
|
||||
newFragments.push(createDocAttachmentFragment(title, caption + ' (Error)', DVMimeType.TextPlain, createDMessageDataInlineText(errorText, 'text/plain'), refString, DOCPART_DEFAULT_VERSION, { ...docMeta, srcOcrFrom: 'image-caption' }));
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
// pdf to text
|
||||
case 'pdf-text':
|
||||
if (!_expectBlob(input.data, 'PDF text converter')) break;
|
||||
// Convert Blob to ArrayBuffer for PDF.js
|
||||
const pdfText = await pdfToText(await input.data.arrayBuffer(), (progress: number) => {
|
||||
edit(attachment.id, { outputsConversionProgress: progress });
|
||||
});
|
||||
if (pdfText.trim().length < 2) {
|
||||
// Warn the user if no text is extracted
|
||||
// edit(attachment.id, { inputError: 'No text found in the PDF file.' });
|
||||
} else
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, DVMimeType.TextPlain, createDMessageDataInlineText(pdfText, 'text/plain'), refString, DOCPART_DEFAULT_VERSION, { ...docMeta, srcOcrFrom: 'pdf' }));
|
||||
// pdf-auto: intelligent conversion with fallback chain (text → OCR → images)
|
||||
case 'pdf-auto':
|
||||
if (!_expectBlob(input.data, 'PDF auto converter')) break;
|
||||
try {
|
||||
// Phase 1: Try text extraction (0-20% progress)
|
||||
const pdfArrayBuffer = await input.data.arrayBuffer();
|
||||
|
||||
// [pdf-text] Extract text with quality metadata
|
||||
const pdfTextResult = await pdfToText(pdfArrayBuffer, (progress: number) => {
|
||||
// Reserve 0-20% for text extraction attempt, 20-100% for potential image fallback
|
||||
edit(attachment.id, { outputsConversionProgress: progress * 0.2 });
|
||||
});
|
||||
|
||||
// Check text density to detect scanned/image-based PDFs
|
||||
if (pdfTextResult.avgCharsPerPage >= PDF_LOW_TEXT_THRESHOLD) {
|
||||
// Good text extraction - use it
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, DVMimeType.TextPlain, createDMessageDataInlineText(pdfTextResult.text, 'text/plain'), refString, DOCPART_DEFAULT_VERSION, { ...docMeta, srcOcrFrom: 'pdf' }));
|
||||
edit(attachment.id, {
|
||||
outputsHeuristic: { isAuto: true, actualConverterId: 'pdf-text', explain: `${pdfTextResult.avgCharsPerPage.toFixed(0)} chars/page` },
|
||||
});
|
||||
} else {
|
||||
// Low text density - try OCR
|
||||
// console.log(`[PDF Auto] Low text density (${pdfTextResult.avgCharsPerPage.toFixed(0)} chars/page), trying OCR...`);
|
||||
|
||||
// [pdf-images] Phase 2: Render pages to images (20-40% progress)
|
||||
const pdfArrayBufferForImages = await input.data.arrayBuffer();
|
||||
const imageDataURLs = await pdfToImageDataURLs(pdfArrayBufferForImages, PLATFORM_IMAGE_MIMETYPE, PDF_IMAGE_QUALITY, PDF_IMAGE_PAGE_SCALE, (progress) => {
|
||||
edit(attachment.id, { outputsConversionProgress: 0.2 + progress * 0.2 });
|
||||
});
|
||||
|
||||
// Limit pages for OCR (performance)
|
||||
const pagesToProcess = Math.min(imageDataURLs.length, PDF_FALLBACK_MAX_IMAGES);
|
||||
const imagesToOcr = imageDataURLs.slice(0, pagesToProcess);
|
||||
|
||||
// Phase 3: Try OCR on rendered pages (40-90% progress)
|
||||
try {
|
||||
// [pdf-images-ocr] OCR the images
|
||||
const ocrResult = await ocrPdfPagesWithProgress(imagesToOcr, (progress) => {
|
||||
edit(attachment.id, { outputsConversionProgress: 0.4 + progress * 0.5 });
|
||||
});
|
||||
|
||||
if (ocrResult.avgCharsPerPage >= PDF_LOW_TEXT_THRESHOLD) {
|
||||
// OCR yielded good text - use it
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, DVMimeType.TextPlain, createDMessageDataInlineText(ocrResult.text, 'text/plain'), refString, DOCPART_DEFAULT_VERSION, { ...docMeta, srcOcrFrom: 'pdf' }));
|
||||
const truncNote = pdfTextResult.pageCount > pagesToProcess ? ` (${pagesToProcess}/${pdfTextResult.pageCount} pages)` : '';
|
||||
edit(attachment.id, {
|
||||
outputsHeuristic: { isAuto: true, actualConverterId: 'pdf-images-ocr', explain: /*OCR extracted */`${ocrResult.avgCharsPerPage.toFixed(0)} chars/page${truncNote}` },
|
||||
});
|
||||
} else {
|
||||
// OCR also yielded poor results - fall back to images
|
||||
// console.log(`[PDF Auto] OCR also sparse (${ocrResult.avgCharsPerPage.toFixed(0)} chars/page), falling back to images`);
|
||||
for (let i = 0; i < pagesToProcess; i++) {
|
||||
const pdfPageImage = imageDataURLs[i];
|
||||
const pdfPageImageF = await imageDataToImageAttachmentFragmentViaDBlob(pdfPageImage.mimeType, pdfPageImage.base64Data, source, `${title} (pg. ${i + 1})`, caption, false, false);
|
||||
if (pdfPageImageF)
|
||||
newFragments.push(pdfPageImageF);
|
||||
}
|
||||
const truncNote = pdfTextResult.pageCount > pagesToProcess ? ` (${pagesToProcess}/${pdfTextResult.pageCount} pages)` : '';
|
||||
edit(attachment.id, {
|
||||
outputsHeuristic: { isAuto: true, actualConverterId: 'pdf-images', explain: `not a text page${truncNote}` },
|
||||
});
|
||||
}
|
||||
} catch (ocrError) {
|
||||
// OCR failed - fall back to images
|
||||
console.warn('[PDF Auto] OCR failed, falling back to images:', ocrError);
|
||||
for (let i = 0; i < pagesToProcess; i++) {
|
||||
const pdfPageImage = imageDataURLs[i];
|
||||
const pdfPageImageF = await imageDataToImageAttachmentFragmentViaDBlob(pdfPageImage.mimeType, pdfPageImage.base64Data, source, `${title} (pg. ${i + 1})`, caption, false, false);
|
||||
if (pdfPageImageF)
|
||||
newFragments.push(pdfPageImageF);
|
||||
}
|
||||
edit(attachment.id, {
|
||||
outputsHeuristic: { isAuto: true, actualConverterId: 'pdf-images', explain: 'OCR failed, attached as images' },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in PDF auto conversion:', error);
|
||||
}
|
||||
break;
|
||||
|
||||
// pdf to images
|
||||
// pdf-text: strict text extraction, no fallback (honors user choice)
|
||||
case 'pdf-text':
|
||||
if (!_expectBlob(input.data, 'PDF text converter')) break;
|
||||
try {
|
||||
const pdfTextResult = await pdfToText(await input.data.arrayBuffer(), progress => edit(attachment.id, { outputsConversionProgress: progress }));
|
||||
// Always output text, even if sparse (user explicitly chose this)
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, DVMimeType.TextPlain, createDMessageDataInlineText(pdfTextResult.text, 'text/plain'), refString, DOCPART_DEFAULT_VERSION, { ...docMeta, srcOcrFrom: 'pdf' }));
|
||||
edit(attachment.id, {
|
||||
// warn if very little text was extracted (likely a scanned PDF)
|
||||
outputWarnings: pdfTextResult.avgCharsPerPage >= 20 ? undefined : ['Very little text extracted - this PDF may be scanned. Try "Auto" or "OCR (for scans)" mode.'],
|
||||
outputsHeuristic: { isAuto: false, actualConverterId: 'pdf-text', explain: `${pdfTextResult.avgCharsPerPage.toFixed(0)} chars/page` },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in PDF text extraction:', error);
|
||||
}
|
||||
break;
|
||||
|
||||
// pdf-images: render all pages as images (honors user choice)
|
||||
case 'pdf-images':
|
||||
if (!_expectBlob(input.data, 'PDF images converter')) break;
|
||||
// Convert Blob to ArrayBuffer for PDF.js
|
||||
@@ -647,11 +849,39 @@ export async function attachmentPerformConversion(
|
||||
if (pdfPageImageF)
|
||||
newFragments.push(pdfPageImageF);
|
||||
}
|
||||
edit(attachment.id, {
|
||||
outputsHeuristic: { isAuto: false, actualConverterId: 'pdf-images', explain: `${imageDataURLs.length} pages` },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error converting PDF to images:', error);
|
||||
}
|
||||
break;
|
||||
|
||||
// pdf-images-ocr: force OCR on all pages (for scanned documents)
|
||||
case 'pdf-images-ocr':
|
||||
if (!_expectBlob(input.data, 'PDF OCR converter')) break;
|
||||
try {
|
||||
// Render pages to images (0-40% progress)
|
||||
const imageDataURLs = await pdfToImageDataURLs(await input.data.arrayBuffer(), PLATFORM_IMAGE_MIMETYPE, PDF_IMAGE_QUALITY, PDF_IMAGE_PAGE_SCALE, (progress) => {
|
||||
edit(attachment.id, { outputsConversionProgress: progress * 0.4 });
|
||||
});
|
||||
|
||||
// OCR all pages (40-100% progress)
|
||||
const ocrResult = await ocrPdfPagesWithProgress(imageDataURLs, (progress) => {
|
||||
edit(attachment.id, { outputsConversionProgress: 0.4 + progress * 0.6 });
|
||||
});
|
||||
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, DVMimeType.TextPlain, createDMessageDataInlineText(ocrResult.text, 'text/plain'), refString, DOCPART_DEFAULT_VERSION, { ...docMeta, srcOcrFrom: 'pdf' }));
|
||||
edit(attachment.id, {
|
||||
// warn if very little text was extracted (likely a scanned PDF)
|
||||
outputWarnings: ocrResult.avgCharsPerPage >= 20 ? undefined : ['Very little text extracted via OCR - this PDF may contain mostly images/diagrams.'],
|
||||
outputsHeuristic: { isAuto: false, actualConverterId: 'pdf-images-ocr', explain: `${ocrResult.avgCharsPerPage.toFixed(0)} chars/page from ${ocrResult.pageCount} pages` },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in PDF OCR:', error);
|
||||
}
|
||||
break;
|
||||
|
||||
// pdf to text and images
|
||||
case 'pdf-text-and-images':
|
||||
if (!_expectBlob(input.data, 'PDF text and images converter')) break;
|
||||
@@ -673,18 +903,21 @@ export async function attachmentPerformConversion(
|
||||
}
|
||||
|
||||
// duplicated from 'pdf-text'
|
||||
const pdfText = await pdfToText(pdfArrayBufferForText, (progress: number) => {
|
||||
const pdfTextResult = await pdfToText(pdfArrayBufferForText, (progress: number) => {
|
||||
edit(attachment.id, { outputsConversionProgress: 0.5 + progress / 2 }); // Update progress (50% to 100%)
|
||||
});
|
||||
if (pdfText.trim().length < 2) {
|
||||
// Do not warn the user, as hopefully the images are useful
|
||||
} else {
|
||||
const textFragment = createDocAttachmentFragment(title, caption, DVMimeType.TextPlain, createDMessageDataInlineText(pdfText, 'text/plain'), refString, DOCPART_DEFAULT_VERSION, { ...docMeta, srcOcrFrom: 'pdf' });
|
||||
if (pdfTextResult.text.trim().length >= 2) {
|
||||
// Add text fragment if there's meaningful text
|
||||
const textFragment = createDocAttachmentFragment(title, caption, DVMimeType.TextPlain, createDMessageDataInlineText(pdfTextResult.text, 'text/plain'), refString, DOCPART_DEFAULT_VERSION, { ...docMeta, srcOcrFrom: 'pdf' });
|
||||
newFragments.push(textFragment);
|
||||
}
|
||||
// Note: if text is sparse, images are still attached (user explicitly chose text+images)
|
||||
|
||||
// Add the text fragment first, then the image fragments
|
||||
newFragments.push(...imageFragments);
|
||||
edit(attachment.id, {
|
||||
outputsHeuristic: { isAuto: false, actualConverterId: 'pdf-text-and-images', explain: `${pdfTextResult.avgCharsPerPage.toFixed(0)} chars/page + ${imageFragments.length} images` },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error converting PDF to text and images:', error);
|
||||
}
|
||||
@@ -801,14 +1034,28 @@ export async function attachmentPerformConversion(
|
||||
case 'unhandled':
|
||||
// force the user to explicitly select 'as text' if they want to proceed
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
const _exhaustiveCheck: never = converter.id;
|
||||
console.warn('[DEV] Unhandled converter type:', _exhaustiveCheck);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// warn if any doc output fragment has empty text content (something went wrong in conversion)
|
||||
// TODO: future: check if the text is a conversion error... can happen with drag & drop
|
||||
const emptyOutputWarnings: string[] = [];
|
||||
for (const fragment of newFragments)
|
||||
if (isDocPart(fragment.part) && fragment.part.data.idt === 'text' && !fragment.part.data.text.trim())
|
||||
emptyOutputWarnings.push('Converted output is empty - the source content may be missing or invalid.');
|
||||
|
||||
// update
|
||||
replaceOutputFragments(attachment.id, newFragments);
|
||||
edit(attachment.id, {
|
||||
outputsConverting: false,
|
||||
outputsConversionProgress: null,
|
||||
...(emptyOutputWarnings.length && { outputWarnings: emptyOutputWarnings }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -843,6 +1090,19 @@ async function _inputDataToString(data: AttachmentDraftInput['data'], debugLocat
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple Client-side cleaning of possible HTML
|
||||
*/
|
||||
function _cleanPossibleHtmlText(inputStr: string): string {
|
||||
return inputStr
|
||||
// remove class and style attributes
|
||||
.replace(/<[^>]+>/g, (tag) =>
|
||||
tag.replace(/ class="[^"]*"/g, '').replace(/ style="[^"]*"/g, ''),
|
||||
)
|
||||
// remove svg elements
|
||||
.replace(/<svg[^>]*>.*?<\/svg>/g, '');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Special function to convert a list of files to Attachment Fragments, without passing through the attachments system
|
||||
|
||||
@@ -24,6 +24,16 @@ export type AttachmentDraft = {
|
||||
outputsConversionProgress: number | null;
|
||||
outputFragments: DMessageAttachmentFragment[];
|
||||
|
||||
// Warnings for poor conversions (e.g. scanned PDF with text extraction rather than OCR)
|
||||
outputWarnings?: string[];
|
||||
|
||||
// Tracks what method was actually used (especially for Auto mode)
|
||||
outputsHeuristic?: {
|
||||
isAuto: boolean;
|
||||
actualConverterId: AttachmentDraftConverterType;
|
||||
explain?: string; // e.g., "42 chars/page detected"
|
||||
};
|
||||
|
||||
// metadata: {
|
||||
// creationDate?: Date; // Creation date of the file
|
||||
// modifiedDate?: Date; // Last modified date of the file
|
||||
@@ -33,6 +43,13 @@ export type AttachmentDraft = {
|
||||
|
||||
export type AttachmentDraftId = string;
|
||||
|
||||
export type AttachmentCreationOptions = {
|
||||
/** Also attach an image representation of the attachment. Requires Release.Features.ENABLE_TEXT_AND_IMAGES as well. */
|
||||
hintAddImages?: boolean;
|
||||
}
|
||||
|
||||
export type AttachmentCloudProviderId = 'gdrive' | 'onedrive' | 'dropbox';
|
||||
|
||||
|
||||
// 0. draft source (filled at the onset)
|
||||
|
||||
@@ -51,6 +68,23 @@ export type AttachmentDraftSource = {
|
||||
method: 'clipboard-read' | AttachmentDraftSourceOriginDTO;
|
||||
textPlain?: string;
|
||||
textHtml?: string;
|
||||
} | {
|
||||
media: 'cloud';
|
||||
origin: AttachmentDraftSourceOriginCloud;
|
||||
|
||||
// auth for fetching
|
||||
accessToken: string;
|
||||
// tokenExpiresAt?: number; // optional for staleness detection, unix ts
|
||||
|
||||
// recipe for fetching
|
||||
provider: AttachmentCloudProviderId;
|
||||
fileId: string;
|
||||
mimeType: string; // cloud-native MIME (e.g., 'application/vnd.google-apps.document')
|
||||
|
||||
// decorative
|
||||
fileName: string;
|
||||
fileSize?: number;
|
||||
webViewLink?: string; // link to view in cloud provider's UI
|
||||
} | {
|
||||
// special type for attachments thar are references to self (ego, application) objects
|
||||
media: 'ego';
|
||||
@@ -65,10 +99,7 @@ export type AttachmentDraftSourceOriginDTO = 'drop' | 'paste';
|
||||
|
||||
export type AttachmentDraftSourceOriginUrl = 'input-link' | 'clipboard-read' | AttachmentDraftSourceOriginDTO;
|
||||
|
||||
export type AttachmentCreationOptions = {
|
||||
/** Also attach an image representation of the attachment. Requires Release.Features.ENABLE_TEXT_AND_IMAGES as well. */
|
||||
hintAddImages?: boolean;
|
||||
}
|
||||
export type AttachmentDraftSourceOriginCloud = `picker-${AttachmentCloudProviderId}`;
|
||||
|
||||
|
||||
// 1. draft input (loaded from the source)
|
||||
@@ -135,9 +166,10 @@ export type AttachmentDraftConverter = {
|
||||
}
|
||||
|
||||
export type AttachmentDraftConverterType =
|
||||
| 'text' | 'rich-text' | 'rich-text-cleaner' | 'rich-text-table'
|
||||
| 'text' | 'text-cleaner' | 'text-markdown'
|
||||
| 'rich-text' | 'rich-text-cleaner' | 'rich-text-markdown' | 'rich-text-table'
|
||||
| 'image-original' | 'image-resized-high' | 'image-resized-low' | 'image-ocr' | 'image-caption' | 'image-to-default'
|
||||
| 'pdf-text' | 'pdf-images' | 'pdf-text-and-images'
|
||||
| 'pdf-auto' | 'pdf-text' | 'pdf-images' | 'pdf-images-ocr' | 'pdf-text-and-images'
|
||||
| 'docx-to-html'
|
||||
| 'url-page-text' | 'url-page-markdown' | 'url-page-html' | 'url-page-null' | 'url-page-image'
|
||||
| 'youtube-transcript' | 'youtube-transcript-simple'
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { default as TurndownService } from 'turndown';
|
||||
|
||||
|
||||
// Cached Turndown service instance
|
||||
let _turndownService: TurndownService | null = null;
|
||||
|
||||
function getTurndownService(): TurndownService {
|
||||
if (!_turndownService) {
|
||||
_turndownService = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
emDelimiter: '_',
|
||||
});
|
||||
|
||||
// Remove script and style elements
|
||||
_turndownService.remove(['script', 'style', 'noscript']);
|
||||
}
|
||||
return _turndownService;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convert HTML string to Markdown using Turndown.
|
||||
* Performs basic HTML cleaning before conversion.
|
||||
*/
|
||||
export function convertHtmlToMarkdown(html: string): string {
|
||||
// Basic client-side HTML cleaning using DOMParser
|
||||
const cleanedHtml = cleanHtmlForMarkdown(html);
|
||||
return getTurndownService().turndown(cleanedHtml);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Client-side HTML cleaning optimized for Markdown conversion.
|
||||
* Uses DOMParser (browser-native) instead of Cheerio (server-only).
|
||||
*/
|
||||
function cleanHtmlForMarkdown(html: string): string {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// Remove unwanted elements
|
||||
const unwantedSelectors = [
|
||||
'script', 'style', 'link', 'noscript', 'iframe', 'svg', 'canvas',
|
||||
'nav:not(main nav)', 'aside', 'footer:not(article footer)',
|
||||
'.ad', '.ads', '.advertisement', '.banner', '.popup', '.modal', '.overlay',
|
||||
'.cookie-banner', '.newsletter-signup', '.social-share', '.comments',
|
||||
'.sidebar', '.widget', '.carousel', '.slider',
|
||||
'[aria-hidden="true"]', '[hidden]',
|
||||
'[data-analytics]', '[data-tracking]', '[data-gtm]',
|
||||
];
|
||||
|
||||
for (const selector of unwantedSelectors) {
|
||||
try {
|
||||
doc.querySelectorAll(selector).forEach(el => el.remove());
|
||||
} catch {
|
||||
// Skip invalid selectors (e.g., complex :not() selectors may fail in some browsers)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove hidden elements via inline styles
|
||||
doc.querySelectorAll('[style]').forEach(el => {
|
||||
const style = el.getAttribute('style') || '';
|
||||
if (style.includes('display: none') || style.includes('display:none') ||
|
||||
style.includes('visibility: hidden') || style.includes('visibility:hidden'))
|
||||
el.remove();
|
||||
});
|
||||
|
||||
// Clean up anchor hrefs (remove tracking parameters)
|
||||
doc.querySelectorAll('a[href]').forEach(el => {
|
||||
const href = el.getAttribute('href');
|
||||
if (!href) return;
|
||||
|
||||
// Remove javascript: links
|
||||
if (href.toLowerCase().startsWith('javascript:')) {
|
||||
el.removeAttribute('href');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove tracking parameters
|
||||
if (href.includes('?')) {
|
||||
try {
|
||||
const url = new URL(href, 'http://placeholder');
|
||||
const cleanParams = new URLSearchParams();
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (!key.match(/^(utm_|fbclid|gclid|msclkid)/i))
|
||||
cleanParams.append(key, value);
|
||||
});
|
||||
const cleanHref = `${url.pathname}${cleanParams.toString() ? '?' + cleanParams.toString() : ''}${url.hash}`;
|
||||
el.setAttribute('href', cleanHref);
|
||||
} catch {
|
||||
// Keep original href if URL parsing fails
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Remove comments (HTML comment nodes)
|
||||
const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_COMMENT);
|
||||
const comments: Comment[] = [];
|
||||
while (walker.nextNode())
|
||||
comments.push(walker.currentNode as Comment);
|
||||
comments.forEach(comment => comment.remove());
|
||||
|
||||
return doc.body.innerHTML;
|
||||
} catch (error) {
|
||||
console.error('HTML cleaning error:', error);
|
||||
return html; // Return original if cleaning fails
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import type { DMessageId } from '~/common/stores/chat/chat.message';
|
||||
import { getAllFilesFromDirectoryRecursively, getDataTransferFilesOrPromises } from '~/common/util/fileSystemUtils';
|
||||
import { useChatAttachmentsStore } from '~/common/chat-overlay/store-perchat_vanilla';
|
||||
|
||||
import type { AttachmentDraftSourceOriginDTO, AttachmentDraftSourceOriginFile, AttachmentDraftSourceOriginUrl } from './attachment.types';
|
||||
import type { AttachmentDraftSource, AttachmentDraftSourceOriginDTO, AttachmentDraftSourceOriginFile, AttachmentDraftSourceOriginUrl } from './attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from './store-attachment-drafts_slice';
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ function notifyOnlyImages(item: any) {
|
||||
}
|
||||
|
||||
|
||||
export type AttachmentStoreCloudInput = Omit<Extract<AttachmentDraftSource, { media: 'cloud' }>, 'media' | 'origin'>;
|
||||
|
||||
|
||||
/**
|
||||
* @param attachmentsStoreApi A Per-Chat or standalone Attachment Drafts store.
|
||||
* @param enableLoadURLsOnPaste Only used if invoking attachAppendDataTransfer or attachAppendClipboardItems.
|
||||
@@ -254,8 +257,8 @@ export function useAttachmentDrafts(attachmentsStoreApi: AttachmentDraftsStoreAp
|
||||
// https://github.com/enricoros/big-AGI/issues/286
|
||||
const textHtml = clipboardItem.types.includes('text/html')
|
||||
? await clipboardItem.getType('text/html')
|
||||
.then(blob => blob?.text() ?? '')
|
||||
.catch(() => '')
|
||||
.then(blob => blob?.text() ?? '')
|
||||
.catch(() => '')
|
||||
: '';
|
||||
const heuristicBypassImage = textHtml.startsWith('<table ');
|
||||
|
||||
@@ -289,8 +292,8 @@ export function useAttachmentDrafts(attachmentsStoreApi: AttachmentDraftsStoreAp
|
||||
// get the Plain text
|
||||
const textPlain = clipboardItem.types.includes('text/plain')
|
||||
? await clipboardItem.getType('text/plain')
|
||||
.then(blob => blob?.text() ?? '')
|
||||
.catch(() => '')
|
||||
.then(blob => blob?.text() ?? '')
|
||||
.catch(() => '')
|
||||
: '';
|
||||
|
||||
// attach as URL
|
||||
@@ -321,6 +324,27 @@ export function useAttachmentDrafts(attachmentsStoreApi: AttachmentDraftsStoreAp
|
||||
}
|
||||
}, [_createAttachmentDraft, attachAppendFile, attachAppendUrl, enableLoadURLsOnPaste, filterOnlyImages, hintAddImages]);
|
||||
|
||||
/**
|
||||
* Append a cloud file (Google Drive, OneDrive, etc.) to the attachments.
|
||||
* This is the entry point for cloud file picker integrations.
|
||||
*/
|
||||
const attachAppendCloudFile = React.useCallback((cloudFile: AttachmentStoreCloudInput) => {
|
||||
if (ATTACHMENTS_DEBUG_INTAKE)
|
||||
console.log('attachAppendCloudFile', cloudFile);
|
||||
|
||||
// only-images: ignore cloud files as they may not be images
|
||||
if (filterOnlyImages && !cloudFile.mimeType.startsWith('image/')) {
|
||||
notifyOnlyImages(cloudFile);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return _createAttachmentDraft({
|
||||
media: 'cloud',
|
||||
origin: `picker-${cloudFile.provider}`,
|
||||
...cloudFile,
|
||||
}, { hintAddImages });
|
||||
}, [_createAttachmentDraft, filterOnlyImages, hintAddImages]);
|
||||
|
||||
/**
|
||||
* Append ego content to the attachments.
|
||||
*/
|
||||
@@ -348,6 +372,7 @@ export function useAttachmentDrafts(attachmentsStoreApi: AttachmentDraftsStoreAp
|
||||
|
||||
// create drafts
|
||||
attachAppendClipboardItems,
|
||||
attachAppendCloudFile,
|
||||
attachAppendDataTransfer,
|
||||
attachAppendEgoFragments,
|
||||
attachAppendFile,
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { PickerCanceledEvent, PickerPickedEvent } from '@googleworkspace/drive-picker-element';
|
||||
import { DrivePicker, DrivePickerDocsView } from '@googleworkspace/drive-picker-react';
|
||||
|
||||
import { IconButton } from '@mui/joy';
|
||||
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';
|
||||
|
||||
|
||||
// configuration
|
||||
const GOOGLE_DRIVE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_DRIVE_CLIENT_ID || '';
|
||||
const MAX_FILE_SIZE_MB = 10; // skip files larger than this; 0 = no limit; note: Google Workspace files report 0 bytes
|
||||
const MAX_PICKER_FILES = 8; // max files per picker session; 0 = unlimited
|
||||
|
||||
export const hasGoogleDriveCapability = !!GOOGLE_DRIVE_CLIENT_ID;
|
||||
|
||||
|
||||
// -- Token Definitions --
|
||||
|
||||
export interface ICloudProviderTokenAccessor {
|
||||
get: () => CloudProviderToken | null;
|
||||
set: (value: CloudProviderToken | null) => void;
|
||||
}
|
||||
|
||||
export interface CloudProviderToken {
|
||||
token: string;
|
||||
expiresAt?: number; // timestamp in ms; if missing, token is treated as valid (the downstream may clear it eventually)
|
||||
}
|
||||
|
||||
function _getUnexpiredToken(stored: CloudProviderToken | null): string | undefined {
|
||||
if (!stored?.token) return undefined;
|
||||
// if expiresAt is set and expired (with 60s safety margin), return undefined
|
||||
if (stored.expiresAt && Date.now() > stored.expiresAt - 60 * 1000) return undefined;
|
||||
return stored.token;
|
||||
}
|
||||
|
||||
|
||||
// --- In-memory token storage ---
|
||||
|
||||
let _inMemoryToken: CloudProviderToken | null = null;
|
||||
|
||||
const _inMemoryTokenStorage: ICloudProviderTokenAccessor = {
|
||||
get: () => _inMemoryToken,
|
||||
set: (value: CloudProviderToken | null) => _inMemoryToken = value,
|
||||
};
|
||||
|
||||
|
||||
type _OauthResponseEvent = {
|
||||
detail?: {
|
||||
access_token: string; // xxxx.yyyyy....
|
||||
expires_in?: string | number; // 3599
|
||||
// scope?: string; // 'https://www.googleapis.com/auth/drive.file'
|
||||
// token_type?: string; // 'Bearer'
|
||||
};
|
||||
}
|
||||
|
||||
type _OauthErrorEvent = {
|
||||
detail?: {
|
||||
error?: string; // 'access_denied', 'popup_closed_by_user', ...
|
||||
} | {
|
||||
type?: string; // 'popup_closed'
|
||||
// message?: string; // 'Popup window closed'
|
||||
// stack?: string;
|
||||
} | object;
|
||||
}
|
||||
|
||||
export function useGoogleDrivePicker(
|
||||
onCloudFileSelected: (cloudFile: AttachmentStoreCloudInput) => void,
|
||||
isMobile: boolean,
|
||||
tokenStorage: ICloudProviderTokenAccessor = _inMemoryTokenStorage,
|
||||
loginHint?: string,
|
||||
) {
|
||||
|
||||
// state
|
||||
const [isPickerOpen, setIsPickerOpen] = React.useState(false);
|
||||
|
||||
|
||||
const openGoogleDrivePicker = React.useCallback(() => setIsPickerOpen(true), []);
|
||||
|
||||
|
||||
const handleDeauthClick = React.useCallback(() => {
|
||||
setIsPickerOpen(false);
|
||||
tokenStorage.set(null);
|
||||
}, [tokenStorage]);
|
||||
|
||||
|
||||
// handle oauth events, to store the token for the picker callback
|
||||
|
||||
const handleOAuthResponse = React.useCallback((e: _OauthResponseEvent) => {
|
||||
if (!e.detail?.access_token) return;
|
||||
|
||||
const expiresIn = typeof e.detail.expires_in === 'number' ? e.detail.expires_in : typeof e.detail.expires_in === 'string' ? parseInt(e.detail.expires_in, 10) : undefined;
|
||||
tokenStorage.set({
|
||||
token: e.detail.access_token,
|
||||
expiresAt: expiresIn === undefined ? undefined : Date.now() + expiresIn * 1000,
|
||||
});
|
||||
}, [tokenStorage]);
|
||||
|
||||
const handleOAuthError = React.useCallback((e: _OauthErrorEvent) => {
|
||||
setIsPickerOpen(false);
|
||||
// ignore if user closed the popup
|
||||
if (e?.detail && 'type' in e?.detail && e.detail.type === 'popup_closed') return;
|
||||
const errorMsg = e?.detail && 'error' in e?.detail && typeof e.detail.error === 'string' ? e.detail.error : undefined;
|
||||
addSnackbar({ key: 'gdrive-oauth-error', message: errorMsg === 'access_denied' ? 'Drive file access was denied' : 'Google Drive authentication failed.', type: 'issue' });
|
||||
}, []);
|
||||
|
||||
|
||||
// handler picker events
|
||||
|
||||
const handleCanceled = React.useCallback((_e: PickerCanceledEvent) => setIsPickerOpen(false), []);
|
||||
|
||||
const handlePicked = React.useCallback((e: PickerPickedEvent) => {
|
||||
setIsPickerOpen(false);
|
||||
|
||||
const docs = e.detail?.docs;
|
||||
if (!docs?.length) return;
|
||||
|
||||
// read token, just set by handleOAuthResponse
|
||||
const currentToken = _getUnexpiredToken(tokenStorage.get());
|
||||
if (!currentToken)
|
||||
return addSnackbar({ key: 'gdrive-no-token', message: 'Unable to access Google Drive.', type: 'issue' });
|
||||
|
||||
// convert picker docs to cloud file metadata for the attachment system
|
||||
const maxBytes = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
const skippedFiles: string[] = [];
|
||||
|
||||
for (const doc of docs) {
|
||||
// skip files that are too large (note: Google Workspace files report 0 bytes)
|
||||
if (MAX_FILE_SIZE_MB && doc.sizeBytes && doc.sizeBytes > maxBytes) {
|
||||
skippedFiles.push(doc.name);
|
||||
continue;
|
||||
}
|
||||
onCloudFileSelected({
|
||||
accessToken: currentToken,
|
||||
provider: 'gdrive',
|
||||
fileId: doc.id,
|
||||
mimeType: doc.mimeType,
|
||||
fileName: doc.name,
|
||||
fileSize: doc.sizeBytes,
|
||||
webViewLink: doc.url,
|
||||
});
|
||||
}
|
||||
|
||||
if (skippedFiles.length)
|
||||
addSnackbar({ key: 'gdrive-size-limit', message: `Skipped ${skippedFiles.length} file(s) over ${MAX_FILE_SIZE_MB} MB: ${skippedFiles.join(', ')}`, type: 'issue' });
|
||||
|
||||
}, [onCloudFileSelected, tokenStorage]);
|
||||
|
||||
|
||||
// memo components (close button and picker) | null
|
||||
const googleDrivePickerComponent = React.useMemo(() => !isPickerOpen || !GOOGLE_DRIVE_CLIENT_ID ? null : <>
|
||||
|
||||
{/* Top-level close button - portaled to body, above the Google Drive picker */}
|
||||
{createPortal(
|
||||
<TooltipOutlined title='Close and Switch Google Drive Account' placement='bottom'>
|
||||
<IconButton
|
||||
onClick={handleDeauthClick}
|
||||
sx={{
|
||||
'--IconButton-size': '2.75rem',
|
||||
|
||||
backgroundColor: 'background.popup',
|
||||
borderRadius: '50%',
|
||||
boxShadow: 'lg',
|
||||
|
||||
position: 'fixed',
|
||||
top: '1rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 2002, // above the Drive Picker (2001+)
|
||||
}}
|
||||
>
|
||||
<LogoutIcon />
|
||||
</IconButton>
|
||||
</TooltipOutlined>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
|
||||
<DrivePicker
|
||||
app-id={GOOGLE_DRIVE_CLIENT_ID.split('-')[0] || ''}
|
||||
client-id={GOOGLE_DRIVE_CLIENT_ID}
|
||||
title='Attach files from Google Drive'
|
||||
multiselect={true}
|
||||
hide-title-bar='true'
|
||||
// nav-hidden={true /* disables the 'Google Drive' nav */}
|
||||
// mine-only={true}
|
||||
login-hint={loginHint}
|
||||
max-items={MAX_PICKER_FILES || undefined}
|
||||
oauth-token={_getUnexpiredToken(tokenStorage.get())}
|
||||
onOauthResponse={handleOAuthResponse}
|
||||
onOauthError={handleOAuthError}
|
||||
onPicked={handlePicked}
|
||||
onCanceled={handleCanceled}
|
||||
>
|
||||
|
||||
<DrivePickerDocsView
|
||||
// file-ids='id1,id2,id3'
|
||||
// include-folders='default'
|
||||
// mime-types=
|
||||
mode={isMobile ? 'LIST' : undefined /* LIST, GRID - if set hides the switch */}
|
||||
// owned-by-me='default'
|
||||
// select-folder-enabled='default' // does not work, while the one in DrivePicker does
|
||||
// starred=
|
||||
/>
|
||||
|
||||
</DrivePicker>
|
||||
</>, [handleCanceled, handleDeauthClick, handleOAuthError, handleOAuthResponse, handlePicked, isMobile, isPickerOpen, loginHint, tokenStorage]);
|
||||
|
||||
return {
|
||||
openGoogleDrivePicker,
|
||||
googleDrivePickerComponent,
|
||||
};
|
||||
}
|
||||
@@ -227,8 +227,9 @@ export class ConversationHandler {
|
||||
return _chatStoreActions.historyView(this.conversationId)?.find(m => m.id === messageId);
|
||||
}
|
||||
|
||||
historyKeepLastThinkingOnly(): void {
|
||||
return _chatStoreActions.historyKeepLastThinkingOnly(this.conversationId);
|
||||
/** Strips thinking fragments from assistant messages, preserving `keepCount` most recent (0 = discard all, 1 = keep last only). */
|
||||
historyStripThinking(keepCount: number): void {
|
||||
return _chatStoreActions.historyStripThinking(this.conversationId, keepCount);
|
||||
}
|
||||
|
||||
title(): string | undefined {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { DBlobAssetId } from '~/common/stores/blob/dblobs-portability';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { collectFragmentAssetIds, gcRegisterAssetCollector } from '~/common/stores/chat/chat.gc';
|
||||
|
||||
import { ConversationHandler } from './ConversationHandler';
|
||||
|
||||
@@ -14,6 +16,40 @@ export class ConversationsManager {
|
||||
private static _instance: ConversationsManager;
|
||||
private readonly handlers: Map<DConversationId, ConversationHandler> = new Map();
|
||||
|
||||
private constructor() {
|
||||
// Register a GC collector to protect DBlob assets referenced in active Beam stores.
|
||||
// Uses inversion of control to avoid circular dependency (chat/ -> chat-overlay/).
|
||||
gcRegisterAssetCollector(() => this._collectBeamAssetIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect DBlob asset IDs from all active Beam stores (rays, fusions, follow-ups).
|
||||
*/
|
||||
private _collectBeamAssetIds(): DBlobAssetId[] {
|
||||
const assetIds = new Set<DBlobAssetId>();
|
||||
for (const handler of this.handlers.values()) {
|
||||
const { rays, fusions } = handler.getBeamStore().getState();
|
||||
|
||||
// Scatter rays + their follow-up messages
|
||||
for (const ray of rays) {
|
||||
collectFragmentAssetIds(ray.message.fragments, assetIds);
|
||||
// if (ray.followUpMessages)
|
||||
// for (const msg of ray.followUpMessages)
|
||||
// collectFragmentAssetIds(msg.fragments, assetIds);
|
||||
}
|
||||
|
||||
// Gather fusions + their follow-up messages
|
||||
for (const fusion of fusions) {
|
||||
if (fusion.outputDMessage)
|
||||
collectFragmentAssetIds(fusion.outputDMessage.fragments, assetIds);
|
||||
// if (fusion.followUpMessages)
|
||||
// for (const msg of fusion.followUpMessages)
|
||||
// collectFragmentAssetIds(msg.fragments, assetIds);
|
||||
}
|
||||
}
|
||||
return Array.from(assetIds);
|
||||
}
|
||||
|
||||
static getHandler(conversationId: DConversationId): ConversationHandler {
|
||||
const instance = ConversationsManager._instance || (ConversationsManager._instance = new ConversationsManager());
|
||||
let handler = instance.handlers.get(conversationId);
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Breadcrumbs, Typography } from '@mui/joy';
|
||||
import { Breadcrumbs, BreadcrumbsSlotsAndSlotProps, Typography } from '@mui/joy';
|
||||
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
|
||||
const _sx = { p: 0 };
|
||||
const _breadcrumbSlotProps: BreadcrumbsSlotsAndSlotProps['slotProps'] = {
|
||||
root: {
|
||||
sx: { p: 0 },
|
||||
},
|
||||
// see anatomy https://mui.com/joy-ui/react-breadcrumbs/#anatomy
|
||||
ol: {
|
||||
// keep it all in one line
|
||||
sx: { flexWrap: 'nowrap' },
|
||||
},
|
||||
li: {
|
||||
// undo the 'flex' on li, and auto-ellipsize contents
|
||||
sx: { display: 'block' },
|
||||
className: 'agi-ellipsize',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
export function AppBreadcrumbs(props: {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
@@ -23,12 +38,13 @@ export function AppBreadcrumbs(props: {
|
||||
onRootClick?.();
|
||||
}, [onRootClick]);
|
||||
|
||||
return <Breadcrumbs size={props.size || 'sm'} separator={<KeyboardArrowRightIcon />} aria-label='breadcrumbs' sx={_sx}>
|
||||
{(props.children && !!rootTitle && !!onRootClick)
|
||||
? <AppBreadcrumbs.Link color='neutral' href='#' onClick={handleRootClick}>{props.rootTitle}</AppBreadcrumbs.Link>
|
||||
return <Breadcrumbs size={props.size || 'sm'} aria-label='breadcrumbs' separator={<KeyboardArrowRightIcon />} slotProps={_breadcrumbSlotProps}>
|
||||
{/* Title */}
|
||||
{(props.children && !!rootTitle && !!onRootClick) ? <AppBreadcrumbs.Link color='neutral' href='#' onClick={handleRootClick}>{props.rootTitle}</AppBreadcrumbs.Link>
|
||||
: (typeof props.rootTitle === 'string') ? <Typography>{props.rootTitle}</Typography>
|
||||
: props.rootTitle
|
||||
}
|
||||
{/* Rest */}
|
||||
{props.children}
|
||||
{/*{nav.pnt === 'create-new' && <Link color='neutral' href='#'>Create New</Link>}*/}
|
||||
{/*{['Characters', 'Futurama', 'TV Shows', 'Home'].map((item: string) => (*/}
|
||||
|
||||
@@ -11,6 +11,19 @@ const Popup = styled(Popper)({
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Use this for submenus on any Menu/Popup, to prevent the parent popup from closing when clicking on this item. e.g.
|
||||
* <MenuItem onClick={joyKeepPopup(() => setShowModelsHidden(!showModelsHidden))}> ...
|
||||
*/
|
||||
export function joyKeepPopup<TEvent extends React.MouseEvent>(fn: (event: TEvent) => void) {
|
||||
return (event: TEvent) => {
|
||||
// the key to not close the popup when activating this menu item - REV ENG
|
||||
(event as any).defaultMuiPrevented = true;
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Workaround to the Menu in Joy 5-beta.0.
|
||||
*
|
||||
@@ -93,6 +106,8 @@ export function CloseablePopup(props: {
|
||||
},
|
||||
}], [props.placementOffset]);
|
||||
|
||||
const popperMemoSx: undefined | SxProps = React.useMemo(() => !props.zIndex ? undefined : ({ zIndex: props.zIndex }), [props.zIndex]);
|
||||
|
||||
const styleMemoSx: SxProps = React.useMemo(() => ({
|
||||
|
||||
// style
|
||||
@@ -120,7 +135,6 @@ export function CloseablePopup(props: {
|
||||
|
||||
}), [props.boxShadow, props.maxHeightGapPx, props.maxWidth, props.minWidth, props.size, props.dense, props.bigIcons, props.noBottomPadding, props.noTopPadding, props.sx]);
|
||||
|
||||
|
||||
return (
|
||||
<Popup
|
||||
role={undefined}
|
||||
@@ -129,7 +143,7 @@ export function CloseablePopup(props: {
|
||||
placement={props.placement}
|
||||
disablePortal={false}
|
||||
modifiers={modifiersMemo}
|
||||
sx={props.zIndex ? { zIndex: props.zIndex } : undefined}
|
||||
sx={popperMemoSx}
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
{props.menu ? (
|
||||
|
||||
@@ -20,7 +20,7 @@ const BoxCollapsee = styled(Box)({
|
||||
|
||||
export function ExpanderControlledBox(props: { expanded: boolean, children: React.ReactNode, sx?: SxProps }) {
|
||||
return (
|
||||
<BoxCollapser aria-expanded={props.expanded} sx={props.sx}>
|
||||
<BoxCollapser aria-expanded={props.expanded} data-agi-no-copy={!props.expanded || undefined} sx={props.sx}>
|
||||
<BoxCollapsee>
|
||||
{props.children}
|
||||
</BoxCollapsee>
|
||||
|
||||
@@ -40,6 +40,8 @@ export function InlineTextarea(props: {
|
||||
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.nativeEvent.isComposing)
|
||||
return;
|
||||
const shiftOrAlt = e.shiftKey || e.altKey;
|
||||
if (enterIsNewline ? shiftOrAlt : !shiftOrAlt) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { ListItem, ListItemButton } from '@mui/joy';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
|
||||
|
||||
const _styles = {
|
||||
headerButton: {
|
||||
color: 'text.tertiary',
|
||||
py: 1.5,
|
||||
border: 'none',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'md',
|
||||
justifyContent: 'space-between',
|
||||
'&[aria-expanded="false"]': {
|
||||
fontWeight: 'lg',
|
||||
fontStyle: 'italic',
|
||||
color: 'text.primary',
|
||||
},
|
||||
},
|
||||
headerDeco: {
|
||||
width: '1rem',
|
||||
},
|
||||
headerCollapser: {
|
||||
fontSize: 'md',
|
||||
transition: 'transform 0.14s',
|
||||
'[aria-expanded="false"] &': {
|
||||
transform: 'rotate(-180deg)',
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, SxProps>;
|
||||
|
||||
|
||||
export function ListItemGroupCollapser<TId extends string>(props: {
|
||||
id: TId;
|
||||
label: string;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: (id: TId) => void;
|
||||
}) {
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemButton color='neutral' aria-expanded={!props.isCollapsed} onClick={() => props.onToggleCollapse(props.id)} sx={_styles.headerButton}>
|
||||
{/*{serviceVendor?.id && <ListItemDecorator><LLMVendorIconSprite vendorId={serviceVendor.id} /></ListItemDecorator>}*/}
|
||||
<div style={_styles.headerDeco} />
|
||||
<div>{props.label}</div>
|
||||
<ExpandMoreIcon sx={_styles.headerCollapser} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export function FormTextField(props: {
|
||||
tooltip?: string | React.JSX.Element,
|
||||
placeholder?: string, isError?: boolean, disabled?: boolean,
|
||||
value: string | undefined, onChange: (text: string) => void,
|
||||
endDecorator?: React.ReactNode,
|
||||
inputSx?: SxProps,
|
||||
}) {
|
||||
const acId = 'text-' + props.autoCompleteId;
|
||||
@@ -45,6 +46,7 @@ export function FormTextField(props: {
|
||||
autoComplete='off'
|
||||
variant='outlined' placeholder={props.placeholder} error={props.isError}
|
||||
value={props.value} onChange={event => props.onChange(event.target.value)}
|
||||
endDecorator={props.endDecorator}
|
||||
sx={props.inputSx ?? _styles.inputDefault}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FormSwitchControl } from './FormSwitchControl';
|
||||
import { Box, FormControl, Switch, Tooltip } from '@mui/joy';
|
||||
|
||||
import { FormLabelStart } from './FormLabelStart';
|
||||
|
||||
|
||||
/**
|
||||
* Reusable toggle for enabling client-side API fetch.
|
||||
* Appears with animation when client key is present.
|
||||
* Shows a tooltip recommendation when local host is detected but CSF is off.
|
||||
*/
|
||||
export function SetupFormClientSideToggle(props: {
|
||||
visible: boolean;
|
||||
@@ -13,8 +16,12 @@ export function SetupFormClientSideToggle(props: {
|
||||
onChange: (on: boolean) => void;
|
||||
helpText: string;
|
||||
disabled?: boolean;
|
||||
localHostDetected?: boolean; // shows a tooltip to hint at using this
|
||||
}) {
|
||||
|
||||
// show recommendation tooltip for local hosts when CSF is off
|
||||
const showLocalRecommendation = !!props.localHostDetected && !props.checked;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -24,14 +31,29 @@ export function SetupFormClientSideToggle(props: {
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<FormSwitchControl
|
||||
title='Direct Connection'
|
||||
description={props.checked ? 'Connect from browser' : 'Via server (default)'}
|
||||
tooltip={props.helpText}
|
||||
checked={props.checked}
|
||||
onChange={props.onChange}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
<FormControl orientation='horizontal' disabled={props.disabled} sx={{ flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart
|
||||
title='Direct Connection'
|
||||
description={props.checked ? 'Connect from browser' : 'Via server (default)'}
|
||||
tooltip={showLocalRecommendation ? undefined : props.helpText}
|
||||
/>
|
||||
<Tooltip
|
||||
open={showLocalRecommendation}
|
||||
disableInteractive
|
||||
arrow
|
||||
variant='solid'
|
||||
color='success'
|
||||
placement='top-end'
|
||||
title='Recommended ON for local services'
|
||||
>
|
||||
<Switch
|
||||
checked={props.checked}
|
||||
onChange={event => props.onChange(event.target.checked)}
|
||||
endDecorator={props.checked ? 'On' : 'Off'}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, FormLabel } from '@mui/joy';
|
||||
import SyncIcon from '@mui/icons-material/Sync';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
|
||||
import type { ToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
|
||||
@@ -32,7 +32,7 @@ export function SetupFormRefetchButton(props: {
|
||||
color={props.error ? 'warning' : 'primary'}
|
||||
disabled={props.disabled}
|
||||
loading={props.loading}
|
||||
endDecorator={<SyncIcon />}
|
||||
startDecorator={<RefreshIcon />}
|
||||
onClick={props.refetch}
|
||||
sx={{ minWidth: 120, ml: 'auto' }}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DLLM, DLLMId } from '~/common/stores/llms/llms.types';
|
||||
import { DLLM, DLLMId, getLLMLabel } from '~/common/stores/llms/llms.types';
|
||||
import { useLLMs } from '~/common/stores/llms/llms.hooks';
|
||||
|
||||
import type { FormRadioOption } from './FormRadioControl';
|
||||
@@ -20,8 +20,8 @@ export function useFormRadioLlmType(label: string, runModelId: DLLMId | null, in
|
||||
const hidden = !runLLM || !utilLLM || runLLM === utilLLM;
|
||||
|
||||
const options = React.useMemo((): FormRadioOption<LlmType>[] => [
|
||||
{ label: runLLM?.label ?? '[missing llm]', value: 'run' },
|
||||
{ label: utilLLM?.label ?? '[missing util llm]', value: 'util' },
|
||||
{ label: runLLM ? getLLMLabel(runLLM) : '[missing llm]', value: 'run' },
|
||||
{ label: utilLLM ? getLLMLabel(utilLLM) : '[missing util llm]', value: 'util' },
|
||||
], [runLLM, utilLLM]);
|
||||
|
||||
const [llmType, component] = useFormRadio<LlmType>(initialModelType, options, label, hidden);
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Chip, ColorPaletteProp, FormControl, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Option, Select, SelectSlotsAndSlotProps, SvgIconProps, VariantProp, optionClasses } from '@mui/joy';
|
||||
import { Chip, ColorPaletteProp, FormControl, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Option, optionClasses, Select, SelectSlotsAndSlotProps, VariantProp } from '@mui/joy';
|
||||
import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded';
|
||||
import AutoModeIcon from '@mui/icons-material/AutoMode';
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
|
||||
|
||||
import type { IModelVendor } from '~/modules/llms/vendors/IModelVendor';
|
||||
import { LLMVendorIconSprite } from '~/modules/llms/components/LLMVendorIconSprite';
|
||||
import { findModelVendor } from '~/modules/llms/vendors/vendors.registry';
|
||||
import { llmsGetVendorIcon, LLMVendorIcon } from '~/modules/llms/components/LLMVendorIcon';
|
||||
|
||||
import type { DModelDomainId } from '~/common/stores/llms/model.domains.types';
|
||||
import { DLLM, DLLMId, getLLMPricing, LLM_IF_OAI_Reasoning, LLM_IF_Outputs_Audio, LLM_IF_Outputs_Image, LLM_IF_Tools_WebSearch } from '~/common/stores/llms/llms.types';
|
||||
import type { DModelsServiceId } from '~/common/stores/llms/llms.service.types';
|
||||
import { isLLMChatFree_cached } from '~/common/stores/llms/llms.pricing';
|
||||
import { DLLM, DLLMId, getLLMLabel, LLM_IF_OAI_Reasoning, LLM_IF_Outputs_Audio, LLM_IF_Outputs_Image, LLM_IF_Tools_WebSearch } from '~/common/stores/llms/llms.types';
|
||||
import { ListItemGroupCollapser } from '~/common/components/ListItemGroupCollapser';
|
||||
import { PhGearSixIcon } from '~/common/components/icons/phosphor/PhGearSixIcon';
|
||||
import { StarIconUnstyled, StarredNoXL2 } from '~/common/components/StarIcons';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
import { getChatLLMId, llmsStoreActions } from '~/common/stores/llms/store-llms';
|
||||
import { findModelsServiceOrNull, getChatLLMId, llmsStoreActions } from '~/common/stores/llms/store-llms';
|
||||
import { optimaActions, optimaOpenModels } from '~/common/layout/optima/useOptima';
|
||||
import { useToggleableStringSet } from '~/common/util/hooks/useToggleableStringSet';
|
||||
import { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
import { useVisibleLLMs } from '~/common/stores/llms/llms.hooks';
|
||||
|
||||
@@ -69,13 +72,6 @@ const _styles = {
|
||||
backgroundColor: 'background.surface',
|
||||
zIndex: 1,
|
||||
},
|
||||
listVendor: {
|
||||
// see OptimaBarDropdown's _styles.separator
|
||||
fontSize: 'sm',
|
||||
color: 'text.tertiary',
|
||||
textAlign: 'center',
|
||||
my: 0.75,
|
||||
},
|
||||
listConfSep: {
|
||||
mb: 0,
|
||||
},
|
||||
@@ -84,7 +80,7 @@ const _styles = {
|
||||
},
|
||||
} as const satisfies Record<string, SxProps>;
|
||||
|
||||
const _slotProps: SelectSlotsAndSlotProps<false>['slotProps'] = {
|
||||
const _slotProps = {
|
||||
// see the OptimaBarDropdown.listbox for a well made customization (max-height, max-width, etc.)
|
||||
listbox: {
|
||||
sx: {
|
||||
@@ -127,7 +123,7 @@ const _slotProps: SelectSlotsAndSlotProps<false>['slotProps'] = {
|
||||
minWidth: '6rem',
|
||||
} as const,
|
||||
} as const,
|
||||
} as const;
|
||||
} as const satisfies SelectSlotsAndSlotProps<false>['slotProps'];
|
||||
|
||||
|
||||
interface LLMSelectOptions {
|
||||
@@ -155,13 +151,14 @@ export function useLLMSelect(
|
||||
llmId: undefined | DLLMId | null, // undefined: not set at all, null: has the meaning of no-llm-wanted here
|
||||
setLlmId: (llmId: DLLMId | null) => void,
|
||||
options: LLMSelectOptions,
|
||||
): [DLLM | null, React.JSX.Element | null, React.FunctionComponent<SvgIconProps> | undefined] {
|
||||
): [DLLM | null, React.JSX.Element | null] {
|
||||
|
||||
// options
|
||||
const { label, larger = false, disabled = false, placeholder = LLM_TEXT_PLACEHOLDER, isHorizontal = false, autoRefreshDomain, appendConfigureModels = false, showStarFilter = false } = options;
|
||||
|
||||
// state
|
||||
const [controlledOpen, setControlledOpen] = React.useState(false);
|
||||
const { set: collapsedServices, toggle: toggleServiceCollapse } = useToggleableStringSet<DModelsServiceId>();
|
||||
|
||||
// external state
|
||||
const starredOnly = useUIPreferencesStore(state => showStarFilter && state.showModelsStarredOnly);
|
||||
@@ -174,34 +171,72 @@ export function useLLMSelect(
|
||||
const isReasoning = !LLM_SELECT_SHOW_REASONING_ICON ? false : llm?.interfaces?.includes(LLM_IF_OAI_Reasoning) ?? false;
|
||||
|
||||
|
||||
// Scroll preservation: MUI's useSelect auto-scrolls to highlighted item when options change - we want to preserve scroll instead
|
||||
|
||||
const listboxRef = React.useRef<HTMLUListElement>(null);
|
||||
|
||||
const listboxSlotPropsStable = React.useMemo(() => ({
|
||||
..._slotProps,
|
||||
listbox: { ..._slotProps.listbox, ref: listboxRef },
|
||||
}), []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// restore scroll after collapse/expand - snapshot before MUI scrolls, restore via double RAF
|
||||
const el = listboxRef.current;
|
||||
if (!el) return;
|
||||
const scrollTop = el.scrollTop;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
// usually works, especially on expansion
|
||||
el.scrollTop = scrollTop;
|
||||
return requestAnimationFrame(() => {
|
||||
// fixes the collapse too
|
||||
el.scrollTop = scrollTop;
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [collapsedServices]);
|
||||
|
||||
|
||||
// memo LLM Options
|
||||
|
||||
const optimizeToSingleVisibleId = (!controlledOpen && _filteredLLMs.length > LLM_SELECT_REDUCE_OPTIONS) ? llmId : null; // id to keep visible when optimizing
|
||||
|
||||
const optionsArray = React.useMemo(() => {
|
||||
// check if we have multiple services (to show collapsible headers)
|
||||
const hasMultipleServices = _filteredLLMs.some((llm, i, arr) => i > 0 && llm.sId !== arr[i - 1].sId);
|
||||
|
||||
// create the option items
|
||||
let formerVendor: IModelVendor | null = null;
|
||||
let prevServiceId: DModelsServiceId | null = null;
|
||||
return _filteredLLMs.reduce((acc, llm, _index) => {
|
||||
|
||||
if (optimizeToSingleVisibleId && llm.id !== optimizeToSingleVisibleId)
|
||||
return acc;
|
||||
|
||||
const vendor = findModelVendor(llm.vId);
|
||||
const vendorChanged = vendor !== formerVendor;
|
||||
if (vendorChanged)
|
||||
formerVendor = vendor;
|
||||
const serviceVendor = findModelVendor(llm.vId);
|
||||
const isServiceCollapsed = hasMultipleServices && collapsedServices.has(llm.sId);
|
||||
|
||||
// add separators if the vendor changed (and more than one vendor)
|
||||
const addSeparator = vendorChanged && formerVendor !== null;
|
||||
if (addSeparator && !optimizeToSingleVisibleId)
|
||||
acc.push(<Box key={'llm-sep-' + llm.id} sx={_styles.listVendor}>{vendor?.name}</Box>);
|
||||
// add collapsible service headers when changing services
|
||||
if (hasMultipleServices && llm.sId !== prevServiceId) {
|
||||
if (!optimizeToSingleVisibleId) {
|
||||
const serviceLabel = findModelsServiceOrNull(llm.sId)?.label || serviceVendor?.name || llm.sId;
|
||||
acc.push(<ListItemGroupCollapser key={'SID-' + llm.sId} id={llm.sId} label={serviceLabel} isCollapsed={isServiceCollapsed} onToggleCollapse={toggleServiceCollapse} />);
|
||||
}
|
||||
prevServiceId = llm.sId;
|
||||
}
|
||||
|
||||
// skip models if service is collapsed (but always show selected model)
|
||||
if (isServiceCollapsed && llm.id !== llmId)
|
||||
return acc;
|
||||
|
||||
let features = '';
|
||||
const isNotSymlink = !llm.label.startsWith('🔗');
|
||||
const seemsFree = !!getLLMPricing(llm)?.chat?._isFree;
|
||||
const isNotSymlink = !llm.label.startsWith('🔗'); // getLLMLabel exception: need access to the base
|
||||
const llmLabel = getLLMLabel(llm);
|
||||
const seemsFree = isLLMChatFree_cached(llm);
|
||||
if (isNotSymlink) {
|
||||
// check features
|
||||
if (seemsFree) features += 'free ';
|
||||
if (llm.isUserClone)
|
||||
features += '➕ '; // is clone
|
||||
if (llm.interfaces.includes(LLM_IF_OAI_Reasoning))
|
||||
features += '🧠 '; // can reason
|
||||
if (llm.interfaces.includes(LLM_IF_Tools_WebSearch))
|
||||
@@ -221,16 +256,16 @@ export function useLLMSelect(
|
||||
value={llm.id}
|
||||
// Disabled to avoid regenerating the memo too frequently
|
||||
// sx={llm.id === llmId ? { fontWeight: 'md' } : undefined}
|
||||
label={llm.label}
|
||||
label={llmLabel}
|
||||
>
|
||||
{!noIcons && (
|
||||
<ListItemDecorator>
|
||||
{(llm.userStarred && !starredOnly) ? <StarredNoXL2 /> : vendor?.id ? <LLMVendorIcon vendorId={vendor.id} /> : null}
|
||||
{(llm.userStarred && !starredOnly) ? <StarredNoXL2 /> : serviceVendor?.id ? <LLMVendorIconSprite vendorId={serviceVendor.id} /> : null}
|
||||
</ListItemDecorator>
|
||||
)}
|
||||
{/*<Tooltip title={llm.description}>*/}
|
||||
|
||||
<div className='agi-ellipsize'>{llm.label}</div>
|
||||
<div className='agi-ellipsize'>{llmLabel}</div>
|
||||
|
||||
{/* Features Chips - sync with `ModelsList.tsx` */}
|
||||
{!!features && !showModelOptions && <Chip size='sm' color={seemsFree ? 'success' : undefined} variant='plain' sx={_styles.chips}>{features.trim().replace(' ', ' ')}</Chip>}
|
||||
@@ -244,7 +279,7 @@ export function useLLMSelect(
|
||||
// variant='outlined'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
optimaActions().openModelOptions(llm.id);
|
||||
optimaActions().openModelOptions(llm.id, 'parameters');
|
||||
}}
|
||||
sx={_styles.configButton}
|
||||
>
|
||||
@@ -260,7 +295,7 @@ export function useLLMSelect(
|
||||
|
||||
return acc;
|
||||
}, [] as React.JSX.Element[]);
|
||||
}, [_filteredLLMs, llmId, noIcons, optimizeToSingleVisibleId, starredOnly]);
|
||||
}, [_filteredLLMs, collapsedServices, llmId, noIcons, optimizeToSingleVisibleId, starredOnly, toggleServiceCollapse]);
|
||||
|
||||
|
||||
const onSelectChange = React.useCallback((_event: unknown, value: DLLMId | null) => {
|
||||
@@ -289,7 +324,7 @@ export function useLLMSelect(
|
||||
listboxOpen={controlledOpen}
|
||||
onListboxOpenChange={hasNoModels ? optimaOpenModels : setControlledOpen}
|
||||
placeholder={hasNoModels ? LLM_TEXT_CONFIGURE : placeholder}
|
||||
slotProps={_slotProps}
|
||||
slotProps={listboxSlotPropsStable}
|
||||
endDecorator={autoRefreshDomain ?
|
||||
<TooltipOutlined title='Auto-select the model'>
|
||||
<IconButton onClick={() => llmsStoreActions().assignDomainModelId(autoRefreshDomain, null)}>
|
||||
@@ -332,12 +367,7 @@ export function useLLMSelect(
|
||||
</Select>
|
||||
{/*</Box>*/}
|
||||
</FormControl>
|
||||
), [appendConfigureModels, autoRefreshDomain, controlledOpen, disabled, hasNoModels, hasStarred, isHorizontal, isReasoning, label, larger, llmId, onSelectChange, optimizeToSingleVisibleId, options.color, options.sx, options.variant, optionsArray, placeholder, showNoOptions, showStarFilter, starredOnly]);
|
||||
), [appendConfigureModels, autoRefreshDomain, controlledOpen, disabled, hasNoModels, hasStarred, isHorizontal, isReasoning, label, larger, listboxSlotPropsStable, llmId, onSelectChange, optimizeToSingleVisibleId, options.color, options.sx, options.variant, optionsArray, placeholder, showNoOptions, showStarFilter, starredOnly]);
|
||||
|
||||
// Memo the vendor icon for the chat LLM
|
||||
const chatLLMVendorIconFC = React.useMemo(() => {
|
||||
return !llm?.vId ? undefined : llmsGetVendorIcon(llm.vId);
|
||||
}, [llm?.vId]);
|
||||
|
||||
return [llm, llmSelectComponent, chatLLMVendorIconFC];
|
||||
return [llm, llmSelectComponent];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { SvgIcon, SvgIconProps } from '@mui/joy';
|
||||
|
||||
export function BedrockIcon(props: SvgIconProps) {
|
||||
return <SvgIcon viewBox='0 0 109 64' width='24' height='24' stroke='none' {...props}>
|
||||
<path d='M30.63 23.243c0 1.317.144 2.385.398 3.168.289.783.65 1.637 1.156 2.563.18.284.253.569.253.818 0 .356-.217.712-.687 1.068l-2.277 1.495c-.325.214-.65.32-.94.32-.361 0-.723-.178-1.084-.498a11.036 11.036 0 0 1-1.301-1.673 27.44 27.44 0 0 1-1.12-2.1c-2.82 3.275-6.362 4.912-10.627 4.912-3.037 0-5.458-.854-7.23-2.563-1.77-1.708-2.674-3.986-2.674-6.834 0-3.025 1.084-5.481 3.29-7.332 2.204-1.851 5.132-2.777 8.855-2.777 1.229 0 2.494.107 3.831.285 1.337.178 2.71.463 4.157.783V12.28c0-2.705-.579-4.592-1.7-5.695-1.156-1.104-3.108-1.638-5.89-1.638-1.266 0-2.567.143-3.904.463-1.338.32-2.64.712-3.904 1.21-.578.25-1.012.392-1.265.463-.253.071-.434.107-.579.107-.506 0-.759-.356-.759-1.104V4.342c0-.57.073-.996.253-1.246.181-.249.506-.498 1.012-.747 1.266-.64 2.784-1.175 4.555-1.602 1.77-.462 3.65-.676 5.638-.676 4.302 0 7.446.961 9.47 2.883 1.989 1.922 3 4.84 3 8.756v11.533h.073Zm-14.675 5.41c1.193 0 2.422-.213 3.723-.64 1.301-.428 2.458-1.21 3.434-2.279.578-.676 1.012-1.423 1.229-2.278.216-.854.361-1.886.361-3.096v-1.495a30.626 30.626 0 0 0-3.325-.605 27.649 27.649 0 0 0-3.398-.214c-2.422 0-4.193.463-5.386 1.424-1.192.96-1.77 2.313-1.77 4.093 0 1.673.433 2.919 1.337 3.773.867.89 2.132 1.317 3.795 1.317Zm29.024 3.844c-.65 0-1.084-.106-1.373-.356-.29-.213-.542-.711-.759-1.388L34.353 3.24c-.217-.712-.325-1.175-.325-1.424 0-.57.289-.89.867-.89h3.542c.687 0 1.157.107 1.41.356.29.214.506.712.723 1.388l6.072 23.564 5.639-23.564c.18-.712.398-1.174.687-1.388.289-.213.795-.356 1.445-.356h2.892c.687 0 1.157.107 1.446.356.289.214.542.712.687 1.388l5.71 23.849L71.403 2.67c.217-.712.47-1.174.723-1.388.289-.213.759-.356 1.41-.356h3.36c.58 0 .904.285.904.89 0 .178-.036.356-.072.57a4.998 4.998 0 0 1-.253.89l-8.71 27.514c-.218.712-.47 1.174-.76 1.388-.29.214-.759.356-1.374.356h-3.108c-.687 0-1.157-.107-1.446-.356-.289-.25-.542-.712-.687-1.424L55.787 7.795l-5.566 22.923c-.181.712-.398 1.174-.687 1.423-.29.25-.795.356-1.446.356H44.98Zm46.447.961c-1.88 0-3.759-.213-5.566-.64-1.807-.427-3.217-.89-4.157-1.424-.578-.32-.976-.676-1.12-.997a2.48 2.48 0 0 1-.217-.996v-1.816c0-.747.289-1.103.831-1.103.217 0 .434.035.65.107.218.07.543.213.904.356 1.23.534 2.567.96 3.976 1.245 1.446.285 2.856.427 4.302.427 2.277 0 4.048-.391 5.277-1.174 1.229-.783 1.88-1.922 1.88-3.382 0-.996-.326-1.815-.977-2.491-.65-.676-1.88-1.282-3.65-1.851l-5.241-1.602c-2.639-.818-4.59-2.029-5.784-3.63-1.192-1.566-1.807-3.31-1.807-5.162 0-1.495.325-2.811.976-3.95a9.196 9.196 0 0 1 2.603-2.92c1.084-.818 2.313-1.423 3.759-1.85C89.51.178 91.029 0 92.619 0c.795 0 1.627.035 2.422.142.831.107 1.59.25 2.35.392.722.178 1.409.356 2.06.57.65.213 1.156.426 1.518.64.506.285.867.57 1.084.89.217.284.325.676.325 1.174v1.673c0 .748-.289 1.14-.831 1.14-.289 0-.759-.143-1.374-.428-2.06-.925-4.373-1.388-6.94-1.388-2.06 0-3.686.32-4.807.997-1.12.676-1.699 1.708-1.699 3.168 0 .996.362 1.85 1.085 2.527.723.676 2.06 1.352 3.976 1.957l5.132 1.602c2.603.819 4.482 1.958 5.603 3.417 1.12 1.46 1.662 3.133 1.662 4.983 0 1.53-.325 2.92-.939 4.13-.651 1.21-1.518 2.277-2.639 3.132-1.12.89-2.458 1.53-4.012 1.993-1.627.498-3.325.747-5.169.747Z' fill='currentColor' />
|
||||
<path d='M98.254 50.76C86.363 59.408 69.085 64 54.23 64 33.41 64 14.65 56.42.481 43.82c-1.12-.997-.108-2.35 1.23-1.567 15.325 8.756 34.229 14.06 53.784 14.06 13.193 0 27.687-2.705 41.024-8.258 1.988-.89 3.687 1.282 1.735 2.705Z' fill='currentColor' />
|
||||
<path d='M103.199 45.204c-1.519-1.922-10.049-.925-13.916-.463-1.157.143-1.338-.854-.29-1.601 6.796-4.699 17.965-3.346 19.266-1.78 1.301 1.602-.362 12.6-6.723 17.868-.976.819-1.916.392-1.482-.676 1.446-3.524 4.663-11.461 3.145-13.348Z' fill='currentColor' />
|
||||
</SvgIcon>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { SvgIcon, SvgIconProps } from '@mui/joy';
|
||||
|
||||
export function ClaudeCrabIcon(props: SvgIconProps) {
|
||||
return <SvgIcon viewBox='0 0 24 24' width='24' height='24' stroke='none' fill='none' {...props} sx={{ shapeRendering: 'crispEdges', ...props.sx }}>
|
||||
<path d='M4 4 h16 v4 h4 v4 h-4 v8 h-2 v-4 h-2 v4 h-2 v-4 h-4 v4 h-2 v-4 h-2 v4 h-2 v-8 h-4 v-4 h4 z' fill='#F01D1D' />
|
||||
<rect x='6' y='6' width='2' height='2' fill='#000000' />
|
||||
<rect x='16' y='6' width='2' height='2' fill='#000000' />
|
||||
</SvgIcon>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { SvgIcon, SvgIconProps } from '@mui/joy';
|
||||
|
||||
export function InworldIcon(props: SvgIconProps) {
|
||||
return <SvgIcon viewBox='0 0 141 181' width='24' height='24' fill='currentColor' {...props}>
|
||||
<path d='M48.2616 34.7993C47.9981 34.8585 47.9766 34.6058 48.1379 34.5144C53.3155 31.4874 60.6866 30.767 61.235 30.353C61.7297 29.6594 57.864 29.697 56.5199 29.7024C46.74 30.0411 38.2989 33.681 31.288 40.2941C20.5242 49.4664 16.9973 64.5582 18.4866 78.1285C20.2285 92.1611 27.9384 105.344 39.385 113.635C63.2405 129.673 96.9242 122.011 114.774 100.183C136.753 73.537 130.194 35.6004 107.215 18.2613C101.252 14.1214 94.7359 10.8524 87.7035 8.98143C84.4238 8.00828 79.8592 7.07277 77.7624 7.00288C74.5472 6.90073 75.6171 9.54596 75.0472 10.1965C74.4773 10.8471 68.5901 11.1105 75.2515 13.4493C79.1925 14.8311 76.7946 14.9117 73.7891 17.6322C71.1278 20.0409 73.4504 22.7399 72.531 23.6861C68.5847 27.7508 74.6279 29.5358 77.7462 31.31C92.8542 39.3747 101.177 53.8913 98.4242 70.9831C94.5585 93.5644 60.9286 103.317 46.0464 85.462C34.5999 71.924 38.928 49.3858 55.2241 42.0361C57.4822 40.9447 62.5792 39.3532 60.3909 39.536C51.9552 40.2403 45.369 44.7297 43.2399 46.3588C43.0571 46.4986 42.8152 46.2835 42.9281 46.0846C44.4711 43.3695 53.4122 37.036 71.0041 34.8101C74.1494 34.1327 62.3157 31.6541 48.2616 34.7993Z' />
|
||||
<path d='M55.4704 170.577C56.4274 170.566 57.3791 170.706 58.2716 170.797C60.5673 170.867 63.148 170.604 65.6696 170.932C71.1536 171.244 76.6807 172.475 82.1808 172.609C85.9712 172.889 89.5896 172.717 93.4607 172.862C96.3801 173.238 99.848 173.007 102.466 173.4C104.101 173.588 105.601 173.507 107.241 173.749C111.579 174.367 115.902 174.459 120.295 174.4C121.967 174.453 123.682 174.486 125.349 174.378C127.128 174.276 129.069 174.351 130.499 173.212C131.349 172.711 131.306 171.695 131.483 170.996C131.704 170.615 132.166 170.41 132.37 170.023C132.596 169.357 132.741 168.663 132.752 167.98V167.937C132.8 165.615 131.688 163.33 130.838 161.174C129.424 158.05 127.763 155.13 125.967 152.206C123.962 149.372 122.956 145.506 119.547 143.974C113.402 141.281 107.085 141.565 100.283 140.743C87.482 139.608 73.8849 138.775 61.9867 138.614C50.8843 138.775 31.2708 139.501 21.1738 140.399C3.2378 141.996 11.5875 144.84 11.023 149.872C10.8187 151.727 8.79172 151.576 9.01753 153.394C9.39388 156.028 14.3349 159.324 16.8188 159.991C27.76 162.916 13.2435 163.351 14.8672 166.276C16.2166 168.711 22.2383 168.012 24.6954 168.98L55.4597 170.577H55.4704Z' />
|
||||
</SvgIcon>;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { SvgIcon, SvgIconProps } from '@mui/joy';
|
||||
|
||||
export function ZAIIcon(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon viewBox='0 0 30 30' width={24} height={24} fill='currentColor' strokeWidth={0} {...props}>
|
||||
<path d='M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z' />
|
||||
<polygon points='24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1' />
|
||||
<path d='M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z' />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
@@ -186,7 +186,7 @@ export function GoodModal(props: {
|
||||
},
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
// gap: { md: 1 }, // Note: let the startButton decide how to space itself
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
{props.startButton}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Data-driven shortcut catalog for documentation display in ShortcutsModal.
|
||||
* Extends ShortcutDefinition for fingerprint matching against registered shortcuts.
|
||||
*/
|
||||
import type { ShortcutDefinition } from './useGlobalShortcuts';
|
||||
|
||||
export type ShortcutCatalogItem = ShortcutDefinition & Required<Pick<ShortcutDefinition, 'description'>>;
|
||||
|
||||
export interface ShortcutCatalogCategory {
|
||||
label: string;
|
||||
items: ShortcutCatalogItem[];
|
||||
}
|
||||
|
||||
export const shortcutsCatalog: ShortcutCatalogCategory[] = [
|
||||
{
|
||||
label: 'Edit',
|
||||
items: [
|
||||
{ key: 'Enter', shift: true, description: 'Newline' },
|
||||
{ key: 'Enter', alt: true, description: 'Append (no response)' },
|
||||
{ key: 'Enter', ctrl: true, description: 'Beam (and start all Beams)' },
|
||||
{ key: 'b', ctrl: true, shift: true, description: 'Beam last message' },
|
||||
{ key: 'z', ctrl: true, shift: true, description: 'Regenerate last message' },
|
||||
{ key: 'Backspace', ctrl: true, shift: true, description: 'Delete last message' },
|
||||
{ key: 'f', ctrl: true, shift: true, description: 'Attach file' },
|
||||
{ key: 'v', ctrl: true, shift: true, description: 'Attach clipboard' },
|
||||
{ key: 'l', ctrl: true, description: 'Change Model' },
|
||||
{ key: 'p', ctrl: true, description: 'Change Persona' },
|
||||
{ key: 'm', ctrl: true, description: 'Microphone (voice typing)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Chats',
|
||||
items: [
|
||||
{ key: 'n', ctrl: true, shift: true, description: 'New chat' },
|
||||
{ key: 'x', ctrl: true, shift: true, description: 'Reset chat' },
|
||||
{ key: 'd', ctrl: true, shift: true, description: 'Delete chat' },
|
||||
{ key: 'ArrowUp', ctrl: true, description: 'Previous message/Beam' },
|
||||
{ key: 'ArrowDown', ctrl: true, description: 'Next message/Beam' },
|
||||
{ key: '[', ctrl: true, description: 'Previous chat (in history)' },
|
||||
{ key: ']', ctrl: true, description: 'Next chat (in history)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
items: [
|
||||
{ key: ',', ctrl: true, description: 'Preferences' },
|
||||
{ key: 'm', ctrl: true, shift: true, description: 'Models' },
|
||||
{ key: 'o', ctrl: true, shift: true, description: 'Current Model Options' },
|
||||
{ key: 'p', ctrl: true, shift: true, description: 'Current Persona Options' },
|
||||
{ key: '+', ctrl: true, shift: true, description: 'Increase Text Size' },
|
||||
{ key: '-', ctrl: true, shift: true, description: 'Decrease Text Size' },
|
||||
{ key: 'a', ctrl: true, shift: true, description: 'AI Request Inspector' },
|
||||
{ key: '/', ctrl: true, shift: true, description: 'Shortcuts' },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -19,14 +19,18 @@ export const ShortcutKey = {
|
||||
PageDown: 'PageDown',
|
||||
};
|
||||
|
||||
export interface ShortcutObject {
|
||||
/** Base key-combo definition shared by registration and catalog. */
|
||||
export interface ShortcutDefinition {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
// altForNonMac?: boolean;
|
||||
alt?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ShortcutObject extends ShortcutDefinition {
|
||||
disabled?: boolean;
|
||||
action: (() => void) | '_specialPrintShortcuts';
|
||||
description?: string;
|
||||
endDecoratorIcon?: typeof SvgIcon;
|
||||
level?: number; // if set, it will exclusively show icons at that level of priority and hide the others
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { SelectSlotsAndSlotProps } from '@mui/joy/Select/SelectProps';
|
||||
import { Box, ListDivider, listItemButtonClasses, ListItemDecorator, listItemDecoratorClasses, Option, optionClasses, Select, selectClasses } from '@mui/joy';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
|
||||
import { ListItemGroupCollapser } from '~/common/components/ListItemGroupCollapser';
|
||||
|
||||
|
||||
// set to true to enable the dense mode, which is default in the rest of the app
|
||||
const useDenseDropdowns = false;
|
||||
@@ -138,6 +140,9 @@ function OptimaBarDropdown<TValue extends string>(props: {
|
||||
showSymbols?: boolean | 'compact',
|
||||
showGone?: boolean,
|
||||
showFaded?: boolean,
|
||||
// collapsible separators: when provided, separators become clickable toggle buttons
|
||||
collapsedSeparators?: ReadonlySet<string>,
|
||||
onSeparatorClick?: (key: string) => void,
|
||||
}, ref: React.Ref<OptimaBarControlMethods>) {
|
||||
|
||||
// state
|
||||
@@ -197,14 +202,25 @@ function OptimaBarDropdown<TValue extends string>(props: {
|
||||
const label = (props.showSymbols && _item.symbol && !(_item.title === 'Default' && _item.symbol === '🧠')) ? `${_item.symbol} ${safeTitle}` : safeTitle;
|
||||
const iconOrSymbol = _item.icon || _item.symbol || '';
|
||||
|
||||
return _item.type === 'separator' ? (
|
||||
<ListDivider key={_itemKey || `sep-${idx}`}>
|
||||
{/*<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, '--Icon-fontSize': 'var(--joy-fontSize-lg)' }}>*/}
|
||||
{/*{_item.icon} */}
|
||||
{_item.title}
|
||||
{/*</Box>*/}
|
||||
</ListDivider>
|
||||
) : (
|
||||
if (_item.type === 'separator')
|
||||
return props.onSeparatorClick ? (
|
||||
<ListItemGroupCollapser
|
||||
key={_itemKey}
|
||||
id={_itemKey}
|
||||
label={safeTitle}
|
||||
isCollapsed={!!props.collapsedSeparators?.has(_itemKey)}
|
||||
onToggleCollapse={props.onSeparatorClick}
|
||||
/>
|
||||
) : (
|
||||
<ListDivider key={_itemKey || `sep-${idx}`}>
|
||||
{/*<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, '--Icon-fontSize': 'var(--joy-fontSize-lg)' }}>*/}
|
||||
{/*{_item.icon} */}
|
||||
{_item.title}
|
||||
{/*</Box>*/}
|
||||
</ListDivider>
|
||||
);
|
||||
|
||||
return (
|
||||
<Option key={_itemKey} value={_itemKey} label={label}>
|
||||
{/* Icon / Symbol */}
|
||||
{(props.showSymbols === true || (props.showSymbols === 'compact' && !!iconOrSymbol)) && <ListItemDecorator>
|
||||
|
||||
@@ -146,13 +146,16 @@ export function MobileNavItems(props: { currentApp?: NavItemApp }) {
|
||||
Models
|
||||
</Button>
|
||||
|
||||
{/* HARDCODED: Discord */}
|
||||
<BringTheLove
|
||||
text={navItems.links[0].name}
|
||||
icon={navItems.links[0].icon}
|
||||
link={navItems.links[0].href}
|
||||
sx={_styles.button}
|
||||
/>
|
||||
{/* External links (e.g. Discord) */}
|
||||
{navItems.links.slice(0 /* take up to 1 element, which is Discord */, 1).map((link) => (
|
||||
<BringTheLove
|
||||
key={link.name}
|
||||
text={link.name}
|
||||
icon={link.icon}
|
||||
link={link.href}
|
||||
sx={_styles.button}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
</Sheet>
|
||||
|
||||
@@ -19,7 +19,7 @@ const _styles = {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
},
|
||||
accentedTagline: {
|
||||
taglineAccented: {
|
||||
textAlign: 'start',
|
||||
mt: 0.75,
|
||||
},
|
||||
@@ -37,9 +37,10 @@ const _styles = {
|
||||
export function OptimaAppPageHeading(props: {
|
||||
title: React.ReactNode;
|
||||
tagline?: React.ReactNode;
|
||||
accentedTagline?: boolean;
|
||||
taglineAccented?: boolean;
|
||||
startDecorator?: React.ReactNode;
|
||||
endDecorator?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
noDivider?: boolean;
|
||||
noMarginBottom?: boolean;
|
||||
onClick?: (event: React.MouseEvent) => void;
|
||||
@@ -50,13 +51,13 @@ export function OptimaAppPageHeading(props: {
|
||||
|
||||
return (
|
||||
<Box mb={props.noMarginBottom ? undefined : 2.25} sx={{ overflow: 'hidden', display: 'grid' }}>
|
||||
{!!props.title && <Typography level={isMobile ? 'h3' : 'h2'} startDecorator={props.startDecorator} endDecorator={props.endDecorator} sx={_styles.title}>
|
||||
{!!props.title && <Typography level={isMobile ? 'h3' : 'h2'} startDecorator={props.startDecorator} endDecorator={props.endDecorator} textColor={props.disabled ? 'neutral.plainDisabledColor' : undefined} sx={_styles.title}>
|
||||
{props.onClick
|
||||
? <Box component='span' sx={_styles.textClickable} onClick={props.onClick} className='agi-ellipsize'>{props.title}</Box>
|
||||
: <span className='agi-ellipsize'>{props.title}</span>
|
||||
}
|
||||
</Typography>}
|
||||
{!!props.tagline && <Typography level='body-sm' sx={props.accentedTagline ? _styles.accentedTagline : _styles.tagline}>
|
||||
{!!props.tagline && <Typography level='body-sm' sx={props.taglineAccented ? _styles.taglineAccented : _styles.tagline}>
|
||||
{props.tagline}
|
||||
</Typography>}
|
||||
{!props.noDivider && <ListDivider sx={_styles.divisor} />}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user