mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
340 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,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
|
||||
@@ -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,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.
|
||||
@@ -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)`);
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
"Bash(cp:*)",
|
||||
"Bash(curl:*)",
|
||||
"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 show:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(ls:*)",
|
||||
@@ -25,6 +28,7 @@
|
||||
"Bash(sed:*)",
|
||||
"Bash(tree:*)",
|
||||
"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/*"
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -51,7 +51,8 @@ jobs:
|
||||
# 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
|
||||
# former: claude-sonnet-4-5-20250929
|
||||
claude_args: |
|
||||
--model claude-sonnet-4-5-20250929
|
||||
--model claude-opus-4-5-20251101
|
||||
--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:*),mcp__chrome-devtools,SlashCommand"
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -72,6 +72,6 @@ jobs:
|
||||
# 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-5-20251101
|
||||
--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:*),mcp__chrome-devtools,SlashCommand"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -72,6 +72,6 @@ jobs:
|
||||
# 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-5-20251101
|
||||
--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"
|
||||
--allowedTools "Edit,Read,Write,WebFetch,WebSearch,Bash(cat:*),Bash(cp:*),Bash(find:*),Bash(git branch:*),Bash(grep:*),Bash(ls:*),Bash(mkdir:*),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@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
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*
|
||||
@@ -228,7 +228,7 @@ The server uses a split architecture with two tRPC routers:
|
||||
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
|
||||
- **External Services** - ElevenLabs (TTS), Inworld (TTS), Google Search, YouTube transcripts
|
||||
|
||||
Located at `/src/server/trpc/trpc.router-edge.ts`
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -43,7 +43,7 @@ It comes packed with **world-class features** like Beam, and is praised for its
|
||||
|
||||
### 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.5, 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
|
||||
|
||||
@@ -332,7 +333,7 @@ Configure 100s of AI models from 18+ providers:
|
||||
| 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/) |
|
||||
| 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)
|
||||
|
||||
+1
-3
@@ -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,4 @@ services:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
command: [ "next", "start", "-p", "3000" ]
|
||||
command: [ "next", "start", "-p", "3000" ]
|
||||
|
||||
+1
-1
@@ -43,7 +43,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.
|
||||
@@ -66,8 +66,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
|
||||
@@ -132,10 +133,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 +156,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.
|
||||
|
||||
@@ -105,7 +105,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
|
||||
|
||||
Generated
+1120
-955
File diff suppressed because it is too large
Load Diff
+21
-20
@@ -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",
|
||||
@@ -29,38 +29,39 @@
|
||||
"@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",
|
||||
"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.336.4",
|
||||
"posthog-node": "^5.24.7",
|
||||
"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,25 +72,25 @@
|
||||
"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.1.0",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react": "^19.2.10",
|
||||
"@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",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -63,8 +63,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 +199,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');
|
||||
|
||||
@@ -545,6 +547,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 +625,8 @@ export function Composer(props: {
|
||||
|
||||
const { openWebInputDialog, webInputDialogComponent } = useWebInputModal(handleAttachWebLinks, composeText);
|
||||
|
||||
const { openGoogleDrivePicker, googleDrivePickerComponent } = useGoogleDrivePicker(attachAppendCloudFile, isMobile);
|
||||
|
||||
|
||||
// Attachments Down
|
||||
|
||||
@@ -799,6 +806,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 +840,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 +1138,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } 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 = {
|
||||
@@ -315,8 +315,8 @@ export function ChatMessage(props: {
|
||||
const handleCloseOpsMenu = React.useCallback(() => setOpsMenuAnchor(null), []);
|
||||
|
||||
const handleOpsCopy = (e: React.MouseEvent) => {
|
||||
copyToClipboard(textSubject, 'Text');
|
||||
e.preventDefault();
|
||||
clipboardCopyDOMSelectionOrFallback(blocksRendererRef.current, textSubject, 'Message');
|
||||
handleCloseOpsMenu();
|
||||
closeContextMenu();
|
||||
closeBubble();
|
||||
@@ -893,18 +893,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={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>*/}
|
||||
{/*)}*/}
|
||||
|
||||
|
||||
{/* Message Operations Menu (3 dots) */}
|
||||
|
||||
+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>
|
||||
);
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
|
||||
@@ -182,7 +182,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'}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 <>
|
||||
|
||||
|
||||
@@ -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,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: 54,
|
||||
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,35 +576,69 @@ 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;
|
||||
@@ -570,23 +690,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 +726,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 +843,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 +897,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,6 +1028,12 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,6 +1076,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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />}
|
||||
endDecorator={<RefreshIcon />}
|
||||
onClick={props.refetch}
|
||||
sx={{ minWidth: 120, ml: 'auto' }}
|
||||
>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
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, SvgIconProps, 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 ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
|
||||
import type { IModelVendor } from '~/modules/llms/vendors/IModelVendor';
|
||||
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 type { DModelsServiceId } from '~/common/stores/llms/llms.service.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 { 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 { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
import { useVisibleLLMs } from '~/common/stores/llms/llms.hooks';
|
||||
@@ -69,12 +71,13 @@ const _styles = {
|
||||
backgroundColor: 'background.surface',
|
||||
zIndex: 1,
|
||||
},
|
||||
listVendor: {
|
||||
// see OptimaBarDropdown's _styles.separator
|
||||
listServiceHeaderButton: {
|
||||
fontSize: 'sm',
|
||||
color: 'text.tertiary',
|
||||
textAlign: 'center',
|
||||
my: 0.75,
|
||||
fontWeight: 'md',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
listServiceHeaderExpand: {
|
||||
fontSize: 'md',
|
||||
},
|
||||
listConfSep: {
|
||||
mb: 0,
|
||||
@@ -84,7 +87,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 +130,7 @@ const _slotProps: SelectSlotsAndSlotProps<false>['slotProps'] = {
|
||||
minWidth: '6rem',
|
||||
} as const,
|
||||
} as const,
|
||||
} as const;
|
||||
} as const satisfies SelectSlotsAndSlotProps<false>['slotProps'];
|
||||
|
||||
|
||||
interface LLMSelectOptions {
|
||||
@@ -162,6 +165,7 @@ export function useLLMSelect(
|
||||
|
||||
// state
|
||||
const [controlledOpen, setControlledOpen] = React.useState(false);
|
||||
const [collapsedServices, setCollapsedServices] = React.useState<Set<DModelsServiceId>>(new Set());
|
||||
|
||||
// external state
|
||||
const starredOnly = useUIPreferencesStore(state => showStarFilter && state.showModelsStarredOnly);
|
||||
@@ -174,27 +178,83 @@ export function useLLMSelect(
|
||||
const isReasoning = !LLM_SELECT_SHOW_REASONING_ICON ? false : llm?.interfaces?.includes(LLM_IF_OAI_Reasoning) ?? false;
|
||||
|
||||
|
||||
// handlers
|
||||
|
||||
const toggleServiceCollapse = React.useCallback((serviceId: DModelsServiceId) => {
|
||||
setCollapsedServices(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(serviceId)) next.delete(serviceId);
|
||||
else next.add(serviceId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
// 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(
|
||||
<ListItem key={'llm-sep-' + llm.sId}>
|
||||
<ListItemButton onClick={() => toggleServiceCollapse(llm.sId)} sx={_styles.listServiceHeaderButton}>
|
||||
{/*{serviceVendor?.id && <ListItemDecorator><LLMVendorIcon vendorId={serviceVendor.id} /></ListItemDecorator>}*/}
|
||||
<div />
|
||||
{isServiceCollapsed ? <i>{serviceLabel}</i> : serviceLabel}
|
||||
{isServiceCollapsed ? <ExpandMoreIcon sx={_styles.listServiceHeaderExpand} /> : <ExpandLessIcon sx={_styles.listServiceHeaderExpand} />}
|
||||
</ListItemButton>
|
||||
</ListItem>,
|
||||
);
|
||||
}
|
||||
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('🔗');
|
||||
@@ -202,6 +262,8 @@ export function useLLMSelect(
|
||||
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))
|
||||
@@ -225,7 +287,7 @@ export function useLLMSelect(
|
||||
>
|
||||
{!noIcons && (
|
||||
<ListItemDecorator>
|
||||
{(llm.userStarred && !starredOnly) ? <StarredNoXL2 /> : vendor?.id ? <LLMVendorIcon vendorId={vendor.id} /> : null}
|
||||
{(llm.userStarred && !starredOnly) ? <StarredNoXL2 /> : serviceVendor?.id ? <LLMVendorIcon vendorId={serviceVendor.id} /> : null}
|
||||
</ListItemDecorator>
|
||||
)}
|
||||
{/*<Tooltip title={llm.description}>*/}
|
||||
@@ -244,7 +306,7 @@ export function useLLMSelect(
|
||||
// variant='outlined'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
optimaActions().openModelOptions(llm.id);
|
||||
optimaActions().openModelOptions(llm.id, 'parameters');
|
||||
}}
|
||||
sx={_styles.configButton}
|
||||
>
|
||||
@@ -260,7 +322,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 +351,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,7 +394,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(() => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { OPTIMA_OPEN_DEBOUNCE, OPTIMA_PEEK_HOVER_ENTER_DELAY, OPTIMA_PEEK_HOVER_
|
||||
|
||||
export type PreferencesTabId = 'chat' | 'voice' | 'draw' | 'tools' | undefined;
|
||||
|
||||
export type ModelOptionsContext = 'full' | 'parameters';
|
||||
|
||||
|
||||
interface OptimaState {
|
||||
|
||||
@@ -27,6 +29,7 @@ interface OptimaState {
|
||||
showKeyboardShortcuts: boolean;
|
||||
showLogger: boolean;
|
||||
showModelOptions: DLLMId | false;
|
||||
showModelOptionsContext: ModelOptionsContext;
|
||||
showModels: boolean;
|
||||
showPreferences: boolean;
|
||||
preferencesTab: PreferencesTabId;
|
||||
@@ -51,6 +54,7 @@ const modalsClosedState = {
|
||||
showKeyboardShortcuts: false,
|
||||
showLogger: false,
|
||||
showModelOptions: false,
|
||||
showModelOptionsContext: 'full' as ModelOptionsContext,
|
||||
showModels: false,
|
||||
showPreferences: false,
|
||||
} as const;
|
||||
@@ -102,7 +106,7 @@ export interface OptimaActions {
|
||||
openLogger: () => void;
|
||||
|
||||
closeModelOptions: () => void;
|
||||
openModelOptions: (id: DLLMId) => void;
|
||||
openModelOptions: (id: DLLMId, context?: ModelOptionsContext) => void;
|
||||
|
||||
closeModels: () => void;
|
||||
openModels: () => void;
|
||||
@@ -209,7 +213,7 @@ export const useLayoutOptimaStore = create<OptimaState & OptimaActions>((_set, _
|
||||
openLogger: () => _set({ ...modalsClosedState, showLogger: true }),
|
||||
|
||||
closeModelOptions: () => _set({ showModelOptions: false }),
|
||||
openModelOptions: (id: DLLMId) => _set({ showModelOptions: id }),
|
||||
openModelOptions: (id: DLLMId, context?: ModelOptionsContext) => _set({ showModelOptions: id, showModelOptionsContext: context ?? 'full' }),
|
||||
|
||||
closeModels: () => _set({ showModels: false }),
|
||||
openModels: () => _set({ showModels: true }),
|
||||
|
||||
@@ -91,6 +91,7 @@ export function useOptimaModals() {
|
||||
showKeyboardShortcuts: state.showKeyboardShortcuts,
|
||||
showLogger: state.showLogger,
|
||||
showModelOptions: state.showModelOptions,
|
||||
showModelOptionsContext: state.showModelOptionsContext,
|
||||
showModels: state.showModels,
|
||||
showPreferences: state.showPreferences,
|
||||
preferencesTab: state.preferencesTab,
|
||||
|
||||
@@ -27,6 +27,8 @@ export type GlobalOverlayId = // string - disabled so we keep an orderliness
|
||||
| 'livefile-overwrite'
|
||||
| 'shortcuts-confirm-close'
|
||||
| 'blocks-off-enhance-code'
|
||||
| 'llms-remove-clones'
|
||||
| 'llms-reset-parameters'
|
||||
| 'llms-service-remove'
|
||||
| 'composer-unsupported-attachments' // The LLM does not seem to support this mime type - continue anyway?
|
||||
| 'composer-open-or-attach' // Open a file or attach it to the chat?
|
||||
|
||||
@@ -11,6 +11,10 @@ export function setupClientUncaughtErrorsLogging(): () => void {
|
||||
|
||||
// Handle uncaught exceptions
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
// Ignore benign ResizeObserver errors (browser warning, not an actual error)
|
||||
if (event.message?.includes('ResizeObserver loop'))
|
||||
return;
|
||||
|
||||
logger.error('Uncaught error', {
|
||||
message: event.error?.message || event.message,
|
||||
stack: event.error?.stack,
|
||||
|
||||
@@ -54,6 +54,8 @@ const scrollableBoxSx: SxProps = {
|
||||
overflowY: 'auto',
|
||||
// actually make sure this scrolls & fills
|
||||
height: '100%',
|
||||
// prevents pull-to-refresh on mobile when scrolling up in the chat
|
||||
overscrollBehaviorY: 'none',
|
||||
} as const;
|
||||
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ export type DMessageToolInvocationPart = {
|
||||
type: 'code_execution';
|
||||
language: string;
|
||||
code: string;
|
||||
author: 'gemini_auto_inline';
|
||||
author: DMessageToolCodeExecutor;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -224,12 +224,12 @@ export type DMessageToolResponsePart = {
|
||||
} | {
|
||||
type: 'code_execution';
|
||||
result: string; // The output
|
||||
executor: 'gemini_auto_inline';
|
||||
executor: DMessageToolCodeExecutor;
|
||||
},
|
||||
environment: DMessageToolEnvironment,
|
||||
};
|
||||
type DMessageToolEnvironment = 'upstream' | 'server' | 'client';
|
||||
|
||||
type DMessageToolCodeExecutor = 'gemini_auto_inline' | 'code_interpreter';
|
||||
|
||||
type DVoidModelAnnotationsPart = {
|
||||
pt: 'annotations',
|
||||
@@ -403,7 +403,7 @@ export function create_FunctionCallInvocation_ContentFragment(id: string, functi
|
||||
return _createContentFragment(_create_FunctionCallInvocation_Part(id, functionName, args));
|
||||
}
|
||||
|
||||
export function create_CodeExecutionInvocation_ContentFragment(id: string, language: string, code: string, author: 'gemini_auto_inline'): DMessageContentFragment {
|
||||
export function create_CodeExecutionInvocation_ContentFragment(id: string, language: string, code: string, author: DMessageToolCodeExecutor): DMessageContentFragment {
|
||||
return _createContentFragment(_create_CodeExecutionInvocation_Part(id, language, code, author));
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ export function create_FunctionCallResponse_ContentFragment(id: string, error: b
|
||||
return _createContentFragment(_create_FunctionCallResponse_Part(id, error, name, result, environment));
|
||||
}
|
||||
|
||||
export function create_CodeExecutionResponse_ContentFragment(id: string, error: boolean | string, result: string, executor: 'gemini_auto_inline', environment: DMessageToolEnvironment): DMessageContentFragment {
|
||||
export function create_CodeExecutionResponse_ContentFragment(id: string, error: boolean | string, result: string, executor: DMessageToolCodeExecutor, environment: DMessageToolEnvironment): DMessageContentFragment {
|
||||
return _createContentFragment(_create_CodeExecutionResponse_Part(id, error, result, executor, environment));
|
||||
}
|
||||
|
||||
@@ -553,7 +553,7 @@ function _create_FunctionCallInvocation_Part(id: string, functionName: string, a
|
||||
return { pt: 'tool_invocation', id, invocation: { type: 'function_call', name: functionName, args } };
|
||||
}
|
||||
|
||||
function _create_CodeExecutionInvocation_Part(id: string, language: string, code: string, author: 'gemini_auto_inline'): DMessageToolInvocationPart {
|
||||
function _create_CodeExecutionInvocation_Part(id: string, language: string, code: string, author: DMessageToolCodeExecutor): DMessageToolInvocationPart {
|
||||
return { pt: 'tool_invocation', id, invocation: { type: 'code_execution', language, code, author } };
|
||||
}
|
||||
|
||||
@@ -561,7 +561,7 @@ function _create_FunctionCallResponse_Part(id: string, error: boolean | string,
|
||||
return { pt: 'tool_response', id, error, response: { type: 'function_call', name, result }, environment };
|
||||
}
|
||||
|
||||
function _create_CodeExecutionResponse_Part(id: string, error: boolean | string, result: string, executor: 'gemini_auto_inline', environment: DMessageToolEnvironment): DMessageToolResponsePart {
|
||||
function _create_CodeExecutionResponse_Part(id: string, error: boolean | string, result: string, executor: DMessageToolCodeExecutor, environment: DMessageToolEnvironment): DMessageToolResponsePart {
|
||||
return { pt: 'tool_response', id, error, response: { type: 'code_execution', result, executor }, environment };
|
||||
}
|
||||
|
||||
|
||||
@@ -300,8 +300,8 @@ function _messageSetGeneratorAIX(message: Pick<DMessage, 'generator'>, modelLabe
|
||||
|
||||
export function messageSetGeneratorAIX_AutoLabel(message: Pick<DMessage, 'generator'>, modelVendorId: ModelVendorId, modelId: DLLMId): void {
|
||||
|
||||
// Simply strip the first part of the modelId, which is the serviceId, before the dash.
|
||||
const heuristicLabel = modelId.includes('-') ? modelId.replace(/^[^-]+-/, '') : modelId;
|
||||
// Strip the serviceId prefix: 'vendor-' or 'vendor-N-' (when multiple providers of same vendor)
|
||||
const heuristicLabel = modelId.includes('-') ? modelId.replace(/^[^-]+-(\d-)?/, '') : modelId;
|
||||
|
||||
_messageSetGeneratorAIX(message, heuristicLabel, modelVendorId, modelId);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ export function useLLM(llmId: undefined | DLLMId | null): DLLM | undefined {
|
||||
return useModelsStore(state => !llmId ? undefined : state.llms.find(llm => llm.id === llmId));
|
||||
}
|
||||
|
||||
export function useLLMExists(llmId: undefined | DLLMId | null): boolean {
|
||||
return useModelsStore(state => !llmId ? false : state.llms.some(llm => llm.id === llmId));
|
||||
}
|
||||
|
||||
export function useLLMs(llmIds: ReadonlyArray<DLLMId>): ReadonlyArray<DLLM | undefined> {
|
||||
return useModelsStore(useShallow(state => {
|
||||
return llmIds.map(llmId => !llmId ? undefined : state.llms.find(llm => llm.id === llmId));
|
||||
|
||||
@@ -20,6 +20,54 @@ export const FALLBACK_LLM_PARAM_TEMPERATURE = 0.5;
|
||||
// const FALLBACK_LLM_PARAM_REF_UNKNOWN = 'unknown_id';
|
||||
|
||||
|
||||
/// Registry Entry Types (for compile-time validation)
|
||||
|
||||
type _ParameterRegistryEntry =
|
||||
| _IntegerParamDef
|
||||
| _FloatParamDef
|
||||
| _StringParamDef
|
||||
| _BooleanParamDef
|
||||
| _EnumParamDef;
|
||||
|
||||
interface _ParamDefBase {
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
interface _IntegerParamDef extends _ParamDefBase {
|
||||
readonly type: 'integer';
|
||||
readonly range?: readonly [number, number];
|
||||
readonly nullable?: { readonly meaning: string };
|
||||
readonly requiredFallback?: number;
|
||||
readonly initialValue?: number | null;
|
||||
}
|
||||
|
||||
interface _FloatParamDef extends _ParamDefBase {
|
||||
readonly type: 'float';
|
||||
readonly range?: readonly [number, number];
|
||||
readonly nullable?: { readonly meaning: string };
|
||||
readonly requiredFallback?: number;
|
||||
readonly initialValue?: number | null;
|
||||
}
|
||||
|
||||
interface _StringParamDef extends _ParamDefBase {
|
||||
readonly type: 'string';
|
||||
readonly initialValue?: string;
|
||||
}
|
||||
|
||||
interface _BooleanParamDef extends _ParamDefBase {
|
||||
readonly type: 'boolean';
|
||||
readonly initialValue?: boolean;
|
||||
}
|
||||
|
||||
interface _EnumParamDef extends _ParamDefBase {
|
||||
readonly type: 'enum';
|
||||
readonly values: readonly string[];
|
||||
readonly requiredFallback?: string;
|
||||
readonly initialValue?: string;
|
||||
}
|
||||
|
||||
|
||||
/// Registry
|
||||
|
||||
export const DModelParameterRegistry = {
|
||||
@@ -29,60 +77,64 @@ export const DModelParameterRegistry = {
|
||||
|
||||
llmRef: {
|
||||
label: 'Model ID',
|
||||
type: 'string' as const,
|
||||
type: 'string',
|
||||
description: 'Upstream model reference',
|
||||
hidden: true,
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmResponseTokens: {
|
||||
label: 'Maximum Tokens',
|
||||
type: 'integer' as const,
|
||||
type: 'integer',
|
||||
description: 'Maximum length of generated text',
|
||||
nullable: {
|
||||
meaning: 'Explicitly avoid sending max_tokens to upstream API',
|
||||
} as const,
|
||||
},
|
||||
requiredFallback: FALLBACK_LLM_PARAM_RESPONSE_TOKENS, // if required and not specified/user overridden, use this value
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmTemperature: {
|
||||
label: 'Temperature',
|
||||
type: 'float' as const,
|
||||
type: 'float',
|
||||
description: 'Controls randomness in the output',
|
||||
range: [0.0, 2.0] as const,
|
||||
nullable: {
|
||||
meaning: 'Explicitly avoid sending temperature to upstream API',
|
||||
} as const,
|
||||
},
|
||||
requiredFallback: FALLBACK_LLM_PARAM_TEMPERATURE,
|
||||
} as const,
|
||||
},
|
||||
|
||||
/// Extended parameters, specific to certain models/vendors
|
||||
|
||||
llmTopP: {
|
||||
label: 'Top P',
|
||||
type: 'float' as const,
|
||||
type: 'float',
|
||||
description: 'Nucleus sampling threshold',
|
||||
range: [0.0, 1.0] as const,
|
||||
requiredFallback: 1.0,
|
||||
incompatibleWith: ['temperature'] as const,
|
||||
} as const,
|
||||
},
|
||||
|
||||
/**
|
||||
* First introduced as a user-configurable parameter for the 'Verification' required by o3.
|
||||
* [2025-04-16] Adding parameter to disable streaming for o3, and possibly more models.
|
||||
*
|
||||
* [2026-01-21] OpenAI Responses API: Reasoning Summaries require organization verification.
|
||||
* Per OpenAI docs, both streaming AND reasoning summaries require org verification for GPT-5/5.1/5.2.
|
||||
* - https://help.openai.com/en/articles/10362446-api-model-availability-by-usage-tier-and-verification-status
|
||||
* - Rather than adding a separate param, we piggyback on llmForceNoStream.
|
||||
* - AIX Wire type `vndOaiReasoningSummary` is derived from `llmForceNoStream` in aix.client.ts.
|
||||
*/
|
||||
llmForceNoStream: {
|
||||
label: 'Disable Streaming',
|
||||
type: 'boolean' as const,
|
||||
type: 'boolean',
|
||||
description: 'Disables streaming for this model',
|
||||
// initialValue: false, // we don't need the initial value here, will be assumed off
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndAnt1MContext: {
|
||||
label: '1M Context Window (Beta)',
|
||||
type: 'boolean' as const,
|
||||
type: 'boolean',
|
||||
description: 'Enable 1M token context window with premium pricing for >200K input tokens',
|
||||
// No initialValue - undefined means off (e.g. default 200K context window)
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndAntEffort: {
|
||||
label: 'Effort',
|
||||
@@ -94,37 +146,37 @@ export const DModelParameterRegistry = {
|
||||
|
||||
llmVndAntSkills: {
|
||||
label: 'Document Skills',
|
||||
type: 'string' as const,
|
||||
type: 'string',
|
||||
description: 'Comma-separated skills (xlsx,pptx,pdf,docx)',
|
||||
initialValue: '', // empty string = disabled
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndAntThinkingBudget: {
|
||||
label: 'Thinking Budget',
|
||||
type: 'integer' as const,
|
||||
type: 'integer',
|
||||
description: 'Budget for extended thinking',
|
||||
range: [1024, 65536] as const,
|
||||
initialValue: 16384,
|
||||
nullable: {
|
||||
meaning: 'Disable extended thinking',
|
||||
} as const,
|
||||
} as const,
|
||||
},
|
||||
},
|
||||
|
||||
llmVndAntWebFetch: {
|
||||
label: 'Web Fetch',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Enable fetching content from web pages and PDFs',
|
||||
values: ['auto', 'off'] as const,
|
||||
// No initialValue - undefined means off (same as 'off')
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndAntWebSearch: {
|
||||
label: 'Web Search',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Enable web search for real-time information',
|
||||
values: ['auto', 'off'] as const,
|
||||
// No initialValue - undefined means off (same as 'off')
|
||||
} as const,
|
||||
},
|
||||
|
||||
// llmVndAntToolSearch: { // Not user set
|
||||
// label: 'Tool Search',
|
||||
@@ -136,64 +188,63 @@ export const DModelParameterRegistry = {
|
||||
|
||||
llmVndGeminiAspectRatio: {
|
||||
label: 'Aspect Ratio',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Controls the aspect ratio of generated images',
|
||||
values: ['1:1', '2:3', '3:2', '3:4', '4:3', '9:16', '16:9', '21:9'] as const,
|
||||
// No initial value - when undefined, the model decides the aspect ratio
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndGeminiCodeExecution: {
|
||||
label: 'Code Execution',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Enable automatic Python code generation and execution by the model',
|
||||
values: ['auto'] as const,
|
||||
// No initialValue - undefined means off
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndGeminiComputerUse: {
|
||||
label: 'Computer Use Environment',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Environment type for Computer Use tool (required for Computer Use model)',
|
||||
values: ['browser'] as const,
|
||||
initialValue: 'browser',
|
||||
// requiredFallback: 'browser', // See `const _requiredParamId: DModelParameterId[]` in llms.parameters.ts for why custom params don't have required values at AIX invocation...
|
||||
hidden: true,
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndGeminiGoogleSearch: {
|
||||
label: 'Google Search',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Enable Google Search grounding with optional time filter',
|
||||
values: ['unfiltered', '1d', '1w', '1m', '6m', '1y'] as const,
|
||||
// No initialValue - undefined means off
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndGeminiImageSize: { // [Gemini, 2025-11-20] Nano Banana launch
|
||||
label: 'Image Size',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Controls the resolution of generated images',
|
||||
values: ['1K', '2K', '4K'] as const,
|
||||
// No initial value - when undefined, the model decides the image size
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndGeminiMediaResolution: {
|
||||
label: 'Media Resolution',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Controls vision processing quality for multimodal inputs. Higher resolution improves text reading and detail identification but increases token usage.',
|
||||
values: ['mr_high', 'mr_medium', 'mr_low'] as const,
|
||||
// No initialValue - undefined: "If unspecified, the model uses optimal defaults based on the media type." (Images: high, PDFs: medium, Videos: low/medium (rec: high for OCR))
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndGeminiShowThoughts: {
|
||||
label: 'Show Thoughts',
|
||||
type: 'boolean' as const,
|
||||
type: 'boolean',
|
||||
description: 'Show Gemini\'s reasoning process',
|
||||
// initialValue: true, // no initial value
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndGeminiThinkingBudget: {
|
||||
label: 'Thinking Budget',
|
||||
type: 'integer' as const,
|
||||
type: 'integer',
|
||||
/**
|
||||
* can be overwritten, as gemini models seem to have different ranges which also does not include 0
|
||||
* - value = 0 disables thinking
|
||||
@@ -202,15 +253,23 @@ export const DModelParameterRegistry = {
|
||||
range: [0, 24576] as const,
|
||||
// initialValue: unset, // auto-budgeting
|
||||
description: 'Budget for extended thinking. 0 disables thinking. If not set, the model chooses automatically.',
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndGeminiThinkingLevel: {
|
||||
label: 'Thinking Level',
|
||||
type: 'enum' as const,
|
||||
description: 'Controls internal reasoning depth. Replaces thinking_budget for Gemini 3 models. When unset, the model decides dynamically.',
|
||||
values: ['high', 'medium' /* not present at launch */, 'low' /* default when unset */] as const,
|
||||
// No initialValue - undefined means 'dynamic', which for Gemini Pro is the same as 'high' (which is the equivalent of 'medium' for OpenAI's effort levels.. somehow)
|
||||
} as const,
|
||||
type: 'enum',
|
||||
description: 'Controls internal reasoning depth for Gemini 3 Pro. When unset, the model decides dynamically.',
|
||||
values: ['high', 'low'] as const,
|
||||
// No initialValue - undefined means 'dynamic', which for Gemini Pro is the same as 'high'
|
||||
},
|
||||
|
||||
llmVndGeminiThinkingLevel4: {
|
||||
label: 'Thinking Level',
|
||||
type: 'enum',
|
||||
description: 'Controls internal reasoning depth for Gemini 3 Flash. When unset, the model decides dynamically.',
|
||||
values: ['high', 'medium', 'low', 'minimal'] as const,
|
||||
// No initialValue - undefined means 'dynamic'
|
||||
},
|
||||
|
||||
// NOTE: we don't have this as a parameter, as for now we use it in tandem with llmVndGeminiGoogleSearch
|
||||
// llmVndGeminiUrlContext: {
|
||||
@@ -223,54 +282,84 @@ export const DModelParameterRegistry = {
|
||||
|
||||
// Moonshot-specific parameters
|
||||
|
||||
llmVndMoonReasoningEffort: {
|
||||
label: 'Reasoning Effort',
|
||||
type: 'enum',
|
||||
description: 'Controls thinking depth for Kimi K2.5. High enables extended multi-step reasoning (default).',
|
||||
values: ['none', 'high'] as const,
|
||||
// No initialValue - undefined means high (thinking enabled, the default for K2.5)
|
||||
},
|
||||
|
||||
llmVndMoonshotWebSearch: {
|
||||
label: 'Web Search',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Enable Kimi\'s $web_search builtin function for real-time web search ($0.005 per search)',
|
||||
values: ['auto'] as const,
|
||||
// No initialValue - undefined means off
|
||||
} as const,
|
||||
},
|
||||
|
||||
// OpenAI-specific parameters
|
||||
// Reasoning effort levels per model:
|
||||
// - GPT-5: minimal, low, medium (default), high
|
||||
// - GPT-5.1: none (default), low, medium, high
|
||||
// - GPT-5.2: none (default), low, medium, high, xhigh
|
||||
// - GPT-5.2 Pro: medium (default), high, xhigh
|
||||
|
||||
llmVndOaiReasoningEffort: {
|
||||
label: 'Reasoning Effort',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Constrains effort on reasoning for OpenAI reasoning models',
|
||||
values: ['low', 'medium', 'high'] as const,
|
||||
requiredFallback: 'medium',
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndOaiReasoningEffort4: {
|
||||
label: 'Reasoning Effort',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Constrains effort on reasoning for OpenAI advanced reasoning models',
|
||||
values: ['minimal', 'low', 'medium', 'high'] as const,
|
||||
requiredFallback: 'medium',
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndOaiReasoningEffort52: {
|
||||
label: 'Reasoning Effort',
|
||||
type: 'enum',
|
||||
description: 'Constrains effort on reasoning for GPT-5.2 models. When unset, defaults to none (fast responses).',
|
||||
values: ['none', 'low', 'medium', 'high', 'xhigh'] as const,
|
||||
// No requiredFallback - unset = none (the default for GPT-5.2)
|
||||
// No initialValue - starts undefined, which the UI should display as "none"
|
||||
},
|
||||
|
||||
llmVndOaiReasoningEffort52Pro: {
|
||||
label: 'Reasoning Effort',
|
||||
type: 'enum',
|
||||
description: 'Constrains effort on reasoning for GPT-5.2 Pro. Defaults to medium.',
|
||||
values: ['medium', 'high', 'xhigh'] as const,
|
||||
// No requiredFallback - unset = medium (the default for GPT-5.2 Pro)
|
||||
},
|
||||
|
||||
llmVndOaiRestoreMarkdown: {
|
||||
label: 'Restore Markdown',
|
||||
type: 'boolean' as const,
|
||||
type: 'boolean',
|
||||
description: 'Restore Markdown formatting in the output',
|
||||
initialValue: true,
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndOaiVerbosity: {
|
||||
label: 'Verbosity',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Controls response length and detail level',
|
||||
values: ['low', 'medium', 'high'] as const,
|
||||
requiredFallback: 'medium',
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndOaiWebSearchContext: {
|
||||
label: 'Search Context Size',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Amount of context retrieved from the web',
|
||||
values: ['low', 'medium', 'high'] as const,
|
||||
requiredFallback: 'medium',
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndOaiWebSearchGeolocation: {
|
||||
// NOTE: for now this is a boolean to enable/disable using client-side geolocation, but
|
||||
@@ -278,19 +367,27 @@ export const DModelParameterRegistry = {
|
||||
// back if of type AixAPI_Model.userGeolocation, which is the AIX Wire format for the
|
||||
// location payload.
|
||||
label: 'Add User Location (Geolocation API)',
|
||||
type: 'boolean' as const,
|
||||
type: 'boolean',
|
||||
description: 'Approximate location for search results',
|
||||
initialValue: false,
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndOaiImageGeneration: {
|
||||
label: 'Image Generation',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Image generation mode and quality',
|
||||
values: ['mq', 'hq', 'hq_edit' /* precise input editing */, 'hq_png' /* uncompressed */] as const,
|
||||
// No initialValue - defaults to undefined (off)
|
||||
// No requiredFallback - this is optional
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndOaiCodeInterpreter: {
|
||||
label: 'Code Interpreter',
|
||||
type: 'enum',
|
||||
description: 'Python code execution ($0.03/container)',
|
||||
values: ['off', 'auto'] as const,
|
||||
// No initialValue - undefined means off (same as 'off')
|
||||
},
|
||||
|
||||
// Perplexity-specific parameters
|
||||
|
||||
@@ -298,64 +395,120 @@ export const DModelParameterRegistry = {
|
||||
|
||||
llmVndPerplexityDateFilter: {
|
||||
label: 'Date Range',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Filter results by publication date',
|
||||
values: ['unfiltered', '1m', '3m', '6m', '1y'] as const,
|
||||
// requiredFallback: 'unfiltered',
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndOrtWebSearch: {
|
||||
label: 'Web Search',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Enable OpenRouter web search (uses native search for OpenAI/Anthropic, Exa for others)',
|
||||
values: ['auto'] as const,
|
||||
// No initialValue - undefined means off
|
||||
} as const,
|
||||
},
|
||||
|
||||
llmVndPerplexitySearchMode: {
|
||||
label: 'Search Mode',
|
||||
type: 'enum' as const,
|
||||
type: 'enum',
|
||||
description: 'Type of sources to search',
|
||||
values: ['default', 'academic'] as const,
|
||||
// requiredFallback: 'default', // or leave unset for "unspecified"
|
||||
} as const,
|
||||
},
|
||||
|
||||
// xAI-specific parameters
|
||||
|
||||
llmVndXaiSearchMode: {
|
||||
label: 'Search Mode',
|
||||
type: 'enum' as const,
|
||||
description: 'Controls when to use live search',
|
||||
values: ['auto', 'on', 'off'] as const,
|
||||
initialValue: 'auto', // we default to auto for our users, to get them search out of the box
|
||||
} as const,
|
||||
llmVndXaiCodeExecution: {
|
||||
label: 'Code Execution',
|
||||
type: 'enum',
|
||||
description: 'Enable server-side code execution by the model',
|
||||
values: ['off', 'auto'] as const,
|
||||
// No initialValue - undefined means off (same as 'off')
|
||||
},
|
||||
|
||||
llmVndXaiSearchSources: {
|
||||
label: 'Search Sources',
|
||||
type: 'string' as const,
|
||||
description: 'Comma-separated sources (web,x,news,rss)',
|
||||
initialValue: 'web,x', // defaults to web,x as per xAI docs
|
||||
} as const,
|
||||
|
||||
llmVndXaiSearchDateFilter: {
|
||||
label: 'Search From Date',
|
||||
type: 'enum' as const,
|
||||
description: 'Filter search results by publication date',
|
||||
llmVndXaiSearchInterval: {
|
||||
label: 'Search Interval', // "X Search only" for now, fw comp to web search
|
||||
type: 'enum',
|
||||
description: 'Search in this interval',
|
||||
values: ['unfiltered', '1d', '1w', '1m', '6m', '1y'] as const,
|
||||
// requiredFallback: 'unfiltered',
|
||||
} as const,
|
||||
// No initialValue - undefined means unfiltered
|
||||
},
|
||||
|
||||
} as const;
|
||||
llmVndXaiWebSearch: {
|
||||
label: 'Web Search',
|
||||
type: 'enum',
|
||||
description: 'Enable web search for real-time information',
|
||||
values: ['off', 'auto'] as const,
|
||||
// No initialValue - undefined means off (same as 'off')
|
||||
},
|
||||
|
||||
llmVndXaiXSearch: {
|
||||
label: 'X Search',
|
||||
type: 'enum',
|
||||
description: 'Enable X/Twitter search for social media content',
|
||||
values: ['off', 'auto'] as const,
|
||||
// NOTE: disabling or this could be slow
|
||||
// initialValue: 'auto', // we default to 'auto' for our users, as they may expect "X search" out of the box
|
||||
},
|
||||
|
||||
llmVndXaiXSearchHandles: {
|
||||
label: 'X Handles Filter',
|
||||
type: 'string',
|
||||
description: 'Filter X search to specific handles (comma-separated, e.g. @elonmusk, @xai)',
|
||||
// initialValue: '', // empty = no filter
|
||||
},
|
||||
|
||||
} as const satisfies Record<string, _ParameterRegistryEntry>;
|
||||
|
||||
|
||||
/// Types
|
||||
|
||||
// this is the client-side typescript definition that matches ModelParameterSpec_schema in `llm.server.types.ts`
|
||||
export interface DModelParameterSpec<T extends DModelParameterId> {
|
||||
/** Stores runtime parameter values (initial and user overrides). */
|
||||
export type DModelParameterValues = {
|
||||
[K in DModelParameterId]?: DModelParameterValue<K>;
|
||||
};
|
||||
|
||||
export type DModelParameterId = keyof typeof DModelParameterRegistry;
|
||||
|
||||
/** Maps a parameter ID to its TypeScript value type (with nullable handling). */
|
||||
export type DModelParameterValue<T extends DModelParameterId> =
|
||||
typeof DModelParameterRegistry[T] extends { nullable: object }
|
||||
? _ParamTypeToBaseValue<T> | null
|
||||
: _ParamTypeToBaseValue<T>;
|
||||
|
||||
|
||||
// helper: map parameter type to base TypeScript type (before nullable handling)
|
||||
type _ParamTypeToBaseValue<T extends DModelParameterId> =
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'integer' ? number :
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'float' ? number :
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'string' ? string :
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'boolean' ? boolean :
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'enum' ? _EnumValues<typeof DModelParameterRegistry[T]> :
|
||||
never;
|
||||
|
||||
type _EnumValues<T> = T extends { readonly type: 'enum'; readonly values: readonly (infer U)[] } ? U : never;
|
||||
|
||||
|
||||
/**
|
||||
* Union of all possible model parameter specifications.
|
||||
*/
|
||||
export type DModelParameterSpecAny = {
|
||||
[K in DModelParameterId]: DModelParameterSpec<K>;
|
||||
}[DModelParameterId];
|
||||
|
||||
/**
|
||||
* Model-specific parameter configuration
|
||||
* Defines which parameters a model supports and their per-model settings.
|
||||
*
|
||||
* Note: This is the client-side TypeScript definition that matches
|
||||
* ModelParameterSpec_schema in `llm.server.types.ts`.
|
||||
*/
|
||||
interface DModelParameterSpec<T extends DModelParameterId> {
|
||||
paramId: T;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
initialValue?: boolean | number | string | null;
|
||||
initialValue?: DModelParameterValue<T>;
|
||||
// upstreamDefault?: DModelParameterValue<T>;
|
||||
/**
|
||||
* (optional, rare) Special: [min, max] range override for this parameter.
|
||||
@@ -364,44 +517,21 @@ export interface DModelParameterSpec<T extends DModelParameterId> {
|
||||
rangeOverride?: [number, number];
|
||||
}
|
||||
|
||||
export type DModelParameterValues = {
|
||||
[K in DModelParameterId]?: DModelParameterValue<K>;
|
||||
}
|
||||
|
||||
export type DModelParameterId = keyof typeof DModelParameterRegistry;
|
||||
// type _ExtendedParameterId = keyof typeof _ExtendedParameterRegistry;
|
||||
|
||||
type _EnumValues<T> = T extends { type: 'enum', values: readonly (infer U)[] } ? U : never;
|
||||
|
||||
type DModelParameterValue<T extends DModelParameterId> =
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'integer'
|
||||
? typeof DModelParameterRegistry[T] extends { nullable: any }
|
||||
? number | null
|
||||
: number :
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'float'
|
||||
? typeof DModelParameterRegistry[T] extends { nullable: any }
|
||||
? number | null
|
||||
: number :
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'string' ? string :
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'boolean' ? boolean :
|
||||
typeof DModelParameterRegistry[T]['type'] extends 'enum'
|
||||
? _EnumValues<typeof DModelParameterRegistry[T]>
|
||||
: never;
|
||||
|
||||
|
||||
/// Utility Functions
|
||||
|
||||
export function applyModelParameterInitialValues(destValues: DModelParameterValues, parameterSpecs: DModelParameterSpec<DModelParameterId>[], overwriteExisting: boolean): void {
|
||||
for (const param of parameterSpecs) {
|
||||
const paramId = param.paramId;
|
||||
export function applyModelParameterSpecsInitialValues(destValues: DModelParameterValues, modelParameterSpecs: DModelParameterSpecAny[], overwriteExisting: boolean): void {
|
||||
for (const parameterSpec of modelParameterSpecs) {
|
||||
const paramId = parameterSpec.paramId;
|
||||
|
||||
// skip if already present
|
||||
// NOTE: for the currently only caller, the destValues already has llmRef, llmTemperature, llmResponseTokens
|
||||
if (!overwriteExisting && paramId in destValues)
|
||||
continue;
|
||||
|
||||
// 1. (if present) apply Spec.initialValue
|
||||
if (param.initialValue !== undefined) {
|
||||
destValues[paramId] = param.initialValue as DModelParameterValue<typeof paramId>;
|
||||
if (parameterSpec.initialValue !== undefined) {
|
||||
destValues[paramId] = parameterSpec.initialValue as DModelParameterValue<typeof paramId>;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -416,17 +546,21 @@ export function applyModelParameterInitialValues(destValues: DModelParameterValu
|
||||
}
|
||||
|
||||
|
||||
const _requiredParamId: DModelParameterId[] = [
|
||||
/**
|
||||
* Implicit common parameters always supported by all models, not listed in parameterSpecs.
|
||||
* Must be preserved during model refresh operations.
|
||||
*/
|
||||
export const LLMS_ImplicitParamIds: readonly DModelParameterId[] = [
|
||||
// 'llmRef', // disabled: we know this can't have a fallback value in the registry
|
||||
'llmResponseTokens', // DModelParameterRegistry.llmResponseTokens.requiredFallback = FALLBACK_LLM_PARAM_RESPONSE_TOKENS
|
||||
'llmTemperature', // DModelParameterRegistry.llmTemperature.requiredFallback = FALLBACK_LLM_PARAM_TEMPERATURE
|
||||
] as const;
|
||||
];
|
||||
|
||||
export function getAllModelParameterValues(initialParameters: undefined | DModelParameterValues, userParameters?: DModelParameterValues): DModelParameterValues {
|
||||
|
||||
// fallback values
|
||||
const fallbackParameters: DModelParameterValues = {};
|
||||
for (const requiredParamId of _requiredParamId) {
|
||||
for (const requiredParamId of LLMS_ImplicitParamIds) {
|
||||
if ('requiredFallback' in DModelParameterRegistry[requiredParamId])
|
||||
fallbackParameters[requiredParamId] = DModelParameterRegistry[requiredParamId].requiredFallback as DModelParameterValue<typeof requiredParamId>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type { ModelVendorId } from '~/modules/llms/vendors/vendors.registry';
|
||||
|
||||
import type { DModelParameterId, DModelParameterSpec, DModelParameterValues } from './llms.parameters';
|
||||
import type { DModelParameterSpecAny, DModelParameterValues } from './llms.parameters';
|
||||
import type { DModelPricing } from './llms.pricing';
|
||||
import type { DModelsServiceId } from './llms.service.types';
|
||||
|
||||
@@ -21,37 +21,40 @@ export type DLLMId = string;
|
||||
export interface DLLM {
|
||||
id: DLLMId;
|
||||
|
||||
// editable properties (kept on update, if isEdited)
|
||||
// factory properties (overwritten on update)
|
||||
label: string;
|
||||
created: number | 0;
|
||||
updated?: number | 0;
|
||||
description: string;
|
||||
hidden: boolean; // default hidden state (can change underlying between refreshes)
|
||||
hidden: boolean;
|
||||
|
||||
// hard properties (overwritten on update)
|
||||
contextTokens: DLLMContextTokens; // null: must assume it's unknown
|
||||
maxOutputTokens: DLLMMaxOutputTokens; // null: must assume it's unknown
|
||||
trainingDataCutoff?: string; // 'Apr 2029'
|
||||
interfaces: DModelInterfaceV1[]; // if set, meaning this is the known and comprehensive set of interfaces
|
||||
benchmark?: { cbaElo?: number, cbaMmlu?: number }; // benchmark values
|
||||
benchmark?: { cbaElo?: number }; // benchmark values (Chat Bot Arena ELO)
|
||||
pricing?: DModelPricing;
|
||||
|
||||
// parameters system
|
||||
parameterSpecs: DModelParameterSpec<DModelParameterId>[];
|
||||
// parameters system (overwritten on update)
|
||||
parameterSpecs: DModelParameterSpecAny[];
|
||||
initialParameters: DModelParameterValues;
|
||||
|
||||
// references
|
||||
sId: DModelsServiceId;
|
||||
vId: ModelVendorId;
|
||||
// references (const, never change)
|
||||
sId: DModelsServiceId; // could be weak, but they're removed at the same time
|
||||
vId: ModelVendorId; // known hardcoded value
|
||||
|
||||
// user edited properties - if not undefined/missing, they override the others
|
||||
userLabel?: string;
|
||||
userHidden?: boolean;
|
||||
userStarred?: boolean;
|
||||
userParameters?: DModelParameterValues; // user has set these parameters
|
||||
userContextTokens?: DLLMContextTokens; // user override for context window
|
||||
userMaxOutputTokens?: DLLMMaxOutputTokens; // user override for max output tokens
|
||||
userPricing?: DModelPricing; // user override for model pricing
|
||||
userContextTokens?: DLLMContextTokens;
|
||||
userMaxOutputTokens?: DLLMMaxOutputTokens;
|
||||
userPricing?: DModelPricing;
|
||||
userParameters?: DModelParameterValues;
|
||||
|
||||
// clone metadata - user-created duplicates of models with independent settings
|
||||
isUserClone?: boolean; // true if this is a user-created clone
|
||||
cloneSourceId?: DLLMId; // original model ID (for reference)
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +160,7 @@ export type DModelInterfaceV1 =
|
||||
| 'tools-web-search' // TEMP: ui flag - supports integrated web search tool
|
||||
| 'hotfix-no-stream' // disable streaming for o1-preview (old) and o1 (20241217)
|
||||
| 'hotfix-no-temperature' // disable temperature for deepseek-r1
|
||||
| 'hotfix-no-webp' // convert WebP images to PNG (e.g. some local models via LM Studio)
|
||||
| 'hotfix-strip-images' // strip images from the input
|
||||
| 'hotfix-strip-sys0' // strip the system instruction (unsupported)
|
||||
| 'hotfix-sys0-to-usr0' // cast sys0 to usr0
|
||||
@@ -178,11 +182,11 @@ export const LLM_IF_Tools_WebSearch: DModelInterfaceV1 = 'tools-web-search';
|
||||
export const LLM_IF_OAI_Complete: DModelInterfaceV1 = 'oai-complete';
|
||||
export const LLM_IF_ANT_PromptCaching: DModelInterfaceV1 = 'ant-prompt-caching';
|
||||
export const LLM_IF_OAI_PromptCaching: DModelInterfaceV1 = 'oai-prompt-caching';
|
||||
export const LLM_IF_OAI_Realtime: DModelInterfaceV1 = 'oai-realtime';
|
||||
export const LLM_IF_OAI_Responses: DModelInterfaceV1 = 'oai-responses';
|
||||
export const LLM_IF_GEM_CodeExecution: DModelInterfaceV1 = 'gem-code-execution';
|
||||
export const LLM_IF_HOTFIX_NoStream: DModelInterfaceV1 = 'hotfix-no-stream';
|
||||
export const LLM_IF_HOTFIX_NoTemperature: DModelInterfaceV1 = 'hotfix-no-temperature';
|
||||
export const LLM_IF_HOTFIX_NoWebP: DModelInterfaceV1 = 'hotfix-no-webp';
|
||||
export const LLM_IF_HOTFIX_StripImages: DModelInterfaceV1 = 'hotfix-strip-images';
|
||||
export const LLM_IF_HOTFIX_StripSys0: DModelInterfaceV1 = 'hotfix-strip-sys0';
|
||||
export const LLM_IF_HOTFIX_Sys0ToUsr0: DModelInterfaceV1 = 'hotfix-sys0-to-usr0';
|
||||
@@ -206,11 +210,11 @@ export const LLMS_ALL_INTERFACES = [
|
||||
LLM_IF_ANT_PromptCaching, // [Anthropic] model supports anthropic-specific caching
|
||||
LLM_IF_GEM_CodeExecution, // [Gemini] Tool: code execution
|
||||
LLM_IF_OAI_PromptCaching, // [OpenAI] model supports OpenAI prompt caching
|
||||
LLM_IF_OAI_Realtime, // [OpenAI] realtime API support - unused
|
||||
LLM_IF_OAI_Responses, // [OpenAI] Responses API (new) support
|
||||
// Hotfixes to patch specific model quirks
|
||||
LLM_IF_HOTFIX_NoStream, // disable streaming (e.g., o1-preview(old))
|
||||
LLM_IF_HOTFIX_NoTemperature,// disable temperature parameter (e.g., deepseek-r1)
|
||||
LLM_IF_HOTFIX_NoWebP, // convert WebP images to PNG (e.g. LM Studio)
|
||||
LLM_IF_HOTFIX_StripImages, // remove images from input (e.g. o3-mini-2025-01-31)
|
||||
LLM_IF_HOTFIX_StripSys0, // strip system instruction (e.g. Gemini Image Generation 2025-03-13), excludes Sys0ToUsr0
|
||||
LLM_IF_HOTFIX_Sys0ToUsr0, // downgrade system to user messages for this model (e.g. o1-mini-2024-09-12)
|
||||
|
||||
@@ -7,7 +7,8 @@ import { persist } from 'zustand/middleware';
|
||||
|
||||
import type { DOpenRouterServiceSettings } from '~/modules/llms/vendors/openrouter/openrouter.vendor';
|
||||
import type { IModelVendor } from '~/modules/llms/vendors/IModelVendor';
|
||||
import type { ModelVendorId } from '~/modules/llms/vendors/vendors.registry';
|
||||
import { createDLLMUserClone, getDLLMCloneId } from '~/modules/llms/llm.client';
|
||||
import { findModelVendor, type ModelVendorId } from '~/modules/llms/vendors/vendors.registry';
|
||||
|
||||
import { hasKeys } from '~/common/util/objectUtils';
|
||||
|
||||
@@ -15,6 +16,7 @@ import type { DModelDomainId } from './model.domains.types';
|
||||
import type { DModelParameterId, DModelParameterValues } from './llms.parameters';
|
||||
import type { DModelsService, DModelsServiceId } from './llms.service.types';
|
||||
import { DLLM, DLLMId, LLM_IF_OAI_Fn, LLM_IF_OAI_Vision } from './llms.types';
|
||||
import { DModelParameterRegistry, LLMS_ImplicitParamIds } from './llms.parameters';
|
||||
import { createDModelConfiguration, DModelConfiguration } from './modelconfiguration.types';
|
||||
import { createLlmsAssignmentsSlice, LlmsAssignmentsActions, LlmsAssignmentsSlice, LlmsAssignmentsState, llmsHeuristicUpdateAssignments } from './store-llms-domains_slice';
|
||||
import { getDomainModelConfiguration } from './hooks/useModelDomain';
|
||||
@@ -35,17 +37,21 @@ export interface LlmsRootState {
|
||||
|
||||
interface LlmsRootActions {
|
||||
|
||||
setServiceLLMs: (serviceId: DModelsServiceId, serviceLLMs: ReadonlyArray<DLLM>, keepUserEdits: boolean, keepMissingLLMs: boolean) => void;
|
||||
setServiceLLMs: (serviceId: DModelsServiceId, serviceLLMs: ReadonlyArray<DLLM>, keepUserEdits: true, keepMissingLLMs: false) => void;
|
||||
removeLLM: (id: DLLMId) => void;
|
||||
removeCustomModels: (serviceId: DModelsServiceId) => void;
|
||||
rerankLLMsByServices: (serviceIdOrder: DModelsServiceId[]) => void;
|
||||
updateLLM: (id: DLLMId, partial: Partial<DLLM>) => void;
|
||||
updateLLMs: (updates: Array<{ id: DLLMId; partial: Partial<DLLM> }>) => void;
|
||||
updateLLMUserParameters: (id: DLLMId, partial: Partial<DModelParameterValues>) => void;
|
||||
deleteLLMUserParameter: (id: DLLMId, parameterId: DModelParameterId) => void;
|
||||
resetLLMUserParameters: (id: DLLMId) => void;
|
||||
resetServiceUserParameters: (serviceId: DModelsServiceId) => void;
|
||||
userCloneLLM: (sourceId: DLLMId, cloneLabel: string, cloneVariant: string) => DLLMId | null;
|
||||
|
||||
createModelsService: (vendor: IModelVendor) => DModelsService;
|
||||
removeService: (id: DModelsServiceId) => void;
|
||||
updateServiceLabel: (id: DModelsServiceId, label: string, allowEmpty?: boolean) => void;
|
||||
updateServiceSettings: <TServiceSettings>(id: DModelsServiceId, partialSettings: Partial<TServiceSettings>) => void;
|
||||
|
||||
setConfServiceId: (id: DModelsServiceId | null) => void;
|
||||
@@ -74,42 +80,75 @@ export const useModelsStore = create<LlmsStore>()(persist(
|
||||
|
||||
// actions
|
||||
|
||||
setServiceLLMs: (serviceId: DModelsServiceId, serviceLLMs: ReadonlyArray<DLLM>, keepUserEdits: boolean, keepMissingLLMs: boolean) =>
|
||||
set(({ llms: existingLLMs, modelAssignments }) => {
|
||||
setServiceLLMs: (serviceId: DModelsServiceId, updatedServiceLLMs: ReadonlyArray<DLLM>, keepUserEdits: true, keepMissingLLMs: false) =>
|
||||
set(({ llms, modelAssignments }) => {
|
||||
|
||||
// keep existing model customizations
|
||||
if (keepUserEdits) {
|
||||
serviceLLMs = serviceLLMs.map((llm: DLLM): DLLM => {
|
||||
const existing = existingLLMs.find(m => m.id === llm.id);
|
||||
if (!existing) return llm;
|
||||
// separate existing models
|
||||
const otherServiceLLMs = llms.filter(llm => llm.sId !== serviceId);
|
||||
const previousServiceLLMs = llms.filter(llm => llm.sId === serviceId);
|
||||
const consumedPreviousIds = new Set<DLLMId>();
|
||||
|
||||
const result = {
|
||||
...llm,
|
||||
...(existing.userLabel !== undefined ? { userLabel: existing.userLabel } : {}),
|
||||
...(existing.userHidden !== undefined ? { userHidden: existing.userHidden } : {}),
|
||||
...(existing.userStarred !== undefined ? { userStarred: existing.userStarred } : {}),
|
||||
...(existing.userParameters !== undefined ? { userParameters: { ...existing.userParameters } } : {}),
|
||||
...(existing.userContextTokens !== undefined ? { userContextTokens: existing.userContextTokens } : {}),
|
||||
...(existing.userMaxOutputTokens !== undefined ? { userMaxOutputTokens: existing.userMaxOutputTokens } : {}),
|
||||
...(existing.userPricing !== undefined ? { userPricing: existing.userPricing } : {}),
|
||||
};
|
||||
// process updated models, re-applying user customizations where applicable
|
||||
const mergedServiceLLMs: DLLM[] = updatedServiceLLMs.map((llm: DLLM): DLLM => {
|
||||
// new model: as-is
|
||||
const e = previousServiceLLMs.find(m => m.id === llm.id);
|
||||
if (!e) return llm;
|
||||
|
||||
// clean up stale parameters from userParameters - e.g. was in the model spec but removed in the new version
|
||||
if (result.userParameters)
|
||||
for (const key of Object.keys(result.userParameters))
|
||||
if (!llm.parameterSpecs.some(spec => spec.paramId === key))
|
||||
delete result.userParameters[key as DModelParameterId];
|
||||
// mark this previous model as matched (consumed)
|
||||
consumedPreviousIds.add(e.id);
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
// re-apply user edits from existing model to the new model data
|
||||
if (!keepUserEdits) return llm;
|
||||
const result: DLLM = {
|
||||
...llm,
|
||||
...(e.userLabel !== undefined ? { userLabel: e.userLabel } : {}),
|
||||
...(e.userHidden !== undefined ? { userHidden: e.userHidden } : {}),
|
||||
...(e.userStarred !== undefined ? { userStarred: e.userStarred } : {}),
|
||||
...(e.userContextTokens !== undefined ? { userContextTokens: e.userContextTokens } : {}),
|
||||
...(e.userMaxOutputTokens !== undefined ? { userMaxOutputTokens: e.userMaxOutputTokens } : {}),
|
||||
...(e.userPricing !== undefined ? { userPricing: e.userPricing } : {}),
|
||||
...(e.userParameters !== undefined ? { userParameters: { ...e.userParameters } } : {}),
|
||||
};
|
||||
|
||||
// remove models that are not in the new list
|
||||
if (!keepMissingLLMs)
|
||||
existingLLMs = existingLLMs.filter(llm => llm.sId !== serviceId);
|
||||
// clean up stale parameters from userParameters -
|
||||
// - e.g. was in the model spec but removed in the new version
|
||||
// - or the value of an enum got removed, and so we remove ours
|
||||
if (result.userParameters) {
|
||||
for (const key of Object.keys(result.userParameters)) {
|
||||
const paramId = key as DModelParameterId;
|
||||
|
||||
// replace existing llms with the same id
|
||||
const newLlms = [...serviceLLMs, ...existingLLMs.filter(existingLlm => !serviceLLMs.some(newLlm => newLlm.id === existingLlm.id))];
|
||||
// keep implicit common parameters (always supported, not in parameterSpecs)
|
||||
if (LLMS_ImplicitParamIds.includes(paramId))
|
||||
continue;
|
||||
|
||||
// remove parameters no longer in spec
|
||||
const paramSpec = llm.parameterSpecs.find(spec => spec.paramId === paramId);
|
||||
if (!paramSpec) {
|
||||
delete result.userParameters[paramId];
|
||||
continue;
|
||||
}
|
||||
|
||||
// for enum types, validate the value is still in the allowed values (e.g., 'medium' was removed from thinkingLevel)
|
||||
const regDef = DModelParameterRegistry[paramId];
|
||||
if (regDef && regDef.type === 'enum' && 'values' in regDef) {
|
||||
const currentValue = result.userParameters[paramId];
|
||||
if (currentValue && typeof currentValue === 'string' && !(regDef.values as readonly string[]).includes(currentValue))
|
||||
delete result.userParameters[paramId]; // reset to default (undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
|
||||
// Always preserve custom models
|
||||
// - NOTE: shall we check for the undelying ref to still be in the service, to auto-clean-up older models?
|
||||
const customModels = previousServiceLLMs.filter(llm => llm.isUserClone === true && !consumedPreviousIds.has(llm.id));
|
||||
const missingModels = !keepMissingLLMs ? [] : previousServiceLLMs.filter(llm => !llm.isUserClone && !consumedPreviousIds.has(llm.id));
|
||||
|
||||
// Build the final list in priority order
|
||||
const newLlms = [...customModels, ...missingModels, ...mergedServiceLLMs, ...otherServiceLLMs];
|
||||
return {
|
||||
llms: newLlms,
|
||||
modelAssignments: llmsHeuristicUpdateAssignments(newLlms, modelAssignments),
|
||||
@@ -125,6 +164,15 @@ export const useModelsStore = create<LlmsStore>()(persist(
|
||||
};
|
||||
}),
|
||||
|
||||
removeCustomModels: (serviceId: DModelsServiceId) =>
|
||||
set(state => {
|
||||
const newLlms = state.llms.filter(llm => !(llm.sId === serviceId && llm.isUserClone === true));
|
||||
return {
|
||||
llms: newLlms,
|
||||
modelAssignments: llmsHeuristicUpdateAssignments(newLlms, state.modelAssignments),
|
||||
};
|
||||
}),
|
||||
|
||||
rerankLLMsByServices: (serviceIdOrder: DModelsServiceId[]) =>
|
||||
set(state => {
|
||||
// Create a mapping of service IDs to their index in the provided order
|
||||
@@ -195,8 +243,40 @@ export const useModelsStore = create<LlmsStore>()(persist(
|
||||
}),
|
||||
})),
|
||||
|
||||
resetServiceUserParameters: (serviceId: DModelsServiceId) =>
|
||||
set(({ llms }) => ({
|
||||
llms: llms.map((llm: DLLM): DLLM => {
|
||||
if (llm.sId !== serviceId) return llm;
|
||||
// strip away just the user parameters
|
||||
const { userParameters /*, userContextTokens, userMaxOutputTokens, userPricing, ...*/, ...rest } = llm;
|
||||
return rest;
|
||||
}),
|
||||
})),
|
||||
|
||||
userCloneLLM: (sourceId: DLLMId, cloneLabel: string, cloneVariant: string): DLLMId | null => {
|
||||
const { llms } = get();
|
||||
const sourceLlm = llms.find(llm => llm.id === sourceId);
|
||||
if (!sourceLlm) return null;
|
||||
|
||||
// check uniqueness
|
||||
const cloneId = getDLLMCloneId(sourceId, cloneVariant);
|
||||
if (llms.some(llm => llm.id === cloneId)) return null;
|
||||
|
||||
// create clone
|
||||
const cloneLlm = createDLLMUserClone(sourceLlm, cloneLabel, cloneVariant);
|
||||
|
||||
// IMPORTANT: we have to have this LLM be part of the same group (or the UI will break on multiple-grouping)
|
||||
const serviceStartIndex = llms.findIndex(llm => llm.sId === sourceLlm.sId);
|
||||
const newLlms = [...llms];
|
||||
newLlms.splice(serviceStartIndex, 0, cloneLlm);
|
||||
set({ llms: newLlms });
|
||||
|
||||
return cloneId;
|
||||
},
|
||||
|
||||
createModelsService: (vendor: IModelVendor): DModelsService => {
|
||||
|
||||
// e.g. 'openai', 'openai-1', 'openai-2' - finds the first available slot
|
||||
function _locallyUniqueServiceId(vendorId: ModelVendorId, existingServices: DModelsService[]): DModelsServiceId {
|
||||
let serviceId: DModelsServiceId = vendorId;
|
||||
let serviceIdx = 0;
|
||||
@@ -207,32 +287,35 @@ export const useModelsStore = create<LlmsStore>()(persist(
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
function _relabelServicesFromSameVendor(vendorId: ModelVendorId, services: DModelsService[]): DModelsService[] {
|
||||
let n = 0;
|
||||
return services.map((s: DModelsService): DModelsService =>
|
||||
(s.vId !== vendorId) ? s
|
||||
: { ...s, label: s.label.replace(/ #\d+$/, '') + (++n > 1 ? ` #${n}` : '') },
|
||||
);
|
||||
// e.g. 'OpenAI', 'OpenAI #2', 'OpenAI #3' - uses max index + 1, never relabels existing
|
||||
function _nextAutoLabelForVendor(vendorId: ModelVendorId, vendorName: string, existingServices: DModelsService[]): string {
|
||||
const sameVendorServices = existingServices.filter(s => s.vId === vendorId);
|
||||
if (sameVendorServices.length === 0)
|
||||
return vendorName;
|
||||
let maxIndex = 1;
|
||||
for (const s of sameVendorServices) {
|
||||
const match = s.label.match(/ #(\d+)$/);
|
||||
if (match)
|
||||
maxIndex = Math.max(maxIndex, parseInt(match[1], 10));
|
||||
}
|
||||
return `${vendorName} #${maxIndex + 1}`;
|
||||
}
|
||||
|
||||
const { sources: existingServices, confServiceId } = get();
|
||||
|
||||
// create the service
|
||||
const newService: DModelsService = {
|
||||
id: _locallyUniqueServiceId(vendor.id, existingServices),
|
||||
label: vendor.name,
|
||||
label: _nextAutoLabelForVendor(vendor.id, vendor.name, existingServices),
|
||||
vId: vendor.id,
|
||||
setup: vendor.initializeSetup?.() || {},
|
||||
};
|
||||
|
||||
const newServices = _relabelServicesFromSameVendor(vendor.id, [...existingServices, newService]);
|
||||
|
||||
set({
|
||||
sources: newServices,
|
||||
sources: [...existingServices, newService],
|
||||
confServiceId: confServiceId ?? newService.id,
|
||||
});
|
||||
|
||||
return newServices[newServices.length - 1];
|
||||
return newService;
|
||||
},
|
||||
|
||||
removeService: (id: DModelsServiceId) =>
|
||||
@@ -245,6 +328,26 @@ export const useModelsStore = create<LlmsStore>()(persist(
|
||||
};
|
||||
}),
|
||||
|
||||
updateServiceLabel: (id: DModelsServiceId, label: string, allowEmpty: boolean = false) =>
|
||||
set(state => {
|
||||
// fallback label to vendor name if empty
|
||||
if (!allowEmpty && !label.trim()) {
|
||||
const service = state.sources.find(s => s.id === id);
|
||||
const vendor = service ? findModelVendor(service.vId) : null;
|
||||
label = vendor?.name || label;
|
||||
}
|
||||
// allow max of 32 chars for the name
|
||||
if (label.length > 32)
|
||||
label = label.substring(0, 32);
|
||||
return {
|
||||
sources: state.sources.map((s: DModelsService): DModelsService =>
|
||||
s.id === id
|
||||
? { ...s, label: label }
|
||||
: s,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
updateServiceSettings: <TServiceSettings>(id: DModelsServiceId, partialSettings: Partial<TServiceSettings>) =>
|
||||
set(state => ({
|
||||
sources: state.sources.map((s: DModelsService): DModelsService =>
|
||||
|
||||
@@ -18,4 +18,32 @@
|
||||
/* Prevents pull-to-refresh on mobile, so it's not triggered while scrolling the chat inadvertently */
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Customize the Google Drive Picker background */
|
||||
|
||||
.picker-dialog-bg {
|
||||
/* fixes a weird scrollbars issue */
|
||||
margin-top: -1px !important;
|
||||
margin-left: -1px !important;
|
||||
}
|
||||
|
||||
iframe.picker-dialog-bg {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
div.picker-dialog-bg {
|
||||
/* we have alpha in the background, don't also use opacity */
|
||||
opacity: 1 !important;
|
||||
/*noinspection CssUnresolvedCustomProperty*/
|
||||
background-color: rgba(var(--joy-palette-neutral-darkChannel, 11 13 14) / 0.25) !important;
|
||||
/*backdrop-filter: blur(8px);*/
|
||||
}
|
||||
|
||||
div.picker-dialog {
|
||||
/*noinspection CssUnresolvedCustomProperty*/
|
||||
/*box-shadow: var(--joy-shadow-md);*/
|
||||
border-radius: 1.25rem;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* AudioAutoPlayer - Unified streaming/accumulated audio playback
|
||||
*
|
||||
* Abstracts the difference between:
|
||||
* - Streaming playback (AudioLivePlayer) - plays chunks as they arrive
|
||||
* - Accumulated playback (AudioPlayer) - collects all chunks, plays at end
|
||||
*
|
||||
* Automatically selects streaming if supported (Chrome, Safari, Edge),
|
||||
* falls back to accumulated for browsers without MediaSource support (Firefox).
|
||||
*
|
||||
* Can be forced into accumulated mode via constructor parameter.
|
||||
*/
|
||||
|
||||
import { combine_ArrayBuffers_To_Uint8Array } from '~/common/util/blobUtils';
|
||||
|
||||
import { AudioLivePlayer } from './AudioLivePlayer';
|
||||
import { AudioPlayer } from './AudioPlayer';
|
||||
|
||||
|
||||
export class AudioAutoPlayer {
|
||||
|
||||
private readonly livePlayer: AudioLivePlayer | null = null;
|
||||
private chunksAccumulator: ArrayBuffer[] = [];
|
||||
private isStopped: boolean = false;
|
||||
private isPlayingFullBuffer: boolean = false;
|
||||
private hasEnqueuedChunks: boolean = false;
|
||||
private hasEndedPlayback: boolean = false;
|
||||
|
||||
// deferred for waitForPlaybackEnd() in non-streaming mode
|
||||
private readonly playbackEndPromise: Promise<void>;
|
||||
private playbackEndResolve: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* @param forceAccumulate - If true, always accumulate and play at end (skip streaming)
|
||||
*/
|
||||
constructor(forceAccumulate: boolean = false) {
|
||||
if (!forceAccumulate && AudioLivePlayer.isSupported)
|
||||
this.livePlayer = new AudioLivePlayer();
|
||||
|
||||
// create deferred promise for accumulated mode
|
||||
this.playbackEndPromise = new Promise<void>((resolve) => {
|
||||
this.playbackEndResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether this instance is using streaming playback */
|
||||
get isStreaming(): boolean {
|
||||
return this.livePlayer !== null;
|
||||
}
|
||||
|
||||
/** Enqueue an audio chunk for playback */
|
||||
enqueueChunk(buffer: ArrayBuffer): void {
|
||||
if (this.isStopped)
|
||||
return void console.warn('[DEV] AudioAutoPlayer: enqueueChunk after stop');
|
||||
if (this.isPlayingFullBuffer)
|
||||
return void console.warn('[DEV] AudioAutoPlayer: enqueueChunk after playFullBuffer');
|
||||
|
||||
this.hasEnqueuedChunks = true;
|
||||
if (this.livePlayer) {
|
||||
this.livePlayer.enqueueChunk(buffer);
|
||||
} else {
|
||||
// Accumulate for later - copy buffer in case original is detached
|
||||
this.chunksAccumulator.push(buffer.slice(0));
|
||||
}
|
||||
}
|
||||
|
||||
/** Signal that no more chunks will arrive - triggers playback in accumulated mode */
|
||||
endPlayback(): void {
|
||||
if (this.isStopped)
|
||||
return void console.warn('[DEV] AudioAutoPlayer: endPlayback after stop');
|
||||
if (this.hasEndedPlayback)
|
||||
return void console.warn('[DEV] AudioAutoPlayer: endPlayback called twice');
|
||||
|
||||
this.hasEndedPlayback = true;
|
||||
|
||||
if (this.livePlayer) {
|
||||
|
||||
this.livePlayer.endPlayback();
|
||||
|
||||
} else if (this.chunksAccumulator.length > 0) {
|
||||
|
||||
// combine all chunks and play
|
||||
const combined = combine_ArrayBuffers_To_Uint8Array(this.chunksAccumulator).buffer;
|
||||
this.chunksAccumulator = []; // Clear after combining
|
||||
AudioPlayer.playAudioFull(combined).finally(() => {
|
||||
if (!this.isStopped)
|
||||
this.playbackEndResolve?.();
|
||||
});
|
||||
|
||||
} else if (!this.isPlayingFullBuffer) {
|
||||
// No chunks and no full buffer playing - resolve immediately
|
||||
this.playbackEndResolve?.();
|
||||
}
|
||||
// If isPlayingFullBuffer is true, playFullBuffer's finally() will resolve
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a complete audio buffer directly (bypasses chunk accumulation).
|
||||
* Use this when you have a full audio buffer instead of streaming chunks.
|
||||
*/
|
||||
playFullBuffer(buffer: ArrayBuffer): void {
|
||||
if (this.isStopped)
|
||||
return void console.warn('[DEV] AudioAutoPlayer: playFullBuffer after stop');
|
||||
if (this.hasEnqueuedChunks)
|
||||
console.warn('[DEV] AudioAutoPlayer: playFullBuffer after enqueueChunk');
|
||||
if (this.isPlayingFullBuffer)
|
||||
console.warn('[DEV] AudioAutoPlayer: playFullBuffer called twice');
|
||||
|
||||
this.isPlayingFullBuffer = true;
|
||||
AudioPlayer.playAudioFull(buffer).finally(() => {
|
||||
if (!this.isStopped)
|
||||
this.playbackEndResolve?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when playback completes.
|
||||
* Safe to call before endPlayback()/playFullBuffer() - will wait until playback finishes.
|
||||
*/
|
||||
waitForPlaybackEnd(): Promise<void> {
|
||||
// Use livePlayer only for streaming chunks, not for full buffer playback
|
||||
if (this.livePlayer && !this.isPlayingFullBuffer)
|
||||
return this.livePlayer.waitForPlaybackEnd();
|
||||
return this.playbackEndPromise;
|
||||
}
|
||||
|
||||
/** Stop playback immediately */
|
||||
stop(): void {
|
||||
this.isStopped = true;
|
||||
this.livePlayer?.stop();
|
||||
this.chunksAccumulator = [];
|
||||
this.playbackEndResolve?.(); // Resolve to unblock any waiters
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,34 @@
|
||||
export class AudioLivePlayer {
|
||||
private readonly mimeType: string = 'audio/mpeg';
|
||||
private static readonly MIME_TYPE = 'audio/mpeg';
|
||||
|
||||
/** Whether the browser supports streaming audio playback via MediaSource (false on Firefox) */
|
||||
static readonly isSupported = typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported(AudioLivePlayer.MIME_TYPE);
|
||||
|
||||
private readonly mimeType: string = AudioLivePlayer.MIME_TYPE;
|
||||
|
||||
private readonly audioContext: AudioContext;
|
||||
private readonly audioElement: HTMLAudioElement;
|
||||
private readonly mediaSource: MediaSource;
|
||||
private readonly mediaSourceObjectUrl: string;
|
||||
private sourceBuffer: SourceBuffer | null = null;
|
||||
|
||||
private chunkQueue: ArrayBuffer[] = [];
|
||||
private isSourceBufferUpdating: boolean = false;
|
||||
private isMediaSourceEnded: boolean = false;
|
||||
private isMediaSourceOpen: boolean = false;
|
||||
private isSourceBufferFailed: boolean = false;
|
||||
private isStopped: boolean = false;
|
||||
|
||||
// Deferred for waitForPlaybackEnd() - allows stop() to unblock waiters
|
||||
private playbackEndResolve: (() => void) | null = null;
|
||||
|
||||
|
||||
constructor() {
|
||||
this.audioContext = new AudioContext();
|
||||
this.audioElement = new Audio();
|
||||
this.mediaSource = new MediaSource();
|
||||
this.audioElement.src = URL.createObjectURL(this.mediaSource);
|
||||
this.mediaSourceObjectUrl = URL.createObjectURL(this.mediaSource);
|
||||
this.audioElement.src = this.mediaSourceObjectUrl;
|
||||
this.audioElement.autoplay = true;
|
||||
|
||||
// Suppress Android media notification by clearing media session metadata
|
||||
@@ -35,30 +47,40 @@ export class AudioLivePlayer {
|
||||
}
|
||||
|
||||
private onMediaSourceOpen = () => {
|
||||
this.isMediaSourceOpen = true;
|
||||
this.sourceBuffer = this.mediaSource.addSourceBuffer(this.mimeType);
|
||||
this.sourceBuffer.mode = 'sequence'; // Ensure data is appended in order
|
||||
this.sourceBuffer.addEventListener('updateend', this.onSourceBufferUpdateEnd);
|
||||
this.sourceBuffer.addEventListener('error', this.onSourceBufferError);
|
||||
if (this.isStopped) return; // Prevent race with stop()
|
||||
|
||||
try {
|
||||
this.sourceBuffer = this.mediaSource.addSourceBuffer(this.mimeType);
|
||||
this.sourceBuffer.mode = 'sequence'; // Ensure data is appended in order
|
||||
this.sourceBuffer.addEventListener('updateend', this.onSourceBufferUpdateEnd);
|
||||
this.sourceBuffer.addEventListener('error', this.onSourceBufferError);
|
||||
this.isMediaSourceOpen = true; // Set after successful initialization
|
||||
} catch (e) {
|
||||
// Safety net for any edge cases not caught by isSupported check
|
||||
console.error('[DEV] AudioLivePlayer: Failed to create SourceBuffer:', e);
|
||||
this.isSourceBufferFailed = true;
|
||||
this.playbackEndResolve?.(); // Unblock any waiters
|
||||
return;
|
||||
}
|
||||
|
||||
// Start appending data if any is queued
|
||||
this.appendNextChunk();
|
||||
};
|
||||
|
||||
private onMediaSourceError = (e: Event) => {
|
||||
console.error('MediaSource error:', e);
|
||||
console.warn('[DEV] AudioLivePlayer: MediaSource error:', e);
|
||||
};
|
||||
|
||||
private onMediaSourceEnded = () => {
|
||||
console.log('MediaSource ended');
|
||||
// MediaSource stream ended (all data received)
|
||||
};
|
||||
|
||||
private onMediaSourceClosed = () => {
|
||||
console.log('MediaSource closed');
|
||||
// MediaSource closed (cleanup complete)
|
||||
};
|
||||
|
||||
private onSourceBufferError = (e: Event) => {
|
||||
console.error('SourceBuffer error:', e);
|
||||
console.error('[DEV] AudioLivePlayer: SourceBuffer error:', e);
|
||||
};
|
||||
|
||||
private onSourceBufferUpdateEnd = () => {
|
||||
@@ -74,6 +96,7 @@ export class AudioLivePlayer {
|
||||
};
|
||||
|
||||
private appendNextChunk() {
|
||||
if (this.isStopped) return; // Early exit if stopped
|
||||
if (!this.sourceBuffer || this.isSourceBufferUpdating || !this.isMediaSourceOpen) return;
|
||||
|
||||
if (this.chunkQueue.length > 0) {
|
||||
@@ -83,7 +106,7 @@ export class AudioLivePlayer {
|
||||
this.isSourceBufferUpdating = true;
|
||||
this.sourceBuffer.appendBuffer(chunk);
|
||||
} catch (e) {
|
||||
console.error('Error appending buffer:', e);
|
||||
console.error('[DEV] AudioLivePlayer: Error appending buffer:', e);
|
||||
this.isSourceBufferUpdating = false;
|
||||
}
|
||||
}
|
||||
@@ -120,25 +143,90 @@ export class AudioLivePlayer {
|
||||
this._safeEndOfStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Promise that resolves when audio playback completes.
|
||||
* This waits for the actual audio to finish playing, not just streaming to end.
|
||||
* Also resolves if stop() is called (or was already called).
|
||||
*/
|
||||
public waitForPlaybackEnd(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// If already stopped, ended, or failed to initialize, resolve immediately
|
||||
if (this.isStopped || this.audioElement.ended || this.isSourceBufferFailed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
this.playbackEndResolve = null;
|
||||
this.audioElement.removeEventListener('ended', onEnded);
|
||||
this.audioElement.removeEventListener('error', onError);
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
resolve(); // Resolve even on error to not hang
|
||||
};
|
||||
|
||||
// store resolver so stop() can call it
|
||||
this.playbackEndResolve = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.audioElement.addEventListener('ended', onEnded);
|
||||
this.audioElement.addEventListener('error', onError);
|
||||
|
||||
// Safety: if audio has duration and already played through, resolve
|
||||
// This handles edge case where 'ended' event was missed
|
||||
if (this.audioElement.duration > 0 && this.audioElement.currentTime >= this.audioElement.duration) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback and clean up resources
|
||||
*/
|
||||
public async stop() {
|
||||
public stop() {
|
||||
this.isStopped = true;
|
||||
this.isSourceBufferFailed = true; // Prevent late initialization
|
||||
this.audioElement.pause();
|
||||
this.chunkQueue = [];
|
||||
this.isMediaSourceEnded = true;
|
||||
|
||||
// only abort SourceBuffer when MediaSource is 'open'
|
||||
if (this.sourceBuffer && this.mediaSource.readyState === 'open') {
|
||||
// Resolve any pending waitForPlaybackEnd() callers
|
||||
this.playbackEndResolve?.();
|
||||
|
||||
// Clean up SourceBuffer event listeners and abort if open
|
||||
if (this.sourceBuffer) {
|
||||
this.sourceBuffer.removeEventListener('updateend', this.onSourceBufferUpdateEnd);
|
||||
this.sourceBuffer.removeEventListener('error', this.onSourceBufferError);
|
||||
try {
|
||||
this.sourceBuffer.abort();
|
||||
this.mediaSource.endOfStream();
|
||||
if (this.mediaSource.readyState === 'open') {
|
||||
this.sourceBuffer.abort();
|
||||
this.mediaSource.endOfStream();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore - may race with natural stream end
|
||||
}
|
||||
}
|
||||
|
||||
void this.audioContext.close(); // fire/forget
|
||||
// Clean up MediaSource event listeners
|
||||
this.mediaSource.removeEventListener('sourceopen', this.onMediaSourceOpen);
|
||||
this.mediaSource.removeEventListener('error', this.onMediaSourceError);
|
||||
this.mediaSource.removeEventListener('sourceended', this.onMediaSourceEnded);
|
||||
this.mediaSource.removeEventListener('sourceclose', this.onMediaSourceClosed);
|
||||
|
||||
if (this.audioContext.state !== 'closed')
|
||||
void this.audioContext.close().catch(() => { /* ignore - already closed */
|
||||
});
|
||||
URL.revokeObjectURL(this.mediaSourceObjectUrl);
|
||||
this.audioElement.src = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,39 @@ export namespace AudioPlayer {
|
||||
|
||||
/**
|
||||
* Plays an audio buffer (e.g. from an ArrayBuffer).
|
||||
* Resolves when playback completes, or immediately if buffer is empty/invalid.
|
||||
*/
|
||||
export async function playBuffer(audioBuffer: ArrayBuffer): Promise<void> {
|
||||
const audioContext = new AudioContext();
|
||||
const bufferSource = audioContext.createBufferSource();
|
||||
bufferSource.buffer = await audioContext.decodeAudioData(audioBuffer);
|
||||
bufferSource.connect(audioContext.destination);
|
||||
bufferSource.start();
|
||||
return new Promise((resolve) => {
|
||||
bufferSource.onended = () => resolve();
|
||||
});
|
||||
export async function playAudioFull(audioBuffer: ArrayBuffer): Promise<void> {
|
||||
// sanity check
|
||||
if (!audioBuffer || audioBuffer.byteLength === 0) return;
|
||||
|
||||
let audioContext: AudioContext | undefined;
|
||||
try {
|
||||
audioContext = new AudioContext();
|
||||
|
||||
const audioDataCopy = audioBuffer.slice(0); // slice to avoid detached buffer issues
|
||||
const decodedBuffer = await audioContext.decodeAudioData(audioDataCopy);
|
||||
|
||||
const bufferSource = audioContext.createBufferSource();
|
||||
bufferSource.buffer = decodedBuffer;
|
||||
bufferSource.connect(audioContext.destination);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
bufferSource.onended = () => {
|
||||
audioContext?.close().catch(() => {
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
bufferSource.start();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.warn('[AudioPlayer] playAudioFull failed:', error);
|
||||
audioContext?.close().catch(() => {
|
||||
});
|
||||
// Resolve to not break playback chains - the audio just won't play
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -286,3 +286,18 @@ export function convert_UInt8Array_To_Base64(bytes: Uint8Array, debugCaller: str
|
||||
throw new Error(`Bytes to base64 failed (${debugCaller})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Misc Operations ///
|
||||
|
||||
// Combine multiple ArrayBuffers
|
||||
export function combine_ArrayBuffers_To_Uint8Array(buffers: ReadonlyArray<ArrayBuffer>): Uint8Array<ArrayBuffer> {
|
||||
const totalLength = buffers.reduce((sum, buf) => sum + buf.byteLength, 0);
|
||||
const combined = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const buf of buffers) {
|
||||
combined.set(new Uint8Array(buf), offset);
|
||||
offset += buf.byteLength;
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ClipboardEvent as ReactClipboardEvent } from 'react';
|
||||
|
||||
import { addSnackbar } from '../components/snackbar/useSnackbarsStore';
|
||||
import { Is, isBrowser } from './pwaUtils';
|
||||
|
||||
|
||||
export function copyToClipboard(text: string, typeLabel: string) {
|
||||
if (!isBrowser)
|
||||
return;
|
||||
@@ -67,4 +70,110 @@ export async function getClipboardItems(): Promise<ClipboardItem[] | null> {
|
||||
console.warn('Failed to read clipboard: ', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- HTML copy (from DOM Elements / Selection) with cleaning ---
|
||||
|
||||
/**
|
||||
* Copy selection (if within container) or entire container content to clipboard.
|
||||
* Strips theme colors and no-copy elements. Shows snackbar notification.
|
||||
*/
|
||||
export function clipboardCopyDOMSelectionOrFallback(containerElement: HTMLElement | null, fallbackText: string, typeLabel: string) {
|
||||
if (!isBrowser) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
const hasSelectionInContainer = selection && !selection.isCollapsed && containerElement?.contains(selection.anchorNode);
|
||||
|
||||
// Clone content: selection or full container
|
||||
const div = document.createElement('div');
|
||||
if (hasSelectionInContainer) {
|
||||
div.appendChild(selection.getRangeAt(0).cloneContents());
|
||||
} else if (containerElement) {
|
||||
div.innerHTML = containerElement.innerHTML;
|
||||
} else {
|
||||
copyToClipboard(fallbackText, typeLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
_cleanElementForCopy(div);
|
||||
const cleanedHtml = div.innerHTML;
|
||||
const cleanedText = _getInnerTextFromFloatingElement(div, fallbackText);
|
||||
|
||||
// Write both HTML and plain text to clipboard
|
||||
const htmlBlob = new Blob([cleanedHtml], { type: 'text/html' });
|
||||
const textBlob = new Blob([cleanedText], { type: 'text/plain' });
|
||||
|
||||
navigator.clipboard.write([new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob })])
|
||||
.then(() => addSnackbar({ key: 'copy-to-clipboard', message: `${hasSelectionInContainer ? 'Selection' : typeLabel} copied to clipboard`, type: 'success', closeButton: false, overrides: { autoHideDuration: 2000 } }))
|
||||
.catch(() => copyToClipboard(cleanedText, typeLabel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept copy event (Ctrl+C) to clean HTML before copying.
|
||||
* Call this from onCopy handlers. Returns true if handled.
|
||||
*/
|
||||
export function clipboardInterceptCtrlCForCleanup(event: ReactClipboardEvent): boolean {
|
||||
if (!isBrowser) return false;
|
||||
|
||||
// require a valid selection
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || !event.clipboardData) return false;
|
||||
|
||||
// clone selection content and clean it
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(selection.getRangeAt(0).cloneContents());
|
||||
_cleanElementForCopy(div);
|
||||
|
||||
// get formatted text (innerText respects block elements for line breaks)
|
||||
const cleanedHtml = div.innerHTML;
|
||||
const cleanedText = _getInnerTextFromFloatingElement(div, selection.toString());
|
||||
|
||||
// set cleaned data to clipboard
|
||||
event.clipboardData?.setData('text/html', cleanedHtml);
|
||||
event.clipboardData?.setData('text/plain', cleanedText);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function _cleanElementForCopy(element: HTMLElement) {
|
||||
// remove elements marked with data-agi-no-copy (buttons, reasoning, citations, etc.)
|
||||
element.querySelectorAll('[data-agi-no-copy]').forEach((el) => el.remove());
|
||||
|
||||
// clean all elements
|
||||
[element, ...element.querySelectorAll('*')].forEach((el) => {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
// strip theme-dependent colors, but keeps formatting like font sizes
|
||||
['color', 'background', 'background-color'].forEach(p => el.style.removeProperty(p));
|
||||
|
||||
// preserve whitespace formatting for code elements (newlines would collapse otherwise)
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
if (tagName === 'pre' || tagName === 'code')
|
||||
el.style.whiteSpace = 'pre-wrap';
|
||||
|
||||
// remove framework/accessibility cruft
|
||||
el.removeAttribute('class');
|
||||
el.removeAttribute('tabindex');
|
||||
el.removeAttribute('role');
|
||||
[...el.attributes].filter(a => a.name.startsWith('aria-')).forEach(a => el.removeAttribute(a.name));
|
||||
});
|
||||
|
||||
// remove empty divs (wrapper cruft)
|
||||
element.querySelectorAll('div:empty').forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
/** Get properly formatted text from element (with line breaks for block elements) */
|
||||
function _getInnerTextFromFloatingElement(element: HTMLElement, fallback: string): string {
|
||||
// innerText requires element to be in DOM to respect CSS layout
|
||||
// Note: can't use visibility:hidden as innerText won't return text from hidden elements
|
||||
// Note: white-space:pre-wrap preserves newlines in code (partial selections may not include <code> wrapper)
|
||||
element.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;overflow:hidden;white-space:pre-wrap';
|
||||
document.body.appendChild(element);
|
||||
try {
|
||||
return element.innerText || fallback;
|
||||
} finally {
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ function _prettyTokenStopReason(reason: DMessageGenerator['tokenStopReason'], co
|
||||
}
|
||||
|
||||
|
||||
const oaiORegex = /gpt-[345](?:o|\.\d+)?-|o[1345]-|chatgpt-[45]o?|gpt-5-chat|computer-use-/;
|
||||
const oaiORegex = /gpt-[345](?:o|\.\d+)?-|o[1345]-|osb-|chatgpt-[45]o?|gpt-5-chat|computer-use-/;
|
||||
const geminiRegex = /gemini-|gemma-|learnlm-/;
|
||||
|
||||
|
||||
@@ -382,6 +382,7 @@ export function prettyShortChatModelName(model: string | undefined): string {
|
||||
.replace('chatgpt-', 'ChatGPT_')
|
||||
.replace('gpt-5-chat-', 'ChatGPT-5 ')
|
||||
.replace('gpt-', 'GPT_')
|
||||
.replace('osb-', 'OSB_')
|
||||
// feature variants
|
||||
.replace('-audio', ' Audio')
|
||||
.replace('-realtime-preview', ' Realtime')
|
||||
@@ -457,8 +458,14 @@ export function prettyShortChatModelName(model: string | undefined): string {
|
||||
// start past the last /, if any
|
||||
const lastSlashIndex = model.lastIndexOf('/');
|
||||
const modelName = lastSlashIndex === -1 ? model : model.slice(lastSlashIndex + 1);
|
||||
return modelName.replace('deepseek-', ' Deepseek ')
|
||||
.replace('reasoner', 'R1').replace('r1', 'R1')
|
||||
return modelName
|
||||
// map these for each release
|
||||
.replace('-reasoner', ' 3.2 Reasoner')
|
||||
.replace('-chat', ' 3.2 Chat')
|
||||
.replace('-v3', ' 3')
|
||||
// default replacements
|
||||
.replace('deepseek', 'Deepseek')
|
||||
.replace('speciale', 'Speciale').replace('@', ' ')
|
||||
.replaceAll('-', ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// /**
|
||||
// * Force Touch to Double Click - Mac trackpad force press triggers edit
|
||||
// *
|
||||
// * Converts force touch (deep press on Mac trackpads) into synthetic
|
||||
// * double-click events, enabling edit mode via force press.
|
||||
// *
|
||||
// * Architecture:
|
||||
// * - One-time global setup converts force touch → synthetic dblclick
|
||||
// * - Elements mark themselves with data-edit-intent attribute
|
||||
// * - Regular onDoubleClick handlers catch both real and synthetic events
|
||||
// *
|
||||
// * Usage:
|
||||
// * 1. Call initForceTouchToDoubleClick() once at app startup
|
||||
// * 2. Add data-edit-intent attribute to editable elements
|
||||
// * 3. Use regular onDoubleClick handler - it catches both
|
||||
// */
|
||||
//
|
||||
// // Feature detection - cached
|
||||
// let _forceTouchSupported: boolean | null = null;
|
||||
// const supportsForceTouch = (): boolean =>
|
||||
// (_forceTouchSupported ??= typeof MouseEvent !== 'undefined' && 'webkitForce' in MouseEvent.prototype);
|
||||
//
|
||||
// // One-time global setup
|
||||
// let _initialized = false;
|
||||
//
|
||||
// /**
|
||||
// * Initialize force touch to double-click conversion.
|
||||
// * Safe to call multiple times - guards against re-initialization.
|
||||
// */
|
||||
// function initForceTouchToDoubleClick(): void {
|
||||
// if (_initialized || typeof document === 'undefined' || !supportsForceTouch()) return;
|
||||
// _initialized = true;
|
||||
//
|
||||
// // Opt-in to force events on marked elements
|
||||
// document.addEventListener('webkitmouseforcewillbegin', (e) => {
|
||||
// if ((e.target as HTMLElement).closest?.('[data-edit-intent]')) {
|
||||
// e.preventDefault();
|
||||
// }
|
||||
// }, { capture: true });
|
||||
//
|
||||
// // Force touch → synthetic double-click
|
||||
// document.addEventListener('webkitmouseforcedown', (e) => {
|
||||
// const target = (e.target as HTMLElement).closest?.('[data-edit-intent]');
|
||||
// if (target) {
|
||||
// const me = e as MouseEvent;
|
||||
// target.dispatchEvent(new MouseEvent('dblclick', {
|
||||
// bubbles: true,
|
||||
// cancelable: true,
|
||||
// view: window,
|
||||
// clientX: me.clientX,
|
||||
// clientY: me.clientY,
|
||||
// }));
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// // Auto-initialize when this module is imported
|
||||
// initForceTouchToDoubleClick();
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Enrico Ros
|
||||
* Copyright (c) 2024-2026 Enrico Ros
|
||||
*
|
||||
* Functions to deal with images from the frontend.
|
||||
* Also see videoUtils.ts for more image-related functions.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Shared OCR utilities using Tesseract.js
|
||||
*
|
||||
* Used by:
|
||||
* - image-ocr converter (single image)
|
||||
* - pdf-auto converter (multi-page fallback)
|
||||
* - pdf-images-ocr converter (forced OCR on PDF pages)
|
||||
*/
|
||||
|
||||
import type { recognize as TesseractRecognize } from 'tesseract.js';
|
||||
|
||||
// Cache the Tesseract module to avoid re-importing on every call
|
||||
let cachedRecognize: typeof TesseractRecognize | null = null;
|
||||
|
||||
async function getTesseractRecognize(): Promise<typeof TesseractRecognize> {
|
||||
if (!cachedRecognize) {
|
||||
const tesseract = await import('tesseract.js');
|
||||
cachedRecognize = tesseract.recognize;
|
||||
}
|
||||
return cachedRecognize;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Result of OCR operation with quality metadata
|
||||
*/
|
||||
export interface OcrResult {
|
||||
text: string;
|
||||
avgCharsPerPage: number;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* OCR a single image with progress tracking
|
||||
*
|
||||
* @param imageData - Blob or base64 data URL
|
||||
* @param onProgress - Progress callback (0-1)
|
||||
* @returns Extracted text
|
||||
*/
|
||||
export async function ocrImageWithProgress(
|
||||
imageData: Blob | string,
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<string> {
|
||||
const recognize = await getTesseractRecognize();
|
||||
let lastProgress = -1;
|
||||
|
||||
const { data: page } = await recognize(imageData, undefined, {
|
||||
errorHandler: e => {
|
||||
// NOTE: shall we inform the user about the error?
|
||||
console.error('[OCR Error]', e);
|
||||
},
|
||||
logger: (message) => {
|
||||
if (!onProgress || message.status !== 'recognizing text')
|
||||
return;
|
||||
if (message.progress > lastProgress + 0.01) {
|
||||
lastProgress = message.progress;
|
||||
onProgress(message.progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log('OCR', {page});
|
||||
|
||||
return page.text;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* OCR multiple PDF page images with cumulative progress tracking
|
||||
*
|
||||
* @param imageDataURLs - Array of rendered PDF page images
|
||||
* @param onProgress - Progress callback (0-1, cumulative across all pages)
|
||||
* @returns Combined text from all pages with quality metadata
|
||||
*/
|
||||
export async function ocrPdfPagesWithProgress(
|
||||
imageDataURLs: Array<{ mimeType: string; base64Data: string }>,
|
||||
onProgress?: (progress: number) => void,
|
||||
): Promise<OcrResult> {
|
||||
|
||||
const pageTexts: string[] = [];
|
||||
const totalPages = imageDataURLs.length;
|
||||
|
||||
for (let pageNum = 0; pageNum < totalPages; pageNum++) {
|
||||
const pageImage = imageDataURLs[pageNum];
|
||||
|
||||
// Convert base64 to data URL for Tesseract
|
||||
const dataUrl = `data:${pageImage.mimeType};base64,${pageImage.base64Data}`;
|
||||
const pageText = await ocrImageWithProgress(dataUrl,
|
||||
(pageProgress) => {
|
||||
// Distribute progress across all pages
|
||||
const cumulativeProgress = (pageNum + pageProgress) / totalPages;
|
||||
onProgress?.(cumulativeProgress);
|
||||
},
|
||||
);
|
||||
|
||||
pageTexts.push(pageText);
|
||||
|
||||
// Update progress after each page completes
|
||||
onProgress?.((pageNum + 1) / totalPages);
|
||||
}
|
||||
|
||||
// Combine pages with informative separators for multi-page PDFs
|
||||
const combinedText = pageTexts
|
||||
.map((text, i) => `--- Page ${i + 1}/${totalPages} (OCR) ---\n\n${text}`)
|
||||
.join('\n\n') + `\n\n--- End of OCR Document | If content is missing or garbled, re-attach PDF as images ---`;
|
||||
|
||||
const trimmedLength = combinedText.trim().length;
|
||||
|
||||
return {
|
||||
text: combinedText,
|
||||
pageCount: totalPages,
|
||||
avgCharsPerPage: totalPages > 0 ? trimmedLength / totalPages : 0,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,16 @@ import { canvasToDataURLAndMimeType } from './canvasUtils';
|
||||
// configuration
|
||||
const SKIP_LOADING_IN_DEV = false;
|
||||
|
||||
|
||||
/**
|
||||
* Result of PDF text extraction with quality metadata
|
||||
*/
|
||||
export interface PdfTextResult {
|
||||
text: string;
|
||||
pageCount: number;
|
||||
avgCharsPerPage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text from a PDF file
|
||||
*
|
||||
@@ -14,15 +24,16 @@ const SKIP_LOADING_IN_DEV = false;
|
||||
*
|
||||
* @param pdfBuffer The content of a PDF file
|
||||
* @param onProgress A callback function to report the progress of the text extraction
|
||||
* @returns Text content with quality metadata (pageCount, avgCharsPerPage)
|
||||
*/
|
||||
export async function pdfToText(pdfBuffer: ArrayBuffer, onProgress: (progress: number) => void): Promise<string> {
|
||||
export async function pdfToText(pdfBuffer: ArrayBuffer, onProgress: (progress: number) => void): Promise<PdfTextResult> {
|
||||
const { getDocument } = await dynamicImportPdfJs().catch(error => {
|
||||
console.warn('pdfToText: Failed to load pdfjs-dist', error);
|
||||
return { getDocument: null };
|
||||
});
|
||||
if (!getDocument) {
|
||||
console.log('pdfToText: [dev] pdfjs-dist loading skipped');
|
||||
return '';
|
||||
return { text: '', pageCount: 0, avgCharsPerPage: 0 };
|
||||
}
|
||||
const pdf = await getDocument({ data: pdfBuffer }).promise;
|
||||
const textPages: string[] = []; // Initialize an array to hold text from all pages
|
||||
@@ -65,7 +76,14 @@ export async function pdfToText(pdfBuffer: ArrayBuffer, onProgress: (progress: n
|
||||
}
|
||||
|
||||
onProgress(1);
|
||||
return textPages.join('\n\n'); // Join all the page texts at the end
|
||||
const text = textPages.join('\n\n');
|
||||
const pageCount = pdf.numPages;
|
||||
const trimmedLength = text.trim().length;
|
||||
return {
|
||||
text,
|
||||
pageCount,
|
||||
avgCharsPerPage: pageCount > 0 ? trimmedLength / pageCount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,47 +9,28 @@ export function prettyTimestampForFilenames(useSeconds: boolean = true) {
|
||||
return `${year}-${month}-${day}-${hour}${minute}${useSeconds ? second : ''}`; // YYYY-MM-DD_HHMM[SS] format
|
||||
}
|
||||
|
||||
export function getLocalMidnightInUTCTimestamp(): number {
|
||||
const midnight = new Date();
|
||||
// midnight.setDate(midnight.getDate() - 1);
|
||||
midnight.setHours(24, 0, 0, 0);
|
||||
return midnight.getTime();
|
||||
}
|
||||
/**
|
||||
* Creates a time bucket classifier with precomputed calendar boundaries.
|
||||
* Buckets: Today, Yesterday, This Week, This Month, Last Month, Older
|
||||
* Call once, then use returned function for each item - avoids redundant Date computations.
|
||||
*/
|
||||
export function createTimeBucketClassifierEn() {
|
||||
const now = new Date(), y = now.getFullYear(), m = now.getMonth();
|
||||
const todayMs = new Date(y, m, now.getDate()).getTime();
|
||||
const DAY_MS = 86400000;
|
||||
const yesterdayMs = todayMs - DAY_MS;
|
||||
// Week starts Monday (ISO 8601) - locale-aware: new Intl.Locale(navigator.language).getWeekInfo?.().firstDay
|
||||
const weekStartMs = todayMs - ((now.getDay() + 6) % 7) * DAY_MS;
|
||||
const monthStartMs = new Date(y, m, 1).getTime();
|
||||
const lastMonthStartMs = new Date(y, m - 1, 1).getTime();
|
||||
|
||||
export function getTimeBucketEn(itemTimeStamp: number, midnightTimestamp: number): string {
|
||||
const oneHour = 60 * 60 * 1000;
|
||||
const oneDay = oneHour * 24;
|
||||
const oneWeek = oneDay * 7;
|
||||
const oneMonth = oneDay * 30; // approximation
|
||||
|
||||
// relative time
|
||||
const relDiff = Date.now() - itemTimeStamp;
|
||||
if (relDiff < oneHour)
|
||||
return 'Last Hour';
|
||||
|
||||
// midnight-relative time
|
||||
const diff = midnightTimestamp - itemTimeStamp;
|
||||
if (diff < oneDay) {
|
||||
// if (diff > oneDay / 2)
|
||||
// return 'This morning';
|
||||
// else if (diff > oneDay / 4)
|
||||
// return 'This afternoon';
|
||||
// else
|
||||
// return 'This evening';
|
||||
return 'Today';
|
||||
} else if (diff < oneDay * 2) {
|
||||
return 'Yesterday';
|
||||
} else if (diff < oneDay * 3) {
|
||||
return 'Two Days Ago';
|
||||
} else if (diff < oneWeek) {
|
||||
return 'This Week';
|
||||
} else if (diff < oneWeek * 2) {
|
||||
return 'Last Week';
|
||||
} else if (diff < oneMonth) {
|
||||
return 'This Month';
|
||||
} else if (diff < oneMonth * 2) {
|
||||
return 'Last Month';
|
||||
} else {
|
||||
return (itemTimestamp: number): string => {
|
||||
const t = new Date(itemTimestamp).setHours(0, 0, 0, 0);
|
||||
if (t >= todayMs) return 'Today';
|
||||
if (t >= yesterdayMs) return 'Yesterday';
|
||||
if (t >= weekStartMs) return 'This Week';
|
||||
if (t >= monthStartMs) return 'This Month';
|
||||
if (t >= lastMonthStartMs) return 'Last Month';
|
||||
return 'Older';
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,42 @@ export function urlPrettyHref(href: string, removeHttps: boolean, removeTrailing
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a URL hostname points to a local/private network address.
|
||||
* Matches: localhost, 127.x.x.x, 192.168.x.x, 10.x.x.x, 172.16-31.x.x, ::1, etc.
|
||||
*/
|
||||
export function isLocalUrl(url: string | null | undefined): boolean {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
|
||||
// localhost
|
||||
if (hostname === 'localhost') return true;
|
||||
|
||||
// IPv6 loopback
|
||||
if (hostname === '::1' || hostname === '[::1]') return true;
|
||||
|
||||
// IPv4 patterns
|
||||
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number);
|
||||
// 127.x.x.x (loopback)
|
||||
if (a === 127) return true;
|
||||
// 10.x.x.x (private class A)
|
||||
if (a === 10) return true;
|
||||
// 172.16.x.x - 172.31.x.x (private class B)
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.x.x (private class C)
|
||||
if (a === 192 && b === 168) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* If the string is a valid URL, return it. Otherwise, return null.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ When asked to design or draw something, please work step by step detailing the c
|
||||
Custom: {
|
||||
title: 'Custom',
|
||||
description: 'Define the persona, or task:',
|
||||
systemMessage: 'You are ChatGPT, a large language model trained by OpenAI, based on the GPT-4 architecture.\nCurrent date: {{Today}}',
|
||||
systemMessage: 'You are an AI assistant.\nCurrent date: {{Today}}',
|
||||
symbol: '⚡',
|
||||
call: { starters: ['What\'s the task?', 'What can I do?', 'Ready for your task.', 'Yes?'] },
|
||||
voices: { elevenLabs: { voiceId: 'flq6f7yk4E4fJM5XTYuZ' } },
|
||||
|
||||
@@ -45,12 +45,13 @@ export class ContentReassembler {
|
||||
constructor(
|
||||
private readonly accumulator: AixChatGenerateContent_LL,
|
||||
private readonly onAccumulatorUpdated?: () => MaybePromise<void>,
|
||||
inspectorTransport?: AixClientDebugger.Transport,
|
||||
inspectorContext?: AixClientDebugger.Context,
|
||||
private readonly wireAbortSignal?: AbortSignal,
|
||||
) {
|
||||
|
||||
// [SUDO] Debugging the request, last-write-wins for the global (displayed in the UI)
|
||||
this.debuggerFrameId = !inspectorContext ? null : aixClientDebugger_init(inspectorContext);
|
||||
this.debuggerFrameId = !inspectorContext ? null : aixClientDebugger_init(inspectorTransport ?? 'trpc', inspectorContext);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Immutable } from '~/common/types/immutable.types';
|
||||
import { getImageAsset } from '~/common/stores/blob/dblobs-portability';
|
||||
|
||||
import { DLLM, LLM_IF_HOTFIX_NoStream, LLM_IF_HOTFIX_StripImages, LLM_IF_HOTFIX_StripSys0, LLM_IF_HOTFIX_Sys0ToUsr0 } from '~/common/stores/llms/llms.types';
|
||||
import { DLLM, LLM_IF_HOTFIX_NoStream, LLM_IF_HOTFIX_NoWebP, LLM_IF_HOTFIX_StripImages, LLM_IF_HOTFIX_StripSys0, LLM_IF_HOTFIX_Sys0ToUsr0 } from '~/common/stores/llms/llms.types';
|
||||
import { DMessage, DMessageRole, DMetaReferenceItem, MESSAGE_FLAG_AIX_SKIP, MESSAGE_FLAG_VND_ANT_CACHE_AUTO, MESSAGE_FLAG_VND_ANT_CACHE_USER, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { DMessageFragment, DMessageImageRefPart, DMessageZyncAssetReferencePart, isContentOrAttachmentFragment, isToolResponseFunctionCallPart, isVoidThinkingFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import { Is } from '~/common/util/pwaUtils';
|
||||
import { convert_Base64WithMimeType_To_Blob, convert_Blob_To_Base64 } from '~/common/util/blobUtils';
|
||||
import { imageBlobResizeIfNeeded, LLMImageResizeMode } from '~/common/util/imageUtils';
|
||||
import { imageBlobConvertType, imageBlobResizeIfNeeded, LLMImageResizeMode } from '~/common/util/imageUtils';
|
||||
|
||||
// NOTE: pay particular attention to the "import type", as this is importing from the server-side Zod definitions
|
||||
import type { AixAPIChatGenerate_Request, AixMessages_ModelMessage, AixMessages_ToolMessage, AixMessages_UserMessage, AixParts_InlineImagePart, AixParts_MetaCacheControl, AixParts_MetaInReferenceToPart, AixParts_ModelAuxPart } from '../server/api/aix.wiretypes';
|
||||
@@ -641,10 +641,10 @@ function _clientCreateAixMetaInReferenceToPart(items: DMetaReferenceItem[]): Aix
|
||||
/// Client-side hotfixes
|
||||
|
||||
|
||||
export function clientHotFixGenerateRequest_ApplyAll(llmInterfaces: DLLM['interfaces'], aixChatGenerate: AixAPIChatGenerate_Request, modelName: string): {
|
||||
export async function clientHotFixGenerateRequest_ApplyAll(llmInterfaces: DLLM['interfaces'], aixChatGenerate: AixAPIChatGenerate_Request, modelName: string): Promise<{
|
||||
shallDisableStreaming: boolean;
|
||||
workaroundsCount: number;
|
||||
} {
|
||||
}> {
|
||||
|
||||
let workaroundsCount = 0;
|
||||
|
||||
@@ -660,6 +660,10 @@ export function clientHotFixGenerateRequest_ApplyAll(llmInterfaces: DLLM['interf
|
||||
if (llmInterfaces.includes(LLM_IF_HOTFIX_StripImages))
|
||||
workaroundsCount += clientHotFixGenerateRequest_StripImages(aixChatGenerate);
|
||||
|
||||
// Apply the no-webp hot fix - convert WebP images to JPEG (smaller) or PNG (lossless)
|
||||
if (llmInterfaces.includes(LLM_IF_HOTFIX_NoWebP))
|
||||
workaroundsCount += await clientHotFixGenerateRequest_ConvertWebP(aixChatGenerate, 'image/jpeg');
|
||||
|
||||
// Disable streaming for select chat models that don't support it (e.g. o1-preview (old) and o1-2024-12-17)
|
||||
const shallDisableStreaming = llmInterfaces.includes(LLM_IF_HOTFIX_NoStream);
|
||||
|
||||
@@ -702,6 +706,35 @@ function clientHotFixGenerateRequest_StripImages(aixChatGenerate: AixAPIChatGene
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot fix for models that don't support WebP images - converts to JPEG or PNG
|
||||
*/
|
||||
async function clientHotFixGenerateRequest_ConvertWebP(aixChatGenerate: AixAPIChatGenerate_Request, toFormat: 'image/jpeg' | 'image/png'): Promise<number> {
|
||||
|
||||
let workaroundsCount = 0;
|
||||
const quality = toFormat === 'image/jpeg' ? 0.92 : 1.0;
|
||||
|
||||
for (const message of aixChatGenerate.chatSequence) {
|
||||
for (let j = 0; j < message.parts.length; j++) {
|
||||
const part = message.parts[j];
|
||||
if (part.pt === 'inline_image' && part.mimeType === 'image/webp') {
|
||||
try {
|
||||
const webpBlob = await convert_Base64WithMimeType_To_Blob(part.base64, 'image/webp', 'hotfix-no-webp');
|
||||
const { blob: convertedBlob } = await imageBlobConvertType(webpBlob, toFormat, quality);
|
||||
const convertedBase64 = await convert_Blob_To_Base64(convertedBlob, 'hotfix-no-webp');
|
||||
message.parts[j] = { pt: 'inline_image', mimeType: toFormat, base64: convertedBase64 };
|
||||
workaroundsCount++;
|
||||
} catch (error) {
|
||||
console.warn('[DEV] clientHotFixGenerateRequest_ConvertWebP: Error converting image:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return workaroundsCount;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot fix for models that don't want the system message - e.g. Gemini Image Generation (although this may change)
|
||||
*/
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
|
||||
// IMPORTANT: client-side bundle imports server-side code including stubbed code
|
||||
import type { AixAPI_Access, AixAPI_Model, AixAPIChatGenerate_Request, AixWire_Particles } from '../server/api/aix.wiretypes';
|
||||
import type { AixAPI_Access, AixAPI_ConnectionOptions_ChatGenerate, AixAPI_Context_ChatGenerate, AixAPI_Model, AixAPIChatGenerate_Request, AixWire_Particles } from '../server/api/aix.wiretypes';
|
||||
import type { AixDebugObject } from '../server/dispatch/chatGenerate/chatGenerate.debug';
|
||||
import { AIX_INSPECTOR_ALLOWED_CONTEXTS, AIX_SECURITY_ONLY_IN_DEV_BUILDS } from '../server/api/aix.security';
|
||||
import { createChatGenerateDispatch } from '../server/dispatch/chatGenerate/chatGenerate.dispatch';
|
||||
import { executeChatGenerateWithRetry } from '../server/dispatch/chatGenerate/chatGenerate.retrier';
|
||||
|
||||
@@ -22,24 +23,27 @@ export async function* clientSideChatGenerate(
|
||||
access: AixAPI_Access,
|
||||
model: AixAPI_Model,
|
||||
chatGenerate: AixAPIChatGenerate_Request,
|
||||
context: AixAPI_Context_ChatGenerate,
|
||||
streaming: boolean,
|
||||
connectionOptions: AixAPI_ConnectionOptions_ChatGenerate,
|
||||
abortSignal: AbortSignal,
|
||||
enableResumability: boolean = false,
|
||||
): AsyncGenerator<AixWire_Particles.ChatGenerateOp, void> {
|
||||
// keep in sync with the `aixRouter.chatGenerateContent` server-side procedure
|
||||
const _d: AixDebugObject = _createClientDebugConfig(access);
|
||||
const chatGenerateDispatchCreator = () => createChatGenerateDispatch(access, model, chatGenerate, streaming, enableResumability);
|
||||
const _d: AixDebugObject = _createClientDebugConfig(access, connectionOptions, context.name);
|
||||
const chatGenerateDispatchCreator = () => createChatGenerateDispatch(access, model, chatGenerate, streaming, !!connectionOptions?.enableResumability);
|
||||
|
||||
yield* executeChatGenerateWithRetry(chatGenerateDispatchCreator, streaming, abortSignal, _d);
|
||||
}
|
||||
|
||||
// CSF debug config - lighter than server-side
|
||||
function _createClientDebugConfig(access: AixAPI_Access): AixDebugObject {
|
||||
function _createClientDebugConfig(access: AixAPI_Access, options: undefined | { debugDispatchRequest?: boolean, debugProfilePerformance?: boolean, debugRequestBodyOverride?: Record<string, unknown> }, chatGenerateContextName: string): AixDebugObject {
|
||||
const echoRequest = !!options?.debugDispatchRequest && (AIX_SECURITY_ONLY_IN_DEV_BUILDS || AIX_INSPECTOR_ALLOWED_CONTEXTS.includes(chatGenerateContextName));
|
||||
return {
|
||||
prettyDialect: capitalizeFirstLetter(access.dialect),
|
||||
echoRequest: false, // disable request echo on client, as one can inspect fetch directly
|
||||
consoleLogErrors: false, // don't log to console on client (handled by UI)
|
||||
profiler: undefined,
|
||||
wire: undefined,
|
||||
prettyDialect: capitalizeFirstLetter(access.dialect), // string
|
||||
echoRequest: echoRequest, // boolean
|
||||
requestBodyOverride: echoRequest ? options?.debugRequestBodyOverride : undefined,
|
||||
consoleLogErrors: false, // NO client-side error-echo log to console (handled by UI)
|
||||
profiler: undefined, // NO client-side profiler
|
||||
wire: undefined, // NO client-side wire
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,44 +39,3 @@
|
||||
// },
|
||||
// ];
|
||||
// }
|
||||
|
||||
/**
|
||||
* OpenAI-specific moderation check. This is a separate function, as it's not part of the
|
||||
* streaming chat generation, but it's a pre-check before we even start the streaming.
|
||||
*
|
||||
* @returns null if the message is safe, or a string with the user message if it's not safe
|
||||
*/
|
||||
/* NOTE: NOT PORTED TO AIX YET, this was the former "LLMS" implementation
|
||||
async function _openAIModerationCheck(access: OpenAIAccessSchema, lastMessage: ... | null): Promise<string | null> {
|
||||
if (!lastMessage || lastMessage.role !== 'user')
|
||||
return null;
|
||||
|
||||
try {
|
||||
const moderationResult = await apiAsync.llmOpenAI.moderation.mutate({
|
||||
access, text: lastMessage.content,
|
||||
});
|
||||
const issues = moderationResult.results.reduce((acc, result) => {
|
||||
if (result.flagged) {
|
||||
Object
|
||||
.entries(result.categories)
|
||||
.filter(([_, value]) => value)
|
||||
.forEach(([key, _]) => acc.add(key));
|
||||
}
|
||||
return acc;
|
||||
}, new Set<string>());
|
||||
|
||||
// if there's any perceived violation, we stop here
|
||||
if (issues.size) {
|
||||
const categoriesText = [...issues].map(c => `\`${c}\``).join(', ');
|
||||
// do not proceed with the streaming request
|
||||
return `[Moderation] I an unable to provide a response to your query as it violated the following categories of the OpenAI usage policies: ${categoriesText}.\nFor further explanation please visit https://platform.openai.com/docs/guides/moderation/moderation`;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// as the moderation check was requested, we cannot proceed in case of error
|
||||
return '[Issue] There was an error while checking for harmful content. ' + error?.toString();
|
||||
}
|
||||
|
||||
// moderation check was successful
|
||||
return null;
|
||||
}
|
||||
*/
|
||||
@@ -11,6 +11,7 @@ import { findLLMOrThrow } from '~/common/stores/llms/store-llms';
|
||||
import { getAixInspectorEnabled } from '~/common/stores/store-ui';
|
||||
import { getLabsDevNoStreaming } from '~/common/stores/store-ux-labs';
|
||||
import { metricsStoreAddChatGenerate } from '~/common/stores/metrics/store-metrics';
|
||||
import { stripUndefined } from '~/common/util/objectUtils';
|
||||
import { webGeolocationCached } from '~/common/util/webGeolocationUtils';
|
||||
|
||||
// NOTE: pay particular attention to the "import type", as this is importing from the server-side Zod definitions
|
||||
@@ -20,6 +21,7 @@ import { AixStreamRetry } from './aix.client.retry';
|
||||
import { ContentReassembler } from './ContentReassembler';
|
||||
import { aixCGR_ChatSequence_FromDMessagesOrThrow, aixCGR_FromSimpleText, aixCGR_SystemMessage_FromDMessageOrThrow, AixChatGenerate_TextMessages, clientHotFixGenerateRequest_ApplyAll } from './aix.client.chatGenerateRequest';
|
||||
import { aixClassifyStreamingError } from './aix.client.errors';
|
||||
import { aixClientDebuggerGetRBO } from './debugger/memstore-aix-client-debugger';
|
||||
import { withDecimator } from './withDecimator';
|
||||
|
||||
|
||||
@@ -46,14 +48,16 @@ export function aixCreateModelFromLLMOptions(
|
||||
|
||||
// destructure input with the overrides
|
||||
const {
|
||||
llmRef, llmTemperature, llmResponseTokens, llmTopP,
|
||||
llmRef, llmTemperature, llmResponseTokens, llmTopP, llmForceNoStream,
|
||||
llmVndAnt1MContext, llmVndAntSkills, llmVndAntThinkingBudget, llmVndAntWebFetch, llmVndAntWebSearch, llmVndAntEffort,
|
||||
llmVndGeminiAspectRatio, llmVndGeminiImageSize, llmVndGeminiCodeExecution, llmVndGeminiComputerUse, llmVndGeminiGoogleSearch, llmVndGeminiMediaResolution, llmVndGeminiShowThoughts, llmVndGeminiThinkingBudget, llmVndGeminiThinkingLevel,
|
||||
llmVndGeminiAspectRatio, llmVndGeminiImageSize, llmVndGeminiCodeExecution, llmVndGeminiComputerUse, llmVndGeminiGoogleSearch, llmVndGeminiMediaResolution, llmVndGeminiShowThoughts, llmVndGeminiThinkingBudget, llmVndGeminiThinkingLevel, llmVndGeminiThinkingLevel4,
|
||||
llmVndMoonReasoningEffort, // -> mapped to vndOaiReasoningEffort below
|
||||
// llmVndMoonshotWebSearch,
|
||||
llmVndOaiReasoningEffort, llmVndOaiReasoningEffort4, llmVndOaiRestoreMarkdown, llmVndOaiVerbosity, llmVndOaiWebSearchContext, llmVndOaiWebSearchGeolocation, llmVndOaiImageGeneration,
|
||||
llmVndOaiReasoningEffort, llmVndOaiReasoningEffort4, llmVndOaiReasoningEffort52, llmVndOaiReasoningEffort52Pro, llmVndOaiRestoreMarkdown, llmVndOaiVerbosity, llmVndOaiWebSearchContext, llmVndOaiWebSearchGeolocation, llmVndOaiImageGeneration, llmVndOaiCodeInterpreter,
|
||||
llmVndOrtWebSearch,
|
||||
llmVndPerplexityDateFilter, llmVndPerplexitySearchMode,
|
||||
llmVndXaiSearchMode, llmVndXaiSearchSources, llmVndXaiSearchDateFilter,
|
||||
// xAI
|
||||
llmVndXaiCodeExecution, llmVndXaiSearchInterval, llmVndXaiWebSearch, llmVndXaiXSearch, llmVndXaiXSearchHandles,
|
||||
} = {
|
||||
...llmOptions,
|
||||
...llmOptionOverrides,
|
||||
@@ -94,12 +98,13 @@ export function aixCreateModelFromLLMOptions(
|
||||
console.log(`[DEV] AIX: Geolocation is requested for model ${debugLlmId}, but it's not available.`);
|
||||
}
|
||||
|
||||
return {
|
||||
return stripUndefined({
|
||||
id: llmRef,
|
||||
acceptsOutputs: acceptsOutputs,
|
||||
...(hotfixOmitTemperature ? { temperature: null } : llmTemperature !== undefined ? { temperature: llmTemperature } : {}),
|
||||
...(llmResponseTokens /* null: similar to undefined, will omit the value */ ? { maxTokens: llmResponseTokens } : {}),
|
||||
...(llmTopP !== undefined ? { topP: llmTopP } : {}),
|
||||
...(llmForceNoStream ? { forceNoStream: true } : {}),
|
||||
...(llmVndAntThinkingBudget !== undefined ? { vndAntThinkingBudget: llmVndAntThinkingBudget } : {}),
|
||||
...(llmVndAnt1MContext ? { vndAnt1MContext: llmVndAnt1MContext } : {}),
|
||||
...(llmVndAntSkills ? { vndAntSkills: llmVndAntSkills } : {}),
|
||||
@@ -117,23 +122,32 @@ export function aixCreateModelFromLLMOptions(
|
||||
...(llmVndGeminiMediaResolution ? { vndGeminiMediaResolution: llmVndGeminiMediaResolution } : {}),
|
||||
...(llmVndGeminiShowThoughts ? { vndGeminiShowThoughts: llmVndGeminiShowThoughts } : {}),
|
||||
...(llmVndGeminiThinkingBudget !== undefined ? { vndGeminiThinkingBudget: llmVndGeminiThinkingBudget } : {}),
|
||||
...(llmVndGeminiThinkingLevel ? { vndGeminiThinkingLevel: llmVndGeminiThinkingLevel } : {}),
|
||||
...((llmVndGeminiThinkingLevel || llmVndGeminiThinkingLevel4) ? { vndGeminiThinkingLevel: llmVndGeminiThinkingLevel4 || llmVndGeminiThinkingLevel } : {}), // map both 2-level and 4-level thinking params to the same wire field
|
||||
// ...(llmVndGeminiUrlContext === 'auto' ? { vndGeminiUrlContext: llmVndGeminiUrlContext } : {}),
|
||||
// [Moonshot] Map to vndOaiReasoningEffort - adapter converts to thinking format
|
||||
...((llmVndMoonReasoningEffort && !llmVndOaiReasoningEffort) ? { vndOaiReasoningEffort: llmVndMoonReasoningEffort } : {}),
|
||||
// ...(llmVndMoonshotWebSearch === 'auto' ? { vndMoonshotWebSearch: 'auto' } : {}),
|
||||
...(llmVndOaiResponsesAPI ? { vndOaiResponsesAPI: true } : {}),
|
||||
...((llmVndOaiReasoningEffort4 || llmVndOaiReasoningEffort) ? { vndOaiReasoningEffort: llmVndOaiReasoningEffort4 || llmVndOaiReasoningEffort } : {}),
|
||||
...((llmVndOaiReasoningEffort52Pro || llmVndOaiReasoningEffort52 || llmVndOaiReasoningEffort4 || llmVndOaiReasoningEffort) ? {
|
||||
vndOaiReasoningEffort: llmVndOaiReasoningEffort52Pro || llmVndOaiReasoningEffort52 || llmVndOaiReasoningEffort4 || llmVndOaiReasoningEffort,
|
||||
vndOaiReasoningSummary: llmForceNoStream ? 'none' /* we disable the summaries, to not require org verification */ : 'detailed',
|
||||
} : {}),
|
||||
...(llmVndOaiRestoreMarkdown ? { vndOaiRestoreMarkdown: llmVndOaiRestoreMarkdown } : {}),
|
||||
...(llmVndOaiVerbosity ? { vndOaiVerbosity: llmVndOaiVerbosity } : {}),
|
||||
...(llmVndOaiWebSearchContext ? { vndOaiWebSearchContext: llmVndOaiWebSearchContext } : {}),
|
||||
...(llmVndOaiImageGeneration ? { vndOaiImageGeneration: (llmVndOaiImageGeneration as any /* backward comp */) === true ? 'mq' : llmVndOaiImageGeneration } : {}),
|
||||
...(llmVndOaiCodeInterpreter === 'auto' ? { vndOaiCodeInterpreter: llmVndOaiCodeInterpreter } : {}),
|
||||
...(llmVndOrtWebSearch === 'auto' ? { vndOrtWebSearch: 'auto' } : {}),
|
||||
...(llmVndPerplexityDateFilter ? { vndPerplexityDateFilter: llmVndPerplexityDateFilter } : {}),
|
||||
...(llmVndPerplexitySearchMode ? { vndPerplexitySearchMode: llmVndPerplexitySearchMode } : {}),
|
||||
...(userGeolocation ? { userGeolocation } : {}),
|
||||
...(llmVndXaiSearchMode ? { vndXaiSearchMode: llmVndXaiSearchMode } : {}),
|
||||
...(llmVndXaiSearchSources ? { vndXaiSearchSources: llmVndXaiSearchSources } : {}),
|
||||
...(llmVndXaiSearchDateFilter ? { vndXaiSearchDateFilter: llmVndXaiSearchDateFilter } : {}),
|
||||
};
|
||||
// xAI
|
||||
...(llmVndXaiCodeExecution === 'auto' ? { vndXaiCodeExecution: llmVndXaiCodeExecution } : {}),
|
||||
...(llmVndXaiSearchInterval ? { vndXaiSearchInterval: llmVndXaiSearchInterval } : {}),
|
||||
...(llmVndXaiWebSearch === 'auto' ? { vndXaiWebSearch: llmVndXaiWebSearch } : {}),
|
||||
...(llmVndXaiXSearch === 'auto' ? { vndXaiXSearch: llmVndXaiXSearch } : {}),
|
||||
...(llmVndXaiXSearchHandles ? { vndXaiXSearchHandles: llmVndXaiXSearchHandles } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +310,7 @@ export async function aixChatGenerateText_Simple(
|
||||
|
||||
|
||||
// Client-side late stage model HotFixes
|
||||
const { shallDisableStreaming } = clientHotFixGenerateRequest_ApplyAll(llm.interfaces, aixChatGenerate, llmParameters.llmRef || llm.id);
|
||||
const { shallDisableStreaming } = await clientHotFixGenerateRequest_ApplyAll(llm.interfaces, aixChatGenerate, llmParameters.llmRef || llm.id);
|
||||
if (shallDisableStreaming || aixModel.forceNoStream)
|
||||
aixStreaming = false;
|
||||
|
||||
@@ -424,7 +438,6 @@ function _llToText(src: AixChatGenerateContent_LL, dest: AixChatGenerateText_Sim
|
||||
* - vendor-specific rate limit
|
||||
* - 'pendingIncomplete' logic
|
||||
* - 'o1-preview' hotfix for OpenAI models
|
||||
* - [NOT PORTED YET: checks for harmful content with the free 'moderation' API (OpenAI-only)]
|
||||
*
|
||||
* @param llmId - ID of the Language Model to use
|
||||
* @param aixChatGenerate - Multi-modal chat generation request specifics, including Tools and high-level metadata
|
||||
@@ -456,17 +469,11 @@ export async function aixChatGenerateContent_DMessage_orThrow<TServiceSettings e
|
||||
const aixModel = aixCreateModelFromLLMOptions(llm.interfaces, llmParameters, clientOptions?.llmOptionsOverride, llmId);
|
||||
|
||||
// Client-side late stage model HotFixes
|
||||
const { shallDisableStreaming } = clientHotFixGenerateRequest_ApplyAll(llm.interfaces, aixChatGenerate, llmParameters.llmRef || llm.id);
|
||||
const { shallDisableStreaming } = await clientHotFixGenerateRequest_ApplyAll(llm.interfaces, aixChatGenerate, llmParameters.llmRef || llm.id);
|
||||
if (shallDisableStreaming || aixModel.forceNoStream)
|
||||
aixStreaming = false;
|
||||
|
||||
|
||||
// [OpenAI-only] check for harmful content with the free 'moderation' API, if the user requests so
|
||||
// if (aixAccess.dialect === 'openai' && aixAccess.moderationCheck) {
|
||||
// const moderationUpdate = await _openAIModerationCheck(aixAccess, messages.at(-1) ?? null);
|
||||
// if (moderationUpdate)
|
||||
// return onUpdate({ textSoFar: moderationUpdate, typing: false }, true);
|
||||
// }
|
||||
// Legacy Note: awaited OpenAI moderation check was removed (was only on this codepath)
|
||||
|
||||
// Aix Low-Level Chat Generation
|
||||
const dMessage: AixChatGenerateContent_DMessageGuts = {
|
||||
@@ -620,8 +627,13 @@ async function _aixChatGenerateContent_LL(
|
||||
|
||||
// Inspector support - can be requested by the client, but granted on the server side
|
||||
const inspectorEnabled = getAixInspectorEnabled();
|
||||
const inspectorTransport = inspectorEnabled ? aixAccess.clientSideFetch ? 'csf' as const : 'trpc' as const : undefined;
|
||||
const inspectorContext = inspectorEnabled ? { contextName: aixContext.name, contextRef: aixContext.ref } : undefined;
|
||||
|
||||
// [DEV] Inspector - request body override
|
||||
const requestBodyOverrideJson = inspectorEnabled && aixClientDebuggerGetRBO();
|
||||
const debugRequestBodyOverride = !requestBodyOverrideJson ? false : JSON.parse(requestBodyOverrideJson);
|
||||
|
||||
/**
|
||||
* FIXME: implement client selection of resumability - aixAccess option?
|
||||
* For now we turn it on for Responses API for select kinds of request.
|
||||
@@ -631,6 +643,7 @@ async function _aixChatGenerateContent_LL(
|
||||
|
||||
const aixConnectionOptions: AixAPI_ConnectionOptions_ChatGenerate = {
|
||||
...inspectorEnabled && { debugDispatchRequest: true, debugProfilePerformance: true },
|
||||
...debugRequestBodyOverride && { debugRequestBodyOverride },
|
||||
// FIXME: disabled until clearly working
|
||||
// ...requestResumability && { enableResumability: true },
|
||||
} as const;
|
||||
@@ -682,6 +695,7 @@ async function _aixChatGenerateContent_LL(
|
||||
const reassembler = new ContentReassembler(
|
||||
accumulator_LL, // FIXME: TEMP: moved the accumulator outside to keep appending to it (recreating new ContentReassembler each retry)
|
||||
sendContentUpdate,
|
||||
inspectorTransport,
|
||||
inspectorContext,
|
||||
abortSignal,
|
||||
);
|
||||
@@ -696,9 +710,10 @@ async function _aixChatGenerateContent_LL(
|
||||
aixAccess,
|
||||
aixModel,
|
||||
aixChatGenerate,
|
||||
aixContext,
|
||||
getLabsDevNoStreaming() ? false : aixStreaming,
|
||||
aixConnectionOptions,
|
||||
abortSignal,
|
||||
!!aixConnectionOptions?.enableResumability,
|
||||
);
|
||||
|
||||
// AIX tRPC Streaming Generation from Chat input
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, Divider, FormControl, FormLabel, Link, Option, Select, Switch, Typography } from '@mui/joy';
|
||||
import { Box, Button, Chip, Divider, FormControl, FormLabel, Link, Option, Select, Switch, Typography } from '@mui/joy';
|
||||
import ClearAllIcon from '@mui/icons-material/ClearAll';
|
||||
import KeyboardDoubleArrowDownIcon from '@mui/icons-material/KeyboardDoubleArrowDown';
|
||||
|
||||
import { GoodModal } from '~/common/components/modals/GoodModal';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
@@ -9,12 +10,39 @@ import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
|
||||
import { AixDebuggerFrame } from './AixDebuggerFrame';
|
||||
import { DebugPayloadOverride } from './DebugPayloadOverride';
|
||||
import { aixClientDebuggerActions, useAixClientDebuggerStore } from './memstore-aix-client-debugger';
|
||||
|
||||
|
||||
// configuration
|
||||
const DEBUGGER_DEBOUNCE_MS = 1000 / 5; // 5Hz
|
||||
|
||||
const _styles = {
|
||||
zeroState: {
|
||||
minHeight: '228px', // take up some space even when empty
|
||||
|
||||
// backgroundColor: 'background.level1',
|
||||
borderBottom: '1px solid',
|
||||
borderBottomColor: 'divider',
|
||||
|
||||
margin: 'calc(-1 * var(--Card-padding, 1rem))', mb: 0, padding: 'var(--Card-padding, 1rem)', // fill card
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
frameViewer: {
|
||||
overflow: 'auto', // scroll this part of the dialog, i.e. the full debugging frame
|
||||
|
||||
// backgroundColor: 'background.level1',
|
||||
borderBottom: '1px solid',
|
||||
borderBottomColor: 'divider',
|
||||
|
||||
margin: 'calc(-1 * var(--Card-padding, 1rem))', mb: 0, padding: 'var(--Card-padding, 1rem)', // fill card
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
function _getStoreSnapshot() {
|
||||
const state = useAixClientDebuggerStore.getState();
|
||||
@@ -22,7 +50,7 @@ function _getStoreSnapshot() {
|
||||
frames: state.frames,
|
||||
activeFrameId: state.activeFrameId,
|
||||
maxFrames: state.maxFrames,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -73,11 +101,16 @@ export function AixDebuggerDialog(props: {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const aixInspector = useUIPreferencesStore(state => state.aixInspector);
|
||||
const hasInspector = useUIPreferencesStore(state => state.aixInspector);
|
||||
const hasInjectorJson = useAixClientDebuggerStore(state => !!state.requestBodyOverrideJson);
|
||||
const { frames, activeFrameId, maxFrames } = useDebouncedAixDebuggerStore();
|
||||
|
||||
// local state
|
||||
const [showInjector, setShowInjector] = React.useState(hasInjectorJson);
|
||||
|
||||
// derived state
|
||||
const activeFrame = frames.find(f => f.id === activeFrameId) ?? null;
|
||||
const willInjectJson = hasInspector && hasInjectorJson;
|
||||
|
||||
|
||||
// handlers
|
||||
@@ -90,26 +123,52 @@ export function AixDebuggerDialog(props: {
|
||||
aixClientDebuggerActions().setActiveFrame(value);
|
||||
}, []);
|
||||
|
||||
const handleToggleInjector = React.useCallback(() => {
|
||||
setShowInjector(on => !on);
|
||||
// NOTE: we don't clear injection on close anymore, as we have a good 'active' tag to show injection
|
||||
// if (showInjector || hasInjectorJson) {
|
||||
// // aixClientDebuggerSetRBO(''); // turning off - clear the RBO
|
||||
// setShowInjector(false);
|
||||
// } else {
|
||||
// setShowInjector(true);
|
||||
// }
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<GoodModal
|
||||
open
|
||||
onClose={props.onClose}
|
||||
title={isMobile ? 'AI Inspector' :
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
AI Request Inspector
|
||||
<KeyStroke size='sm' variant='soft' combo='Ctrl + Shift + A' />
|
||||
</Box>
|
||||
}
|
||||
unfilterBackdrop
|
||||
autoOverflow
|
||||
fullscreen={isMobile || 'button'}
|
||||
titleStartDecorator={
|
||||
<Switch
|
||||
checked={aixInspector}
|
||||
checked={hasInspector}
|
||||
onChange={useUIPreferencesStore.getState().toggleAixInspector}
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
}
|
||||
autoOverflow
|
||||
fullscreen={isMobile || 'button'}
|
||||
title={isMobile ? 'AI Inspector' :
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 2 }}>
|
||||
AI Request {(willInjectJson || showInjector) ? 'Injector' : 'Inspector'}
|
||||
<KeyStroke size='sm' variant='soft' combo='Ctrl + Shift + A' />
|
||||
</Box>
|
||||
}
|
||||
startButton={
|
||||
<Button
|
||||
disabled={!hasInspector}
|
||||
variant={showInjector ? 'solid' : willInjectJson ? 'soft' : 'plain'}
|
||||
color={willInjectJson ? 'warning' : 'neutral'}
|
||||
size='sm'
|
||||
onClick={handleToggleInjector}
|
||||
startDecorator={<KeyboardDoubleArrowDownIcon sx={{ transition: 'transform 0.2s', transform: showInjector ? 'rotate(0deg)' : 'rotate(180deg)' }} />}
|
||||
endDecorator={!hasInjectorJson ? null : <Chip size='sm' color='warning' variant={showInjector ? 'soft' : 'solid'}>Active</Chip>}
|
||||
// sx={{ gap: 1 }}
|
||||
>
|
||||
{isMobile ? 'Inject' : 'AI Injector'}
|
||||
</Button>
|
||||
}
|
||||
sx={{ maxWidth: undefined }}
|
||||
>
|
||||
|
||||
@@ -173,23 +232,23 @@ export function AixDebuggerDialog(props: {
|
||||
|
||||
{/* Zero State */}
|
||||
{(!frames.length || !activeFrame) && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '200px' }}>
|
||||
<Box sx={_styles.zeroState}>
|
||||
{!frames.length && <>
|
||||
<Typography level='title-lg'>
|
||||
{aixInspector ? 'Ready to capture' : 'AI Request Inspector'}
|
||||
{hasInspector ? 'Ready to capture' : 'AI Request Inspector'}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mt: 2, maxWidth: 468, textAlign: 'center' }}>
|
||||
{aixInspector
|
||||
{hasInspector
|
||||
? 'Your next AI request will be captured here.'
|
||||
: <>
|
||||
<Link
|
||||
component='button'
|
||||
level='body-sm'
|
||||
onClick={useUIPreferencesStore.getState().toggleAixInspector}
|
||||
>
|
||||
Turn on inspector
|
||||
</Link> to see the exact requests to AI models.
|
||||
</>}
|
||||
<Link
|
||||
component='button'
|
||||
level='body-sm'
|
||||
onClick={useUIPreferencesStore.getState().toggleAixInspector}
|
||||
>
|
||||
Turn on inspector
|
||||
</Link> to see the exact requests to AI models.
|
||||
</>}
|
||||
</Typography>
|
||||
</>}
|
||||
{!activeFrame && !!frames.length && (
|
||||
@@ -202,11 +261,14 @@ export function AixDebuggerDialog(props: {
|
||||
|
||||
{/* Frame viewer */}
|
||||
{!!activeFrame && (
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<Box sx={_styles.frameViewer}>
|
||||
<AixDebuggerFrame frame={activeFrame} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Debug Payload Override */}
|
||||
{showInjector && <DebugPayloadOverride />}
|
||||
|
||||
</GoodModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const _styles = {
|
||||
boxShadow: 'inset 2px 0 4px -2px rgba(0, 0, 0, 0.2)',
|
||||
fontFamily: 'code',
|
||||
fontSize: 'xs',
|
||||
p: 1.5,
|
||||
py: 1,
|
||||
gap: 1,
|
||||
} as const,
|
||||
|
||||
@@ -62,23 +62,29 @@ export function AixDebuggerFrame(props: {
|
||||
|
||||
const { frame } = props;
|
||||
|
||||
const contextName = frame.context?.contextName || '';
|
||||
const isConversation = contextName === 'conversation';
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 'var(--Card-padding, 1rem)' }}>
|
||||
|
||||
{/* Frame Header */}
|
||||
<Box sx={{ fontSize: 'sm', display: 'grid', gridTemplateColumns: { xs: 'auto 1fr', md: 'auto auto auto auto' }, gap: 1, alignItems: 'center' }}>
|
||||
<Typography fontWeight='bold'>Request </Typography>
|
||||
<Typography fontWeight='bold'>{frame.id}</Typography>
|
||||
<Box sx={{ fontSize: 'sm', display: 'grid', gridTemplateColumns: { xs: 'auto 1fr', md: 'auto auto auto auto' }, gap: 0.5, alignItems: 'center' }}>
|
||||
<div>Request</div>
|
||||
<Box fontWeight='md'>#{frame.id}</Box>
|
||||
<div>Status:</div>
|
||||
<Chip variant='soft' color={frame.isComplete ? 'success' : 'warning'}>{frame.isComplete ? 'Complete' : 'In Progress'}</Chip>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip variant={frame.transport !== 'csf' ? undefined : 'solid'} color={frame.transport === 'csf' ? 'primary' : 'success'}>{frame.transport === 'csf' ? 'Direct Connection' : 'Edge Server'}</Chip>
|
||||
<Chip variant={frame.isComplete ? undefined : 'solid'} color={frame.isComplete ? 'success' : 'warning'}>{frame.isComplete ? 'Done' : 'In Progress'}</Chip>
|
||||
</Box>
|
||||
<div>Date</div>
|
||||
<div>{new Date(frame.timestamp).toLocaleString()}</div>
|
||||
<div>-> URL:</div>
|
||||
<Chip className='agi-ellipsize'>{frame.url || 'No URL data available'}</Chip>
|
||||
<div className='agi-ellipsize'>{frame.url || 'No URL data available'}</div>
|
||||
<div>Context:</div>
|
||||
<Chip>{frame.context.contextName}</Chip>
|
||||
<Chip variant={isConversation ? 'soft' : 'solid'} color='primary'>{contextName}</Chip>
|
||||
<div>Reference:</div>
|
||||
<Chip>{frame.context.contextRef}</Chip>
|
||||
<div>{frame.context.contextRef}</div>
|
||||
</Box>
|
||||
|
||||
{/* Headers */}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, Chip, Textarea, Typography } from '@mui/joy';
|
||||
import DataObjectIcon from '@mui/icons-material/DataObject';
|
||||
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
import { aixClientDebuggerSetRBO, useAixClientDebuggerStore } from './memstore-aix-client-debugger';
|
||||
|
||||
|
||||
function _parseJsonOrError(json: string): { parsed: Record<string, unknown> | null; error: string | null } {
|
||||
if (!json.trim()) return { parsed: null, error: null };
|
||||
try {
|
||||
const result = JSON.parse(json);
|
||||
if (typeof result !== 'object' || result === null || Array.isArray(result))
|
||||
return { parsed: null, error: 'Must be a JSON object' };
|
||||
return { parsed: result, error: null };
|
||||
} catch (e: any) {
|
||||
return { parsed: null, error: e.message || 'Invalid JSON' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function DebugPayloadOverride() {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const storeJson = useAixClientDebuggerStore(state => state.requestBodyOverrideJson);
|
||||
|
||||
// local state - initialize from store
|
||||
const [localJson, setLocalJson] = React.useState(storeJson);
|
||||
const { parsed, error } = React.useMemo(() => _parseJsonOrError(localJson), [localJson]);
|
||||
|
||||
|
||||
// [effect] sync local state with external
|
||||
React.useEffect(() => {
|
||||
setLocalJson(storeJson);
|
||||
}, [storeJson]);
|
||||
|
||||
|
||||
// derived
|
||||
const isActive = !!storeJson;
|
||||
const hasLocalChanges = localJson !== storeJson;
|
||||
const canApply = !!parsed && hasLocalChanges;
|
||||
const canClear = isActive || !!localJson;
|
||||
|
||||
const handleApply = React.useCallback(() => {
|
||||
if (parsed)
|
||||
aixClientDebuggerSetRBO(localJson);
|
||||
}, [localJson, parsed]);
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
setLocalJson('');
|
||||
aixClientDebuggerSetRBO('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{isActive && <Chip size='sm' color='warning' variant='solid'>Active</Chip>}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }} color={error ? 'danger.softColor' : hasLocalChanges ? 'primary.plainColor' : undefined} fontSize='sm' fontWeight='md' lineHeight='sm'>
|
||||
<DataObjectIcon sx={{ fontSize: 'md' }} />
|
||||
{error || (hasLocalChanges ? 'WARNING: Unsaved changes' : 'JSON request injection')}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
|
||||
<Textarea
|
||||
placeholder='{"experimental_field": "value"}'
|
||||
value={localJson}
|
||||
onChange={(e) => setLocalJson(e.target.value)}
|
||||
error={!!error}
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
sx={{ flex: 1, fontFamily: 'code', fontSize: 'xs', backgroundColor: 'background.popup', boxShadow: 'none' }}
|
||||
/>
|
||||
|
||||
<Box sx={{ flex: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={canApply ? 'solid' : 'plain'}
|
||||
disabled={!canApply}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={storeJson ? 'solid' : 'soft'}
|
||||
color={storeJson ? 'primary' : 'neutral'}
|
||||
disabled={!canClear}
|
||||
onClick={handleClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
|
||||
{!!storeJson && !hasLocalChanges && !isMobile && (
|
||||
<Typography level='body-xs'>
|
||||
Hint: you can press Shift + Ctrl + Z to regenerate the last `Chat` message.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { AixAPI_Context_ChatGenerate } from '../../server/api/aix.wiretypes';
|
||||
|
||||
//
|
||||
// NOTE: this file is supposed to be lightweight and to be kept in memory. Particles are used by reference and
|
||||
// not cloned or modified. Visualization is a Reactive stringification of the referred objects pretty much.
|
||||
@@ -7,6 +9,19 @@ import { create } from 'zustand';
|
||||
|
||||
const DEFAULT_FRAMES_COUNT = 10;
|
||||
|
||||
// Context names that should NOT auto-select when created (background operations)
|
||||
const BACKGROUND_CONTEXT_NAMES = [
|
||||
'chat-ai-summarize',
|
||||
'chat-ai-summary',
|
||||
'chat-ai-title',
|
||||
'chat-attachment-prompts',
|
||||
'chat-followup-chartjs',
|
||||
'chat-followup-diagram',
|
||||
'chat-followup-htmlui',
|
||||
'fixup-code',
|
||||
'aifn-image-caption',
|
||||
] as const satisfies (AixAPI_Context_ChatGenerate['name'] | string)[];
|
||||
|
||||
|
||||
/// Types ///
|
||||
|
||||
@@ -16,6 +31,7 @@ export namespace AixClientDebugger {
|
||||
// frame information
|
||||
id: AixFrameId;
|
||||
timestamp: number;
|
||||
transport: Transport;
|
||||
// calling purpose
|
||||
context: Context;
|
||||
// upstream request
|
||||
@@ -31,6 +47,8 @@ export namespace AixClientDebugger {
|
||||
particles: Particle[];
|
||||
}
|
||||
|
||||
export type Transport = 'csf' | 'trpc';
|
||||
|
||||
export type Measurements = Record<string, string | number>[];
|
||||
|
||||
export interface Particle {
|
||||
@@ -50,10 +68,11 @@ export type AixFrameId = number;
|
||||
|
||||
let _lastInMemoryFrameId = 1;
|
||||
|
||||
function _createAixClientDebuggerFrame(frameContext: AixClientDebugger.Context): AixClientDebugger.Frame {
|
||||
function _createAixClientDebuggerFrame(transport: AixClientDebugger.Transport, frameContext: AixClientDebugger.Context): AixClientDebugger.Frame {
|
||||
return {
|
||||
id: ++_lastInMemoryFrameId,
|
||||
timestamp: Date.now(),
|
||||
transport: transport,
|
||||
url: '',
|
||||
headers: '',
|
||||
body: '',
|
||||
@@ -74,11 +93,13 @@ interface AixClientDebuggerState {
|
||||
frames: AixClientDebugger.Frame[];
|
||||
activeFrameId: AixFrameId | null;
|
||||
maxFrames: number;
|
||||
// AIX next payload override - JSON string injected into requests after validation
|
||||
requestBodyOverrideJson: string;
|
||||
}
|
||||
|
||||
interface AixClientDebuggerActions {
|
||||
// frames
|
||||
createFrame: (initialContext: AixClientDebugger.Context) => AixFrameId;
|
||||
createFrame: (transport: AixClientDebugger.Transport, initialContext: AixClientDebugger.Context) => AixFrameId;
|
||||
setRequest: (fId: AixFrameId, updates: Pick<AixClientDebugger.Frame, 'url' | 'headers' | 'body' | 'bodySize'>) => void;
|
||||
setProfilerMeasurements: (fId: AixFrameId, measurements: AixClientDebugger.Measurements) => void;
|
||||
addParticle: (fId: AixFrameId, particle: AixClientDebugger.Particle, isAborted?: boolean) => void;
|
||||
@@ -99,16 +120,22 @@ export const useAixClientDebuggerStore = create<AixClientDebuggerStore>((_set) =
|
||||
frames: [],
|
||||
activeFrameId: null,
|
||||
maxFrames: DEFAULT_FRAMES_COUNT,
|
||||
requestBodyOverrideJson: '',
|
||||
|
||||
|
||||
// Frame actions
|
||||
|
||||
createFrame: (initialContext) => {
|
||||
const newFrame = _createAixClientDebuggerFrame(initialContext);
|
||||
createFrame: (transport, initialContext) => {
|
||||
const newFrame = _createAixClientDebuggerFrame(transport, initialContext);
|
||||
|
||||
// Don't auto-select background operations (e.g., title generation) to avoid
|
||||
// stealing focus from the main conversation request
|
||||
const isBackgroundOperation = (BACKGROUND_CONTEXT_NAMES as readonly string[]).includes(initialContext.contextName);
|
||||
|
||||
_set((state) => ({
|
||||
frames: [newFrame, ...state.frames].slice(0, state.maxFrames),
|
||||
activeFrameId: newFrame.id,
|
||||
// Auto-select if: no active frame yet, OR this is not a background operation
|
||||
activeFrameId: (!state.activeFrameId || !isBackgroundOperation) ? newFrame.id : state.activeFrameId,
|
||||
}));
|
||||
|
||||
return newFrame.id;
|
||||
@@ -166,6 +193,14 @@ export const useAixClientDebuggerStore = create<AixClientDebuggerStore>((_set) =
|
||||
}));
|
||||
|
||||
|
||||
export function aixClientDebuggerActions() {
|
||||
return useAixClientDebuggerStore.getState() as AixClientDebuggerActions;
|
||||
export function aixClientDebuggerActions(): AixClientDebuggerActions {
|
||||
return useAixClientDebuggerStore.getState();
|
||||
}
|
||||
|
||||
export function aixClientDebuggerSetRBO(json: string) {
|
||||
useAixClientDebuggerStore.setState({ requestBodyOverrideJson: json });
|
||||
}
|
||||
|
||||
export function aixClientDebuggerGetRBO(): string {
|
||||
return useAixClientDebuggerStore.getState().requestBodyOverrideJson;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AixClientDebugger, AixFrameId, useAixClientDebuggerStore } from './memstore-aix-client-debugger';
|
||||
|
||||
|
||||
export function aixClientDebugger_init(contextInfo: AixClientDebugger.Context): AixFrameId {
|
||||
return useAixClientDebuggerStore.getState().createFrame(contextInfo);
|
||||
export function aixClientDebugger_init(transport: AixClientDebugger.Transport, contextInfo: AixClientDebugger.Context): AixFrameId {
|
||||
return useAixClientDebuggerStore.getState().createFrame(transport, contextInfo);
|
||||
}
|
||||
|
||||
export function aixClientDebugger_setRequest(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { env } from '~/server/env.server';
|
||||
import { objectDeepCloneWithStringLimit } from '~/common/util/objectUtils';
|
||||
|
||||
|
||||
// Strict in dev (throws), more tolerant in prod (warns). Override with AIX_STRICT_PARSING=true.
|
||||
// @see https://github.com/enricoros/big-AGI/issues/918
|
||||
|
||||
export function aixResilientUnknownValue(
|
||||
context: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): false {
|
||||
const DO_THROW = env.AIX_STRICT_PARSING === 'true' || process.env.NODE_ENV === 'development'; // not using 'env' because in client-side code values are empty (mocked) - NOTE: test if this is true
|
||||
|
||||
if (DO_THROW) {
|
||||
const safeValue = objectDeepCloneWithStringLimit(value, context, 1024);
|
||||
throw new Error(`[AIX.${context}] Unknown ${fieldName}: ${JSON.stringify(safeValue)}`);
|
||||
}
|
||||
|
||||
console.warn(`[AIX.${context}|R] Unknown ${fieldName}:`, objectDeepCloneWithStringLimit(value, context, 4094));
|
||||
return false;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user