mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5f674509c | |||
| 197a4ae5c0 | |||
| 64d2dcf39c | |||
| caf54c736b | |||
| 423c2cce28 | |||
| a1af51efcb | |||
| ffc1bf9c58 | |||
| a54bfdb342 | |||
| 03861d2dbd | |||
| 8c080da6bf | |||
| a8c98056b6 | |||
| 78e663f955 | |||
| 70546a5039 | |||
| 30f78b33cb | |||
| 712e8c1f16 | |||
| 933dfdfb53 | |||
| 9ce86b029f | |||
| 13580cc69d | |||
| a7dee0002d | |||
| c84b2df3fa | |||
| d9471a8684 | |||
| ef630c2272 | |||
| e188c71652 | |||
| 910260c2c8 | |||
| 22752abc38 | |||
| 92bc3a5d64 | |||
| 1383752cc1 | |||
| 66af16fb81 | |||
| fc019d7b46 | |||
| ac4f0fcb12 | |||
| a6c2bc663d | |||
| e62ffa02e9 | |||
| a003600839 | |||
| ea73feb06d | |||
| 3bdf69e1b7 | |||
| 590fe78bd1 | |||
| 76187ba0e7 | |||
| 5eba375f4d | |||
| 8fa6a8251f | |||
| 75fa046f30 | |||
| 08a8cd1430 | |||
| 3afbb78a39 | |||
| fca6ccd816 | |||
| 8d351822c1 | |||
| 7d274a31fe | |||
| e36dde0d25 | |||
| 51cc6e5ae5 | |||
| 28d911c617 | |||
| b1e9fe58fb | |||
| 16ba014ade | |||
| e9d5a20c1a | |||
| 6e0036f9c4 | |||
| d7e189aa1c | |||
| ea2b444fb2 | |||
| cd1efaf26e | |||
| e47f0e5d43 | |||
| 5284d37984 | |||
| 1bf6fa0e4d | |||
| fc294c82f1 | |||
| 7b1dc49dda | |||
| d15ddeea24 | |||
| eaac213859 | |||
| 02c1460351 | |||
| 2fff35b7d9 | |||
| c5b9072bde | |||
| 8a570e912a | |||
| 1dcc40afb8 | |||
| c2092f8035 | |||
| 886c4b411e | |||
| 8888fd40cd | |||
| 31cd01bccf | |||
| c59b221004 | |||
| cb3cc3e74c | |||
| 9e90015fcc | |||
| 95e0517056 | |||
| 2b2f47915f | |||
| 9acd178ce1 | |||
| f381f80184 | |||
| c83be61343 | |||
| f6e49d31ec | |||
| cc0429a362 | |||
| b35901d94c | |||
| c0df1a23f4 | |||
| 495619af2c | |||
| 72dfadf106 | |||
| 5825909e45 | |||
| d3f6d87ee0 | |||
| c4f4c5ddad | |||
| 2921d7ca27 | |||
| 2021cbc988 | |||
| e9e29861b2 | |||
| 8e6da36059 | |||
| 5e1469e12e | |||
| bd7465f8b1 | |||
| 570397a616 | |||
| b3b5f1daef | |||
| 25ec3ae47c | |||
| 5ba5e3da58 | |||
| 9296c14ca0 | |||
| 310b5d3422 | |||
| 1c5967112e | |||
| 49a3d8ee71 | |||
| cf8b61e8d9 | |||
| 967ae5723e | |||
| 03421acf2f | |||
| d43896cc5a | |||
| b283124a2f | |||
| 8c39be01f8 | |||
| fb2bd4ccd8 | |||
| 5b826ffc45 | |||
| 0b2ab365d3 | |||
| 93fc54992c | |||
| 60b7326deb | |||
| d6e6139244 | |||
| 0892911ddc | |||
| 30267ac50c | |||
| ffef0ef31d | |||
| fc047087ce | |||
| 81d4966535 | |||
| 004d63fda1 | |||
| 23e2dbb354 | |||
| 28e9899b97 | |||
| 7441d41550 |
@@ -42,7 +42,8 @@ It comes packed with **world-class features** like Beam, and is praised for its
|
||||
[](https://big-agi.com/inspector)
|
||||
|
||||
### What makes Big-AGI different:
|
||||
**Intelligence**: with [Beam & Merge](https://big-agi.com/beam) for multi-model de-hallucination, native search, and bleeding-edge AI models like Nano Banana, Kimi K2 Thinking 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, Kimi K2 or GPT 5.1 -
|
||||
**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.
|
||||
|
||||
@@ -138,9 +139,14 @@ so you **are not vendor locked-in**, and obsessed over a powerful UI that works,
|
||||
NOTE: this is a powerful tool - if you need a toy UI or clone, this ain't it.
|
||||
|
||||
|
||||
## What's New in 2.0 · Oct 31, 2025 · Open
|
||||
---
|
||||
|
||||
👉 **[See the full changelog](https://big-agi.com/changes)**
|
||||
## 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
|
||||
|
||||
### What's New in 2.0 · Oct 31, 2025 · Open
|
||||
|
||||
- **Big-AGI Open** is ready and more productive and faster than ever, with:
|
||||
- **Beam 2**: multi-modal, program-based, follow-ups, save presets
|
||||
@@ -153,7 +159,7 @@ NOTE: this is a powerful tool - if you need a toy UI or clone, this ain't it.
|
||||
|
||||
<img width="830" height="385" alt="image" src="https://github.com/user-attachments/assets/ad52761d-7e3f-44d8-b41e-947ce8b4faa1" />
|
||||
|
||||
### Open links: 👉 [changelog](https://big-agi.com/changes) 👉 [installation](docs/installation.md) 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [documentation](docs/README.md)
|
||||
#### **Open** links: 👉 [changelog](https://big-agi.com/changes) 👉 [installation](docs/installation.md) 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [documentation](docs/README.md)
|
||||
|
||||
**For teams and institutions:** Need shared prompts, SSO, or managed deployments? Reach out at enrico@big-agi.com. We're actively collecting requirements from research groups and IT departments.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const handlerNodeRoutes = (req: Request) => fetchRequestHandler({
|
||||
|
||||
// NOTE: the following statement breaks the build on non-pro deployments, and conditionals don't work either
|
||||
// so we resorted to raising the timeout from 10s to 60s in the vercel.json file instead
|
||||
export const maxDuration = 60;
|
||||
// export const maxDuration = 60;
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export { handlerNodeRoutes as GET, handlerNodeRoutes as POST };
|
||||
@@ -14,5 +14,7 @@ const handlerEdgeRoutes = (req: Request) => fetchRequestHandler({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// NOTE: we don't set maxDuration explicitly here - however we set it in the Vercel project settings, raising to the limit of 300s
|
||||
// export const maxDuration = 60;
|
||||
export const runtime = 'edge';
|
||||
export { handlerEdgeRoutes as GET, handlerEdgeRoutes as POST };
|
||||
@@ -14,6 +14,9 @@ Internal documentation for Big-AGI architecture and systems, for use by AI agent
|
||||
- **[AIX.md](modules/AIX.md)** - AIX streaming architecture documentation
|
||||
- **[AIX-callers-analysis.md](modules/AIX-callers-analysis.md)** - Analysis of AIX entry points, call chains, common and different rendering, error handling, etc.
|
||||
|
||||
#### CSF - Client-Side Fetch
|
||||
- **[CSF.md](systems/client-side-fetch.md)** - Direct browser-to-API communication for LLM requests
|
||||
|
||||
### Systems Documentation
|
||||
|
||||
#### Core Platform Systems
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# CSF - Client-Side Fetch
|
||||
|
||||
Client-Side Fetch (CSF) enables direct browser-to-API communication, bypassing the server for LLM requests. When enabled, the browser makes requests directly to vendor APIs (e.g., `api.openai.com`, `api.groq.com`) instead of routing through the Next.js server. This reduces latency, decreases server load, and is particularly useful for local models where the browser can communicate directly with Ollama or LM Studio.
|
||||
|
||||
## Implementation
|
||||
|
||||
CSF is implemented as an opt-in setting stored as `csf: boolean` in each vendor's service settings. The vendor interface exposes `csfAvailable?: (setup) => boolean` to determine if CSF can be enabled (typically checking if an API key or host is configured). The actual execution happens in `aix.client.direct-chatGenerate.ts` which dynamically imports when CSF is active, making direct fetch calls using the same wire protocols as the server.
|
||||
|
||||
All 16 supported vendors (OpenAI, Anthropic, Gemini, Ollama, LocalAI, Deepseek, Groq, Mistral, xAI, OpenRouter, Perplexity, Together AI, Alibaba, Moonshot, OpenPipe, LM Studio) support CSF. Cloud vendors require CORS support from the API provider (all tested vendors return `access-control-allow-origin: *`). Local vendors (Ollama, LocalAI, LM Studio) require CORS to be enabled on the local server.
|
||||
|
||||
## UI
|
||||
|
||||
The CSF toggle appears in each vendor's setup panel under "Advanced" settings, labeled "Direct Connection". It becomes visible when the prerequisites are met (API key present for cloud vendors, host configured for local vendors). The setting is managed through `useModelServiceClientSideFetch` hook which provides `csfAvailable`, `csfActive`, `csfToggle`, and `csfReset` for UI consumption.
|
||||
+2
-2
@@ -30,7 +30,7 @@ buildType && console.log(` 🧠 big-AGI: building for ${buildType}...\n`);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
let nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
reactStrictMode: !process.env.NO_STRICT_MODE, // default: enabled
|
||||
|
||||
// [exports] https://nextjs.org/docs/advanced-features/static-html-export
|
||||
...(buildType && {
|
||||
@@ -141,7 +141,7 @@ if (process.env.POSTHOG_API_KEY && process.env.POSTHOG_ENV_ID) {
|
||||
personalApiKey: process.env.POSTHOG_API_KEY,
|
||||
envId: process.env.POSTHOG_ENV_ID,
|
||||
host: 'https://us.i.posthog.com', // backtrace upload host
|
||||
verbose: false,
|
||||
logLevel: 'error', // lowered, too noisy
|
||||
sourcemaps: {
|
||||
enabled: process.env.NODE_ENV === 'production',
|
||||
project: 'big-agi',
|
||||
|
||||
Generated
+1222
-516
File diff suppressed because it is too large
Load Diff
+14
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"author": "Enrico Ros <enrico.ros@gmail.com>",
|
||||
"repository": "https://github.com/enricoros/big-agi",
|
||||
@@ -14,7 +14,8 @@
|
||||
"postinstall": "prisma generate --no-hints",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local"
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local",
|
||||
"sharp:win32_x64": "npm install --os=win32 --cpu=x64 sharp"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "src/server/prisma/schema.prisma"
|
||||
@@ -32,7 +33,7 @@
|
||||
"@mui/joy": "^5.0.0-beta.52",
|
||||
"@next/bundle-analyzer": "~15.1.8",
|
||||
"@prisma/client": "~5.22.0",
|
||||
"@tanstack/react-query": "5.90.3",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@trpc/client": "11.5.1",
|
||||
"@trpc/next": "11.5.1",
|
||||
@@ -43,8 +44,8 @@
|
||||
"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",
|
||||
"dexie": "~4.0.11",
|
||||
"dexie-react-hooks": "~1.1.7",
|
||||
"diff": "^8.0.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
@@ -53,10 +54,10 @@
|
||||
"next": "~15.1.8",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "5.4.54",
|
||||
"posthog-js": "^1.297.0",
|
||||
"posthog-node": "^5.13.0",
|
||||
"posthog-js": "^1.298.1",
|
||||
"posthog-node": "^5.14.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"puppeteer-core": "^24.30.0",
|
||||
"puppeteer-core": "^24.31.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.66.1",
|
||||
@@ -68,20 +69,20 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-mark-highlight": "^0.1.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sharp": "^0.33.5",
|
||||
"superjson": "^2.2.5",
|
||||
"sharp": "^0.34.5",
|
||||
"superjson": "^2.2.6",
|
||||
"tesseract.js": "^6.0.1",
|
||||
"tiktoken": "^1.0.22",
|
||||
"turndown": "^7.2.2",
|
||||
"zod": "^4.1.12",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@posthog/nextjs-config": "1.3.2",
|
||||
"@posthog/nextjs-config": "^1.6.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/turndown": "^5.0.6",
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ROUTE_APP_CHAT, ROUTE_INDEX } from '~/common/app.routes';
|
||||
import { Release } from '~/common/app.release';
|
||||
|
||||
// capabilities access
|
||||
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapabilityTextToImage } from '~/common/components/useCapabilities';
|
||||
import { useCapabilityBrowserSpeechRecognition, useCapabilityTextToImage } from '~/common/components/useCapabilities';
|
||||
|
||||
// stores access
|
||||
import { getLLMsDebugInfo } from '~/common/stores/llms/store-llms';
|
||||
@@ -95,7 +95,6 @@ function AppDebug() {
|
||||
const cProduct = {
|
||||
capabilities: {
|
||||
mic: useCapabilityBrowserSpeechRecognition(),
|
||||
elevenLabs: useCapabilityElevenLabs(),
|
||||
textToImage: useCapabilityTextToImage(),
|
||||
},
|
||||
models: getLLMsDebugInfo(),
|
||||
|
||||
@@ -6,13 +6,15 @@ import ChatIcon from '@mui/icons-material/Chat';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTone';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
|
||||
import { useSpeexGlobalEngine } from '~/modules/speex/store-module-speex';
|
||||
|
||||
import { PhVoice } from '~/common/components/icons/phosphor/PhVoice';
|
||||
import { animationColorRainbow } from '~/common/util/animUtils';
|
||||
import { navigateBack } from '~/common/app.routes';
|
||||
import { optimaOpenPreferences } from '~/common/layout/optima/useOptima';
|
||||
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
|
||||
import { useCapabilityBrowserSpeechRecognition } from '~/common/components/useCapabilities';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useUICounter } from '~/common/stores/store-ui';
|
||||
|
||||
@@ -45,7 +47,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
|
||||
|
||||
// external state
|
||||
const recognition = useCapabilityBrowserSpeechRecognition();
|
||||
const synthesis = useCapabilityElevenLabs();
|
||||
const speexGlobalEngine = useSpeexGlobalEngine();
|
||||
const chatIsEmpty = useChatStore(state => {
|
||||
if (!props.conversationId)
|
||||
return false;
|
||||
@@ -58,15 +60,16 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
|
||||
const outOfTheBlue = !props.conversationId;
|
||||
const overriddenEmptyChat = chatEmptyOverride || !chatIsEmpty;
|
||||
const overriddenRecognition = recognitionOverride || recognition.mayWork;
|
||||
const allGood = overriddenEmptyChat && overriddenRecognition && synthesis.mayWork;
|
||||
const fatalGood = overriddenRecognition && synthesis.mayWork;
|
||||
const synthesisShallWork = !!speexGlobalEngine;
|
||||
const allGood = overriddenEmptyChat && overriddenRecognition && synthesisShallWork;
|
||||
const fatalGood = overriddenRecognition && synthesisShallWork;
|
||||
|
||||
|
||||
const handleOverrideChatEmpty = React.useCallback(() => setChatEmptyOverride(true), []);
|
||||
|
||||
const handleOverrideRecognition = React.useCallback(() => setRecognitionOverride(true), []);
|
||||
|
||||
const handleConfigureElevenLabs = React.useCallback(() => optimaOpenPreferences('voice'), []);
|
||||
const handleConfigureVoice = React.useCallback(() => optimaOpenPreferences('voice'), []);
|
||||
|
||||
const handleFinishButton = React.useCallback(() => {
|
||||
if (!allGood)
|
||||
@@ -128,17 +131,17 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
|
||||
|
||||
{/* Text to Speech status */}
|
||||
<StatusCard
|
||||
icon={<RecordVoiceOverTwoToneIcon />}
|
||||
icon={<PhVoice />}
|
||||
text={
|
||||
(synthesis.mayWork ? 'Voice synthesis should be ready.' : 'There might be an issue with ElevenLabs voice synthesis.')
|
||||
+ (synthesis.isConfiguredServerSide ? '' : (synthesis.isConfiguredClientSide ? '' : ' Please add your API key in the settings.'))
|
||||
(synthesisShallWork ? 'Voice synthesis should be ready.' : 'There might be an issue with voice synthesis.')
|
||||
// + (synthesis.isConfiguredServerSide ? '' : (synthesis.isConfiguredClientSide ? '' : ' Please add your API key in the settings.'))
|
||||
}
|
||||
button={synthesis.mayWork ? undefined : (
|
||||
<Button variant='outlined' onClick={handleConfigureElevenLabs} sx={{ mx: 1 }}>
|
||||
button={synthesisShallWork ? undefined : (
|
||||
<Button variant='outlined' onClick={handleConfigureVoice} sx={{ mx: 1 }}>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
hasIssue={!synthesis.mayWork}
|
||||
hasIssue={!synthesisShallWork}
|
||||
/>
|
||||
|
||||
{/*<Typography>*/}
|
||||
|
||||
@@ -317,7 +317,7 @@ export function Contacts(props: { setCallIntent: (intent: AppCallIntent) => void
|
||||
issue={354}
|
||||
text='Call App: Support thread and compatibility matrix'
|
||||
note={<>
|
||||
Voice input uses the HTML Web Speech API, and speech output requires an ElevenLabs API Key.
|
||||
Voice input uses the HTML Web Speech API.
|
||||
</>}
|
||||
// note2='Please report any issues you encounter'
|
||||
sx={{
|
||||
|
||||
+17
-30
@@ -7,22 +7,22 @@ import CallEndIcon from '@mui/icons-material/CallEnd';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import MicNoneIcon from '@mui/icons-material/MicNone';
|
||||
import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTone';
|
||||
|
||||
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { useChatLLMDropdown } from '../chat/components/layout-bar/useLLMDropdown';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../data';
|
||||
import { elevenLabsSpeakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { AixChatGenerateContent_DMessageGuts, aixChatGenerateContent_DMessage_FromConversation } from '~/modules/aix/client/aix.client';
|
||||
import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown';
|
||||
|
||||
import { aixChatGenerateContent_DMessage_FromConversation, AixChatGenerateContent_DMessageGuts } from '~/modules/aix/client/aix.client';
|
||||
import { speakText } from '~/modules/speex/speex.client';
|
||||
|
||||
import type { OptimaBarControlMethods } from '~/common/layout/optima/bar/OptimaBarDropdown';
|
||||
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { OptimaPanelGroupedList } from '~/common/layout/optima/panel/OptimaPanelGroupedList';
|
||||
import { OptimaPanelIn, OptimaToolbarIn } from '~/common/layout/optima/portals/OptimaPortalsIn';
|
||||
import { PhVoice } from '~/common/components/icons/phosphor/PhVoice';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/speechrecognition/useSpeechRecognition';
|
||||
import { conversationTitle, remapMessagesSysToUsr } from '~/common/stores/chat/chat.conversation';
|
||||
import { createDMessageFromFragments, createDMessageTextContent, DMessage, messageFragmentsReduceText, messageWasInterruptedAtStart } from '~/common/stores/chat/chat.message';
|
||||
@@ -43,18 +43,13 @@ import { useAppCallStore } from './state/store-app-call';
|
||||
function CallMenu(props: {
|
||||
pushToTalk: boolean,
|
||||
setPushToTalk: (pushToTalk: boolean) => void,
|
||||
override: boolean,
|
||||
setOverride: (overridePersonaVoice: boolean) => void,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const { grayUI, toggleGrayUI } = useAppCallStore();
|
||||
const { voicesDropdown } = useElevenLabsVoiceDropdown(false, !props.override);
|
||||
|
||||
const handlePushToTalkToggle = () => props.setPushToTalk(!props.pushToTalk);
|
||||
|
||||
const handleChangeVoiceToggle = () => props.setOverride(!props.override);
|
||||
|
||||
return <OptimaPanelGroupedList title='Call'>
|
||||
|
||||
<MenuItem onClick={handlePushToTalkToggle}>
|
||||
@@ -63,17 +58,6 @@ function CallMenu(props: {
|
||||
<Switch checked={props.pushToTalk} onChange={handlePushToTalkToggle} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={handleChangeVoiceToggle}>
|
||||
<ListItemDecorator><RecordVoiceOverTwoToneIcon /></ListItemDecorator>
|
||||
Change Voice
|
||||
<Switch checked={props.override} onChange={handleChangeVoiceToggle} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem>
|
||||
<ListItemDecorator>{' '}</ListItemDecorator>
|
||||
{voicesDropdown}
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider />
|
||||
|
||||
<MenuItem onClick={toggleGrayUI}>
|
||||
@@ -98,7 +82,6 @@ export function Telephone(props: {
|
||||
const [avatarClickCount, setAvatarClickCount] = React.useState<number>(0);// const [micMuted, setMicMuted] = React.useState(false);
|
||||
const [callElapsedTime, setCallElapsedTime] = React.useState<string>('00:00');
|
||||
const [callMessages, setCallMessages] = React.useState<DMessage[]>([]);
|
||||
const [overridePersonaVoice, setOverridePersonaVoice] = React.useState<boolean>(false);
|
||||
const [personaTextInterim, setPersonaTextInterim] = React.useState<string | null>(null);
|
||||
const [pushToTalk, setPushToTalk] = React.useState(true);
|
||||
const [stage, setStage] = React.useState<'ring' | 'declined' | 'connected' | 'ended'>('ring');
|
||||
@@ -118,7 +101,7 @@ export function Telephone(props: {
|
||||
}));
|
||||
const persona = SystemPurposes[props.callIntent.personaId as SystemPurposeId] ?? undefined;
|
||||
const personaCallStarters = persona?.call?.starters ?? undefined;
|
||||
const personaVoiceId = overridePersonaVoice ? undefined : (persona?.voices?.elevenLabs?.voiceId ?? undefined);
|
||||
// const personaVoiceSelector = React.useMemo(() => personaGetVoiceSelector(persona), [persona]);
|
||||
const personaSystemMessage = persona?.systemMessage ?? undefined;
|
||||
|
||||
// hooks and speech
|
||||
@@ -165,7 +148,6 @@ export function Telephone(props: {
|
||||
};
|
||||
|
||||
// [E] pickup -> seed message and call timer
|
||||
// FIXME: Overriding the voice will reset the call - not a desired behavior
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
@@ -185,11 +167,14 @@ export function Telephone(props: {
|
||||
|
||||
setCallMessages([createDMessageTextContent('assistant', firstMessage)]); // [state] set assistant:hello message
|
||||
|
||||
// fire/forget
|
||||
void elevenLabsSpeakText(firstMessage, personaVoiceId, true, true);
|
||||
// fire/forget - use 'fast' priority for real-time conversation
|
||||
void speakText(firstMessage,
|
||||
undefined,
|
||||
{ label: 'Call', priority: 'fast' },
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isConnected, personaCallStarters, personaVoiceId]);
|
||||
}, [isConnected, personaCallStarters]);
|
||||
|
||||
// [E] persona streaming response - upon new user message
|
||||
React.useEffect(() => {
|
||||
@@ -270,9 +255,12 @@ export function Telephone(props: {
|
||||
fullMessage.generator = status.lastDMessage.generator;
|
||||
setCallMessages(messages => [...messages, fullMessage]); // [state] append assistant:call_response
|
||||
|
||||
// fire/forget
|
||||
// fire/forget - use 'fast' priority for real-time conversation
|
||||
if (status.outcome === 'success' && finalText?.length >= 1)
|
||||
void elevenLabsSpeakText(finalText, personaVoiceId, true, true);
|
||||
void speakText(finalText,
|
||||
undefined,
|
||||
{ label: 'Call', priority: 'fast' },
|
||||
);
|
||||
|
||||
}).catch((err: DOMException) => {
|
||||
if (err?.name !== 'AbortError') {
|
||||
@@ -288,7 +276,7 @@ export function Telephone(props: {
|
||||
responseAbortController.current?.abort();
|
||||
responseAbortController.current = null;
|
||||
};
|
||||
}, [isConnected, callMessages, modelId, personaVoiceId, personaSystemMessage, reMessages]);
|
||||
}, [callMessages, isConnected, modelId, personaSystemMessage, reMessages]);
|
||||
|
||||
// [E] Message interrupter
|
||||
const abortTrigger = isConnected && recognitionState.hasSpeech;
|
||||
@@ -325,7 +313,6 @@ export function Telephone(props: {
|
||||
<OptimaPanelIn>
|
||||
<CallMenu
|
||||
pushToTalk={pushToTalk} setPushToTalk={setPushToTalk}
|
||||
override={overridePersonaVoice} setOverride={setOverridePersonaVoice}
|
||||
/>
|
||||
</OptimaPanelIn>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
import type { TradeConfig } from '~/modules/trade/TradeModal';
|
||||
import { downloadSingleChat, importConversationsFromFilesAtRest, openConversationsAtRestPicker } from '~/modules/trade/trade.client';
|
||||
import { imaginePromptFromTextOrThrow } from '~/modules/aifn/imagine/imaginePromptFromText';
|
||||
import { elevenLabsSpeakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { useAreBeamsOpen } from '~/modules/beam/store-beam.hooks';
|
||||
import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
|
||||
|
||||
@@ -346,11 +345,6 @@ export function AppChat() {
|
||||
});
|
||||
}, [handleExecuteAndOutcome]);
|
||||
|
||||
const handleTextSpeak = React.useCallback(async (text: string): Promise<void> => {
|
||||
await elevenLabsSpeakText(text, undefined, true, true);
|
||||
}, []);
|
||||
|
||||
|
||||
// Chat actions
|
||||
|
||||
const handleConversationNewInFocusedPane = React.useCallback((forceNoRecycle: boolean, isIncognito: boolean) => {
|
||||
@@ -725,7 +719,6 @@ export function AppChat() {
|
||||
onConversationNew={handleConversationNewInFocusedPane}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleImagineFromText}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
sx={chatMessageListSx}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Box, List } from '@mui/joy';
|
||||
import type { SystemPurposeExample } from '../../../data';
|
||||
|
||||
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
import { speakText } from '~/modules/speex/speex.client';
|
||||
|
||||
import type { ConversationHandler } from '~/common/chat-overlay/ConversationHandler';
|
||||
import type { DLLMContextTokens } from '~/common/stores/llms/llms.types';
|
||||
@@ -17,8 +18,6 @@ import { createDMessageFromFragments, createDMessageTextContent, DMessage, DMess
|
||||
import { createTextContentFragment, DMessageFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import { openFileForAttaching } from '~/common/components/ButtonAttachFiles';
|
||||
import { optimaOpenPreferences } from '~/common/layout/optima/useOptima';
|
||||
import { useBrowserTranslationWarning } from '~/common/components/useIsBrowserTranslating';
|
||||
import { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
|
||||
import { useChatOverlayStore } from '~/common/chat-overlay/store-perchat_vanilla';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useScrollToBottom } from '~/common/scroll-to-bottom/useScrollToBottom';
|
||||
@@ -51,7 +50,6 @@ export function ChatMessageList(props: {
|
||||
onConversationNew: (forceNoRecycle: boolean, isIncognito: boolean) => void,
|
||||
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
|
||||
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<void>,
|
||||
onTextSpeak: (selectedText: string) => Promise<void>,
|
||||
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
|
||||
sx?: SxProps,
|
||||
}) {
|
||||
@@ -65,7 +63,6 @@ export function ChatMessageList(props: {
|
||||
const { notifyBooting } = useScrollToBottom();
|
||||
const danger_experimentalHtmlWebUi = useChatAutoSuggestHTMLUI();
|
||||
const [showSystemMessages] = useChatShowSystemMessages();
|
||||
const optionalTranslationWarning = useBrowserTranslationWarning();
|
||||
const { conversationMessages, historyTokenCount } = useChatStore(useShallow(({ conversations }) => {
|
||||
const conversation = conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return {
|
||||
@@ -77,10 +74,9 @@ export function ChatMessageList(props: {
|
||||
_composerInReferenceToCount: state.inReferenceTo?.length ?? 0,
|
||||
ephemerals: state.ephemerals?.length ? state.ephemerals : null,
|
||||
})));
|
||||
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
|
||||
|
||||
// derived state
|
||||
const { conversationHandler, conversationId, capabilityHasT2I, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
|
||||
const { conversationHandler, conversationId, capabilityHasT2I, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine } = props;
|
||||
const composerCanAddInReferenceTo = _composerInReferenceToCount < 5;
|
||||
const composerHasInReferenceto = _composerInReferenceToCount > 0;
|
||||
|
||||
@@ -214,12 +210,15 @@ export function ChatMessageList(props: {
|
||||
}, [capabilityHasT2I, conversationId, onTextImagine]);
|
||||
|
||||
const handleTextSpeak = React.useCallback(async (text: string) => {
|
||||
if (!isSpeakable)
|
||||
return optimaOpenPreferences('voice');
|
||||
// sandwich the speaking with the indicator
|
||||
setIsSpeaking(true);
|
||||
await onTextSpeak(text);
|
||||
const result = await speakText(text, undefined, { label: 'Chat speak' });
|
||||
setIsSpeaking(false);
|
||||
}, [isSpeakable, onTextSpeak]);
|
||||
|
||||
// open voice preferences
|
||||
if (!result.success && (result.errorType === 'tts-no-engine' || result.errorType === 'tts-unconfigured'))
|
||||
optimaOpenPreferences('voice');
|
||||
}, []);
|
||||
|
||||
|
||||
// operate on the local selection set
|
||||
@@ -326,8 +325,6 @@ export function ChatMessageList(props: {
|
||||
return (
|
||||
<List role='chat-messages-list' sx={listSx}>
|
||||
|
||||
{optionalTranslationWarning}
|
||||
|
||||
{props.isMessageSelectionMode && (
|
||||
<MessagesSelectionHeader
|
||||
hasSelected={selectedMessages.size > 0}
|
||||
@@ -381,7 +378,7 @@ export function ChatMessageList(props: {
|
||||
onMessageTruncate={handleMessageTruncate}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={capabilityHasT2I ? handleTextImagine : undefined}
|
||||
onTextSpeak={isSpeakable ? handleTextSpeak : undefined}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
/>
|
||||
|
||||
);
|
||||
|
||||
@@ -905,7 +905,7 @@ export function Composer(props: {
|
||||
)}
|
||||
|
||||
{!showChatInReferenceTo && !isDraw && tokenLimit > 0 && (
|
||||
<TokenBadgeMemo hideBelowDollars={0.005} chatPricing={tokenChatPricing} direct={tokensComposer} history={tokensHistory} responseMax={tokensResponseMax} limit={tokenLimit} showCost={labsShowCost} enableHover={!isMobile} showExcess absoluteBottomRight />
|
||||
<TokenBadgeMemo hideBelowDollars={0.01} chatPricing={tokenChatPricing} direct={tokensComposer} history={tokensHistory} responseMax={tokensResponseMax} limit={tokenLimit} showCost={labsShowCost} enableHover={!isMobile} showExcess absoluteBottomRight />
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
@@ -47,9 +47,9 @@ function TokenBadge(props: {
|
||||
const showAltCosts = !!props.showCost && !!costMax && costMin !== undefined;
|
||||
if (showAltCosts) {
|
||||
// Note: switched to 'min cost (>= ...)' on mobile as well, to restore the former behavior, just uncomment the !props.enableHover (a proxy for isMobile)
|
||||
badgeValue = (/*!props.enableHover ||*/ isHovering)
|
||||
? '< ' + formatModelsCost(costMax)
|
||||
: '> ' + formatModelsCost(costMin);
|
||||
badgeValue =
|
||||
// (/*!props.enableHover ||*/ isHovering) ? '< ' + formatModelsCost(costMax) :
|
||||
'> ' + formatModelsCost(costMin);
|
||||
} else {
|
||||
|
||||
// show the direct tokens, unless we exceed the limit and 'showExcess' is enabled
|
||||
@@ -77,7 +77,7 @@ function TokenBadge(props: {
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: {
|
||||
...((props.absoluteBottomRight) && { position: 'absolute', bottom: 8, right: 8 }),
|
||||
...((props.absoluteBottomRight) && { position: 'absolute', bottom: 8, right: '1rem' }),
|
||||
cursor: 'help',
|
||||
...(shallInvisible && {
|
||||
opacity: 0,
|
||||
@@ -92,6 +92,13 @@ function TokenBadge(props: {
|
||||
fontFamily: 'code',
|
||||
fontSize: 'xs',
|
||||
...((props.absoluteBottomRight || props.inline) && { position: 'static', transform: 'none' }),
|
||||
// make it transparent over text
|
||||
// backgroundColor: `rgb(var(--joy-palette-${color}-lightChannel) / 15%)`, // similar to success.50
|
||||
background: 'transparent',
|
||||
boxShadow: 'none', // outline
|
||||
'&:hover': {
|
||||
backgroundColor: `${color}.softHoverBg`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -21,7 +21,6 @@ import InsertLinkIcon from '@mui/icons-material/InsertLink';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||
import NotificationsOutlinedIcon from '@mui/icons-material/NotificationsOutlined';
|
||||
import RecordVoiceOverOutlinedIcon from '@mui/icons-material/RecordVoiceOverOutlined';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import ReplyAllRoundedIcon from '@mui/icons-material/ReplyAllRounded';
|
||||
import ReplyRoundedIcon from '@mui/icons-material/ReplyRounded';
|
||||
@@ -40,6 +39,7 @@ import { CloseablePopup } from '~/common/components/CloseablePopup';
|
||||
import { DMessage, DMessageId, DMessageUserFlag, DMetaReferenceItem, MESSAGE_FLAG_AIX_SKIP, MESSAGE_FLAG_NOTIFY_COMPLETE, MESSAGE_FLAG_STARRED, MESSAGE_FLAG_VND_ANT_CACHE_AUTO, MESSAGE_FLAG_VND_ANT_CACHE_USER, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { MarkHighlightIcon } from '~/common/components/icons/MarkHighlightIcon';
|
||||
import { PhVoice } from '~/common/components/icons/phosphor/PhVoice';
|
||||
import { Release } from '~/common/app.release';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
import { adjustContentScaling, themeScalingMap, themeZIndexChatBubble } from '~/common/app.theme';
|
||||
@@ -797,6 +797,7 @@ export function ChatMessage(props: {
|
||||
fitScreen={props.fitScreen}
|
||||
isMobile={props.isMobile}
|
||||
messageRole={messageRole}
|
||||
messageGeneratorLlmId={messageGenerator?.mgt === 'aix' ? messageGenerator.aix?.mId : undefined}
|
||||
messagePendingIncomplete={messagePendingIncomplete}
|
||||
optiAllowSubBlocksMemo={!!messagePendingIncomplete}
|
||||
disableMarkdownText={disableMarkdown || fromUser /* User messages are edited as text. Try to have them in plain text. NOTE: This may bite. */}
|
||||
@@ -1026,7 +1027,7 @@ export function ChatMessage(props: {
|
||||
)}
|
||||
{!!props.onTextSpeak && (
|
||||
<MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverOutlinedIcon />}</ListItemDecorator>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <PhVoice />}</ListItemDecorator>
|
||||
Speak
|
||||
</MenuItem>
|
||||
)}
|
||||
@@ -1154,7 +1155,7 @@ export function ChatMessage(props: {
|
||||
</Tooltip>}
|
||||
{!!props.onTextSpeak && <Tooltip disableInteractive arrow placement='top' title='Speak'>
|
||||
<IconButton color='success' onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
|
||||
{!props.isSpeaking ? <RecordVoiceOverOutlinedIcon /> : <CircularProgress sx={{ '--CircularProgress-size': '16px' }} />}
|
||||
{!props.isSpeaking ? <PhVoice /> : <CircularProgress sx={{ '--CircularProgress-size': '16px' }} />}
|
||||
</IconButton>
|
||||
</Tooltip>}
|
||||
{(!!props.onTextDiagram || !!props.onTextImagine || !!props.onTextSpeak) && <Divider />}
|
||||
@@ -1194,7 +1195,7 @@ export function ChatMessage(props: {
|
||||
Auto-Draw
|
||||
</MenuItem>}
|
||||
{!!props.onTextSpeak && <MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverOutlinedIcon />}</ListItemDecorator>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <PhVoice />}</ListItemDecorator>
|
||||
Speak
|
||||
</MenuItem>}
|
||||
</CloseablePopup>
|
||||
|
||||
+2
-2
@@ -7,13 +7,13 @@ import CodeIcon from '@mui/icons-material/Code';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import RecordVoiceOverOutlinedIcon from '@mui/icons-material/RecordVoiceOverOutlined';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import TextureIcon from '@mui/icons-material/Texture';
|
||||
|
||||
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { DMessageAttachmentFragment, DMessageFragmentId, DVMimeType, isDocPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { LiveFileIcon } from '~/common/livefile/liveFile.icons';
|
||||
import { PhVoice } from '~/common/components/icons/phosphor/PhVoice';
|
||||
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
|
||||
import { ellipsizeMiddle } from '~/common/util/textUtils';
|
||||
import { useLiveFileMetadata } from '~/common/livefile/useLiveFileMetadata';
|
||||
@@ -41,7 +41,7 @@ export function buttonIconForFragment(part: DMessageAttachmentFragment['part']):
|
||||
case 'image':
|
||||
return ImageOutlinedIcon;
|
||||
case 'audio':
|
||||
return RecordVoiceOverOutlinedIcon;
|
||||
return PhVoice;
|
||||
default:
|
||||
const _exhaustiveCheck: never = assetType;
|
||||
return TextureIcon; // missing zync asset type
|
||||
|
||||
@@ -3,15 +3,44 @@ import * as React from 'react';
|
||||
import { ScaledTextBlockRenderer } from '~/modules/blocks/ScaledTextBlockRenderer';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageErrorPart } from '~/common/stores/chat/chat.fragments';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
|
||||
import { BlockPartError_NetDisconnected } from './BlockPartError_NetDisconnected';
|
||||
import { BlockPartError_RequestExceeded } from './BlockPartError_RequestExceeded';
|
||||
|
||||
|
||||
export function BlockPartError(props: {
|
||||
errorText: string,
|
||||
errorHint?: DMessageErrorPart['hint'],
|
||||
messageRole: DMessageRole,
|
||||
messageGeneratorLlmId?: string | null,
|
||||
contentScaling: ContentScaling,
|
||||
}) {
|
||||
|
||||
// special error presentation, based on hints
|
||||
switch (props.errorHint) {
|
||||
case 'aix-net-disconnected':
|
||||
// determine the 2 'kinds' of disconnection errors in aix.client.ts
|
||||
const kind =
|
||||
props.errorText.includes('**network error**') ? 'net-client-closed'
|
||||
: props.errorText.includes('**connection terminated**') ? 'net-server-closed'
|
||||
: 'net-unknown-closed';
|
||||
|
||||
// For client-side error, we don't show the _NetDisconnected component
|
||||
if (kind === 'net-client-closed')
|
||||
break;
|
||||
|
||||
return <BlockPartError_NetDisconnected disconnectionKind={kind} messageGeneratorLlmId={props.messageGeneratorLlmId} contentScaling={props.contentScaling} />;
|
||||
|
||||
case 'aix-request-exceeded':
|
||||
return <BlockPartError_RequestExceeded messageGeneratorLlmId={props.messageGeneratorLlmId} contentScaling={props.contentScaling} />;
|
||||
|
||||
default:
|
||||
// continue rendering generic error
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if the errorText starts with '**' and has a closing '**' following Markdown rules
|
||||
let textToRender = props.errorText;
|
||||
let renderAsMarkdown = false;
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Alert, Box, FormHelperText, Switch } from '@mui/joy';
|
||||
import WifiOffRoundedIcon from '@mui/icons-material/WifiOffRounded';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import { useLLM } from '~/common/stores/llms/llms.hooks';
|
||||
import { useModelServiceClientSideFetch } from '~/common/stores/llms/hooks/useModelServiceClientSideFetch';
|
||||
|
||||
|
||||
/**
|
||||
* Error recovery component for "Connection terminated" errors.
|
||||
*/
|
||||
export function BlockPartError_NetDisconnected(props: {
|
||||
disconnectionKind: 'net-client-closed' | 'net-server-closed' | 'net-unknown-closed';
|
||||
messageGeneratorLlmId?: string | null;
|
||||
contentScaling: ContentScaling;
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const model = useLLM(props.messageGeneratorLlmId) ?? null;
|
||||
const isServerSideClosed = props.disconnectionKind === 'net-server-closed'; // do not show CSF option for non-server-side
|
||||
const { csfAvailable, csfActive, csfToggle, vendorName } = useModelServiceClientSideFetch(isServerSideClosed, model);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
size={props.contentScaling === 'xs' ? 'sm' : 'md'}
|
||||
color='danger'
|
||||
variant='plain'
|
||||
sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}
|
||||
>
|
||||
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-start' }}>
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<WifiOffRoundedIcon sx={{ flexShrink: 0, mt: 0.5 }} />
|
||||
<div>
|
||||
<Box fontSize='larger'>
|
||||
Connection Terminated
|
||||
</Box>
|
||||
<div>
|
||||
The connection was unexpectedly closed before the response completed.
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Recovery options */}
|
||||
{csfAvailable ? <>
|
||||
|
||||
{/* Explanation */}
|
||||
<Box color='text.tertiary' fontSize='sm' my={2}>
|
||||
<strong>Experimental:</strong> enable direct connection to {vendorName} to bypass server timeouts - then try again.
|
||||
</Box>
|
||||
|
||||
{/* Toggle */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
borderRadius: 'sm',
|
||||
bgcolor: 'background.popup',
|
||||
boxShadow: 'md',
|
||||
// border: '1px solid',
|
||||
// borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box color={!csfActive ? undefined : 'primary.solidBg'} fontWeight='lg' mb={0.5}>
|
||||
Direct Connection {csfActive && '- Now Try Again'}
|
||||
</Box>
|
||||
<FormHelperText>
|
||||
Connect directly from this client -> {vendorName || 'AI service'}
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
|
||||
<Switch
|
||||
checked={csfActive}
|
||||
onChange={(e) => csfToggle(e.target.checked)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
</> : (
|
||||
<div>
|
||||
<Box sx={{ color: 'text.secondary', my: 1 }}>
|
||||
Suggestions:
|
||||
</Box>
|
||||
<Box component='ul' sx={{ color: 'text.secondary' }}>
|
||||
<li>Check your internet connection and try again</li>
|
||||
<li>The AI service may be experiencing issues - wait a moment and retry</li>
|
||||
<li>If the issue persists, please let us know promptly on Discord or GitHib</li>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Alert, Box, FormHelperText, Switch } from '@mui/joy';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import { useLLM } from '~/common/stores/llms/llms.hooks';
|
||||
import { useModelServiceClientSideFetch } from '~/common/stores/llms/hooks/useModelServiceClientSideFetch';
|
||||
|
||||
|
||||
/**
|
||||
* Error recovery component for "Request too large" errors.
|
||||
*/
|
||||
export function BlockPartError_RequestExceeded(props: {
|
||||
messageGeneratorLlmId?: string | null;
|
||||
contentScaling: ContentScaling;
|
||||
onRegenerate?: () => void;
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const model = useLLM(props.messageGeneratorLlmId) ?? null;
|
||||
const { csfAvailable, csfActive, csfToggle, vendorName } = useModelServiceClientSideFetch(true, model);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
size={props.contentScaling === 'xs' ? 'sm' : 'md'}
|
||||
color='warning'
|
||||
sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, border: '1px solid', borderColor: 'warning.outlinedBorder' }}
|
||||
>
|
||||
|
||||
<WarningRoundedIcon sx={{ flexShrink: 0, mt: 0.25 }} />
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
|
||||
<Box fontSize='larger'>
|
||||
Request Too Large
|
||||
</Box>
|
||||
<div>
|
||||
Your message or attachments exceed the limit of the Vercel edge network
|
||||
</div>
|
||||
|
||||
{/* Recovery options */}
|
||||
{csfAvailable ? <>
|
||||
|
||||
{/* Explanation */}
|
||||
<Box color='text.secondary' fontSize='sm' my={2}>
|
||||
<strong>Experimental:</strong> enable Direct Connection to {vendorName} to work around size limitations.
|
||||
</Box>
|
||||
|
||||
{/* Toggle */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
borderRadius: 'sm',
|
||||
bgcolor: 'background.popup',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box color={!csfActive ? undefined : 'primary.solidBg'} fontWeight='lg' mb={0.5}>
|
||||
Direct Connection {csfActive && '- Now Try Again'}
|
||||
</Box>
|
||||
<FormHelperText>
|
||||
Connect directly from this client -> {vendorName || 'AI service'}
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
|
||||
<Switch
|
||||
checked={csfActive}
|
||||
onChange={(e) => csfToggle(e.target.checked)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Regenerate button */}
|
||||
{/*{props.onRegenerate && (*/}
|
||||
{/* <Button*/}
|
||||
{/* size='sm'*/}
|
||||
{/* variant={csfActive ? 'solid' : 'outlined'}*/}
|
||||
{/* color={csfActive ? 'success' : 'neutral'}*/}
|
||||
{/* startDecorator={<RefreshIcon />}*/}
|
||||
{/* onClick={props.onRegenerate}*/}
|
||||
{/* sx={{ alignSelf: 'flex-start' }}*/}
|
||||
{/* >*/}
|
||||
{/* {csfActive ? 'Regenerate with Direct Connection' : 'Regenerate'}*/}
|
||||
{/* </Button>*/}
|
||||
{/*)}*/}
|
||||
|
||||
</> : (
|
||||
<Box>
|
||||
<Box sx={{ color: 'text.secondary', my: 1 }}>
|
||||
Suggestions:
|
||||
</Box>
|
||||
<Box component='ul' sx={{ color: 'text.secondary' }}>
|
||||
<li>Use the cleanup button in the right pane to hide old messages</li>
|
||||
<li>Remove large attachments from the conversation</li>
|
||||
{/*<li>Reduce conversation length before sending</li>*/}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export function ContentFragments(props: {
|
||||
isMobile: boolean,
|
||||
messageRole: DMessageRole,
|
||||
messagePendingIncomplete?: boolean,
|
||||
messageGeneratorLlmId?: string | null,
|
||||
optiAllowSubBlocksMemo?: boolean,
|
||||
disableMarkdownText: boolean,
|
||||
enhanceCodeBlocks: boolean,
|
||||
@@ -172,7 +173,7 @@ export function ContentFragments(props: {
|
||||
|
||||
default:
|
||||
const _exhaustiveVoidCheck: never = part;
|
||||
// fallthrough - we don't handle these here anymore
|
||||
// fallthrough - we don't handle these here anymore
|
||||
case 'annotations':
|
||||
return (
|
||||
<ScaledTextBlockRenderer
|
||||
@@ -243,7 +244,9 @@ export function ContentFragments(props: {
|
||||
<BlockPartError
|
||||
key={fId}
|
||||
errorText={part.error}
|
||||
errorHint={part.hint}
|
||||
messageRole={props.messageRole}
|
||||
messageGeneratorLlmId={props.messageGeneratorLlmId}
|
||||
contentScaling={props.contentScaling}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { elevenLabsSpeakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import type { AixChatGenerateContent_DMessageGuts } from '~/modules/aix/client/aix.client';
|
||||
import { speakText } from '~/modules/speex/speex.client';
|
||||
|
||||
import { isTextContentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
import type { AixChatGenerateContent_DMessageGuts } from '~/modules/aix/client/aix.client';
|
||||
|
||||
import type { PersonaProcessorInterface } from '../chat-persona';
|
||||
|
||||
|
||||
@@ -58,7 +57,7 @@ export class PersonaChatMessageSpeak implements PersonaProcessorInterface {
|
||||
#speak(text: string) {
|
||||
console.log('📢 TTS:', text);
|
||||
this.spokenLine = true;
|
||||
// fire/forget: we don't want to stall this loop
|
||||
void elevenLabsSpeakText(text, undefined, false, true);
|
||||
// fire/forget: we don't want to stall streaming
|
||||
void speakText(text, undefined, { label: 'Chat message' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ export function AppNews() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{idx === 0 && <Divider sx={{ my: 6, mx: 6 }}/>}
|
||||
{idx === 1 && <Divider sx={{ my: 6, mx: 6 }}/>}
|
||||
|
||||
</React.Fragment>;
|
||||
})}
|
||||
|
||||
@@ -71,6 +71,21 @@ export const DevNewsItem: NewsItem = {
|
||||
|
||||
// news and feature surfaces
|
||||
export const NewsItems: NewsItem[] = [
|
||||
{
|
||||
versionCode: '2.0.2',
|
||||
versionName: 'Heavy Critters',
|
||||
versionDate: new Date('2025-12-01T06:00:00Z'), // 2.0.2
|
||||
// versionDate: new Date('2025-11-24T23:30:00Z'), // 2.0.1
|
||||
items: [
|
||||
{ text: <><B>New in 2.0.2</B> Speech synthesis with Web Speech, LocalAI, OpenAI and more</> },
|
||||
{ text: <><B>Opus 4.5</B>, <B>Gemini 3 Pro</B>, <B>Nano Banana Pro</B>, <B>Grok 4.1</B>, <B>GPT-5.1</B>, <B>Kimi K2</B></> },
|
||||
{ text: <><B>Image Generation</B> with Azure and LocalAI providers, in addition to OpenAI</> },
|
||||
{ text: <>Enhanced <B>OpenRouter</B> integration with auto-capabilities and reasoning</> },
|
||||
{ text: <>Call transcripts, generate persona images, search button in beams</> },
|
||||
{ text: <>Starred models, errors resilience, 278 fixes</> },
|
||||
{ text: <ExternalLink href='https://github.com/enricoros/big-agi/issues/new?template=ai-triage.yml'>AI-Automatic feature development</ExternalLink> },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionCode: '2.0.0',
|
||||
versionName: 'Open',
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { Accordion, AccordionDetails, accordionDetailsClasses, AccordionGroup, AccordionSummary, accordionSummaryClasses, Avatar, Box, Button, ListItemContent, styled, Tab, TabList, TabPanel, Tabs } from '@mui/joy';
|
||||
|
||||
import { Accordion, AccordionDetails, AccordionGroup, AccordionSummary, accordionSummaryClasses, Avatar, Box, Button, ListItemContent, styled, Tab, TabList, TabPanel, Tabs, Typography } from '@mui/joy';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import KeyboardCommandKeyOutlinedIcon from '@mui/icons-material/KeyboardCommandKeyOutlined';
|
||||
import LanguageRoundedIcon from '@mui/icons-material/LanguageRounded';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
|
||||
import ScienceIcon from '@mui/icons-material/Science';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import TerminalOutlinedIcon from '@mui/icons-material/TerminalOutlined';
|
||||
|
||||
import { BrowseSettings } from '~/modules/browse/BrowseSettings';
|
||||
import { DallESettings } from '~/modules/t2i/dalle/DallESettings';
|
||||
import { ElevenlabsSettings } from '~/modules/elevenlabs/ElevenlabsSettings';
|
||||
import { GoogleSearchSettings } from '~/modules/google/GoogleSearchSettings';
|
||||
import { T2ISettings } from '~/modules/t2i/T2ISettings';
|
||||
|
||||
@@ -20,14 +19,15 @@ import type { PreferencesTabId } from '~/common/layout/optima/store-layout-optim
|
||||
import { AppBreadcrumbs } from '~/common/components/AppBreadcrumbs';
|
||||
import { DarkModeToggleButton, darkModeToggleButtonSx } from '~/common/components/DarkModeToggleButton';
|
||||
import { GoodModal } from '~/common/components/modals/GoodModal';
|
||||
import { Is } from '~/common/util/pwaUtils';
|
||||
import { PhVoice } from '~/common/components/icons/phosphor/PhVoice';
|
||||
import { optimaActions } from '~/common/layout/optima/useOptima';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
import { AppChatSettingsAI } from './AppChatSettingsAI';
|
||||
import { AppChatSettingsUI } from './settings-ui/AppChatSettingsUI';
|
||||
import { UxLabsSettings } from './UxLabsSettings';
|
||||
import { VoiceSettings } from './VoiceSettings';
|
||||
import { VoiceInSettings } from './VoiceInSettings';
|
||||
import { VoiceOutSettings } from './VoiceOutSettings';
|
||||
|
||||
|
||||
// configuration
|
||||
@@ -44,7 +44,11 @@ const Topics = styled(AccordionGroup)({
|
||||
|
||||
// larger summary, with a spinning icon
|
||||
[`& .${accordionSummaryClasses.button}`]: {
|
||||
minHeight: 64,
|
||||
minHeight: '52px',
|
||||
border: 'none',
|
||||
paddingRight: '0.75rem',
|
||||
backgroundColor: 'rgba(var(--joy-palette-primary-lightChannel) / 0.2)',
|
||||
gap: '1rem',
|
||||
},
|
||||
[`& .${accordionSummaryClasses.indicator}`]: {
|
||||
transition: '0.2s',
|
||||
@@ -52,11 +56,6 @@ const Topics = styled(AccordionGroup)({
|
||||
[`& [aria-expanded="true"] .${accordionSummaryClasses.indicator}`]: {
|
||||
transform: 'rotate(45deg)',
|
||||
},
|
||||
|
||||
// larger padded block
|
||||
[`& .${accordionDetailsClasses.content}.${accordionDetailsClasses.expanded}`]: {
|
||||
paddingBlock: '1rem',
|
||||
},
|
||||
});
|
||||
|
||||
function Topic(props: { title?: React.ReactNode, icon?: string | React.ReactNode, startCollapsed?: boolean, children?: React.ReactNode }) {
|
||||
@@ -92,9 +91,9 @@ function Topic(props: { title?: React.ReactNode, icon?: string | React.ReactNode
|
||||
>
|
||||
{!!props.icon && (
|
||||
<Avatar
|
||||
size='sm'
|
||||
color={COLOR_TOPIC_ICON}
|
||||
variant={expanded ? 'plain' /* was: soft */ : 'plain'}
|
||||
// size='sm'
|
||||
>
|
||||
{props.icon}
|
||||
</Avatar>
|
||||
@@ -109,7 +108,7 @@ function Topic(props: { title?: React.ReactNode, icon?: string | React.ReactNode
|
||||
slotProps={{
|
||||
content: {
|
||||
sx: {
|
||||
px: { xs: 1.5, md: 2 },
|
||||
p: { xs: 1.5, md: 2.5 },
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -153,6 +152,7 @@ const _styles = {
|
||||
tabsListTab: {
|
||||
// borderRadius: '2rem',
|
||||
borderRadius: 'sm',
|
||||
fontSize: 'sm',
|
||||
flex: 1,
|
||||
p: 0,
|
||||
'&[aria-selected="true"]': {
|
||||
@@ -251,7 +251,7 @@ export function SettingsModal(props: {
|
||||
<Tab value='tools' disableIndicator sx={_styles.tabsListTab}>Tools</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value='chat' variant='outlined' sx={_styles.tabPanel}>
|
||||
<TabPanel value='chat' color='primary' variant='outlined' sx={_styles.tabPanel}>
|
||||
<Topics>
|
||||
<Topic>
|
||||
<AppChatSettingsUI />
|
||||
@@ -268,18 +268,18 @@ export function SettingsModal(props: {
|
||||
</Topics>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value='voice' variant='outlined' sx={_styles.tabPanel}>
|
||||
<TabPanel value='voice' color='primary' variant='outlined' sx={_styles.tabPanel}>
|
||||
<Topics>
|
||||
<Topic icon={/*'🎙️'*/ <MicIcon />} title='Microphone'>
|
||||
<VoiceSettings />
|
||||
<VoiceInSettings isMobile={isMobile} />
|
||||
</Topic>
|
||||
<Topic icon={/*'📢'*/ <RecordVoiceOverIcon />} title='ElevenLabs API'>
|
||||
<ElevenlabsSettings />
|
||||
<Topic icon={/*'📢'*/ <PhVoice />} title={'Speech'/*<>Voices <GoodBadge badge='New' /></>*/}>
|
||||
<VoiceOutSettings isMobile={isMobile} />
|
||||
</Topic>
|
||||
</Topics>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value='draw' variant='outlined' sx={_styles.tabPanel}>
|
||||
<TabPanel value='draw' color='primary' variant='outlined' sx={_styles.tabPanel}>
|
||||
<Topics>
|
||||
<Topic>
|
||||
<T2ISettings />
|
||||
@@ -290,7 +290,45 @@ export function SettingsModal(props: {
|
||||
</Topics>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value='tools' variant='outlined' sx={_styles.tabPanel}>
|
||||
<TabPanel value='tools' color='primary' variant='outlined' sx={_styles.tabPanel}>
|
||||
|
||||
{/* Search Modifier Info */}
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
borderRadius: 'calc(var(--joy-radius-md) - 1px)',
|
||||
// backgroundColor: 'background.level1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='success'
|
||||
startDecorator={<SearchIcon />}
|
||||
sx={{
|
||||
// this is copied frmo ButtonSearchControl._styles.desktop
|
||||
minWidth: 100,
|
||||
justifyContent: 'flex-start',
|
||||
borderRadius: '18px',
|
||||
pointerEvents: 'none',
|
||||
'[data-joy-color-scheme="light"] &': {
|
||||
bgcolor: '#d5ec31',
|
||||
},
|
||||
boxShadow: 'inset 0 2px 4px -1px rgba(0,0,0,0.15)',
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 'md', mb: 0.5 }}>
|
||||
Use the Search button
|
||||
</Typography>
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
Modern AI models have native search built-in. Click the Search button when chatting to enable real-time web search.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Topics>
|
||||
<Topic icon={<LanguageRoundedIcon />} title='Load Web Pages (with images)' startCollapsed>
|
||||
<BrowseSettings />
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FormControl } from '@mui/joy';
|
||||
|
||||
import { useChatMicTimeoutMs } from '../chat/store-app-chat';
|
||||
|
||||
import type { FormRadioOption } from '~/common/components/forms/FormRadioControl';
|
||||
import { FormChipControl } from '~/common/components/forms/FormChipControl';
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { LanguageSelect } from '~/common/components/LanguageSelect';
|
||||
|
||||
|
||||
const _minTimeouts: ReadonlyArray<FormRadioOption<string>> = [
|
||||
{ value: '600', label: '0.6s', description: 'Best for quick calls' },
|
||||
{ value: '2000', label: '2s', description: 'Standard' },
|
||||
{ value: '5000', label: '5s', description: 'Breathe' },
|
||||
{ value: '15000', label: '15s', description: 'Best for thinking' },
|
||||
] as const;
|
||||
|
||||
|
||||
export function VoiceInSettings(props: { isMobile: boolean }) {
|
||||
|
||||
// external state
|
||||
const [chatTimeoutMs, setChatTimeoutMs] = useChatMicTimeoutMs();
|
||||
|
||||
// derived - converts from string keys to numbers and vice versa
|
||||
const chatTimeoutValue: string = '' + chatTimeoutMs;
|
||||
const setChatTimeoutValue = React.useCallback((value: string) => {
|
||||
value && setChatTimeoutMs(parseInt(value));
|
||||
}, [setChatTimeoutMs]);
|
||||
|
||||
return <>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart
|
||||
title='Language'
|
||||
description='Mic and voice'
|
||||
// tooltip='For Microphone input and Voice output. Microphone support varies by browser (iPhone/Safari lacks speech input).'
|
||||
/>
|
||||
<LanguageSelect />
|
||||
</FormControl>
|
||||
|
||||
{!props.isMobile && (
|
||||
<FormChipControl
|
||||
title='Timeout'
|
||||
// color='primary'
|
||||
options={_minTimeouts}
|
||||
value={chatTimeoutValue}
|
||||
onChange={setChatTimeoutValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SpeexConfigureEngines } from '~/modules/speex/components/SpeexConfigureEngines';
|
||||
import { useSpeexEngines } 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';
|
||||
|
||||
|
||||
const _autoSpeakOptions: FormRadioOption<ChatAutoSpeakType>[] = [
|
||||
{ value: 'off', label: 'No', description: 'Off' },
|
||||
{ value: 'firstLine', label: 'Start', description: 'First paragraph' },
|
||||
{ value: 'all', label: 'Full', description: 'Complete response' },
|
||||
] as const;
|
||||
|
||||
|
||||
/**
|
||||
* Voice output settings - Auto-speak mode and TTS engine configuration
|
||||
*/
|
||||
export function VoiceOutSettings(props: { isMobile: boolean }) {
|
||||
|
||||
// external state
|
||||
const { autoSpeak, setAutoSpeak } = useChatAutoAI();
|
||||
|
||||
// external state - module
|
||||
const hasEngines = useSpeexEngines().length > 0;
|
||||
|
||||
return <>
|
||||
|
||||
{/* Auto-speak setting */}
|
||||
<FormChipControl
|
||||
title='Speak Chats'
|
||||
size='md'
|
||||
// color='primary'
|
||||
tooltip={!hasEngines ? 'No voice engines available. Configure a TTS service or use system voice.' : undefined}
|
||||
disabled={!hasEngines}
|
||||
options={_autoSpeakOptions}
|
||||
value={autoSpeak}
|
||||
onChange={setAutoSpeak}
|
||||
/>
|
||||
|
||||
{/* Engine configuration */}
|
||||
<SpeexConfigureEngines isMobile={props.isMobile} />
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FormControl } from '@mui/joy';
|
||||
|
||||
import { useChatMicTimeoutMs } from '../chat/store-app-chat';
|
||||
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { FormRadioControl } from '~/common/components/forms/FormRadioControl';
|
||||
import { LanguageSelect } from '~/common/components/LanguageSelect';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
|
||||
export function VoiceSettings() {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const [chatTimeoutMs, setChatTimeoutMs] = useChatMicTimeoutMs();
|
||||
|
||||
|
||||
// this converts from string keys to numbers and vice versa
|
||||
const chatTimeoutValue: string = '' + chatTimeoutMs;
|
||||
const setChatTimeoutValue = (value: string) => value && setChatTimeoutMs(parseInt(value));
|
||||
|
||||
return <>
|
||||
|
||||
{/* LanguageSelect: moved from the UI settings (where it logically belongs), just to group things better from an UX perspective */}
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart title='Language'
|
||||
description='ASR and TTS'
|
||||
tooltip='Currently for Microphone input and Voice output. Microphone support varies by browser (iPhone/Safari lacks speech input). We will use the ElevenLabs MultiLanguage model if a language other than English is selected.' />
|
||||
<LanguageSelect />
|
||||
</FormControl>
|
||||
|
||||
{!isMobile && <FormRadioControl
|
||||
title='Mic Timeout'
|
||||
description={chatTimeoutMs < 1000 ? 'Best for quick calls' : chatTimeoutMs > 5000 ? 'Best for thinking' : 'Standard'}
|
||||
options={[
|
||||
{ value: '600', label: '.6s' },
|
||||
{ value: '2000', label: '2s' },
|
||||
{ value: '5000', label: '5s' },
|
||||
{ value: '15000', label: '15s' },
|
||||
]}
|
||||
value={chatTimeoutValue} onChange={setChatTimeoutValue}
|
||||
/>}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -23,8 +23,8 @@ export const Release = {
|
||||
|
||||
// this is here to trigger revalidation of data, e.g. models refresh
|
||||
Monotonics: {
|
||||
Aix: 40,
|
||||
NewsVersion: 200,
|
||||
Aix: 43,
|
||||
NewsVersion: 202,
|
||||
},
|
||||
|
||||
// Frontend: pretty features
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Chip, ChipProps } from '@mui/joy';
|
||||
|
||||
|
||||
/**
|
||||
* Simple badge/label component for inline status indicators like "New", "Beta", etc.
|
||||
*/
|
||||
export function GoodBadge(props: {
|
||||
badge: React.ReactNode;
|
||||
color?: ChipProps['color'];
|
||||
variant?: ChipProps['variant'];
|
||||
sx?: ChipProps['sx'];
|
||||
}) {
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
color={props.color ?? 'success'}
|
||||
variant={props.variant ?? 'soft'}
|
||||
sx={{
|
||||
ml: 1.5,
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'md',
|
||||
borderRadius: 'xs',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
// default "new" color - lime/yellow-green
|
||||
...(props.color === undefined && {
|
||||
bgcolor: '#d5ec31',
|
||||
color: 'primary.softColor',
|
||||
}),
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
{props.badge}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Option, Select } from '@mui/joy';
|
||||
import { Option, optionClasses, Select, SelectSlotsAndSlotProps } from '@mui/joy';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
|
||||
import { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
@@ -10,6 +10,20 @@ import { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
import languages from './Languages.json';
|
||||
|
||||
|
||||
// copied from useLLMSelect.tsx - inspired by optimaSelectSlotProps.listbox
|
||||
const _selectSlotProps: SelectSlotsAndSlotProps<false>['slotProps'] = {
|
||||
root: { sx: { minWidth: 200 } },
|
||||
listbox: {
|
||||
sx: {
|
||||
boxShadow: 'xl',
|
||||
[`& .${optionClasses.root}`]: {
|
||||
maxWidth: 'min(640px, calc(100dvw - 0.25rem))',
|
||||
},
|
||||
},
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
|
||||
export function LanguageSelect() {
|
||||
// external state
|
||||
|
||||
@@ -32,19 +46,19 @@ export function LanguageSelect() {
|
||||
</Option>
|
||||
) : (
|
||||
Object.entries(localesOrCode).map(([country, code]) => (
|
||||
<Option key={code} value={code}>
|
||||
<Option key={code} value={code} label={language}>
|
||||
{`${language} (${country})`}
|
||||
</Option>
|
||||
))
|
||||
)), []);
|
||||
|
||||
return (
|
||||
<Select value={preferredLanguage} onChange={handleLanguageChanged}
|
||||
indicator={<KeyboardArrowDownIcon />}
|
||||
slotProps={{
|
||||
root: { sx: { minWidth: 200 } },
|
||||
indicator: { sx: { opacity: 0.5 } },
|
||||
}}>
|
||||
<Select
|
||||
value={preferredLanguage}
|
||||
onChange={handleLanguageChanged}
|
||||
indicator={<KeyboardArrowDownIcon />}
|
||||
slotProps={_selectSlotProps}
|
||||
>
|
||||
{languageOptions}
|
||||
</Select>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,13 @@ const _styles = {
|
||||
gap: 1,
|
||||
} as const,
|
||||
|
||||
chipGroupEnd: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 1,
|
||||
} as const,
|
||||
|
||||
chip: {
|
||||
'--Chip-minHeight': '1.75rem', // this makes it prob better
|
||||
px: 1.5,
|
||||
@@ -36,6 +43,7 @@ export const FormChipControl = <TValue extends string>(props: {
|
||||
// specific
|
||||
size?: 'sm' | 'md' | 'lg',
|
||||
color?: ColorPaletteProp,
|
||||
alignEnd?: boolean,
|
||||
// =FormRadioControl
|
||||
title: string | React.JSX.Element;
|
||||
description?: string | React.JSX.Element;
|
||||
@@ -48,6 +56,9 @@ export const FormChipControl = <TValue extends string>(props: {
|
||||
|
||||
const { onChange } = props;
|
||||
|
||||
const selectedOption = props.options.find(option => option.value === props.value);
|
||||
const description = selectedOption?.description ?? props.description;
|
||||
|
||||
const handleChipClick = React.useCallback((value: Immutable<TValue>) => {
|
||||
if (!props.disabled)
|
||||
onChange(value);
|
||||
@@ -55,8 +66,8 @@ export const FormChipControl = <TValue extends string>(props: {
|
||||
|
||||
return (
|
||||
<FormControl orientation='horizontal' disabled={props.disabled} sx={_styles.control}>
|
||||
{(!!props.title || !!props.description) && <FormLabelStart title={props.title} description={props.description} tooltip={props.tooltip} />}
|
||||
<Box sx={_styles.chipGroup}>
|
||||
{(!!props.title || !!description) && <FormLabelStart title={props.title} description={description} tooltip={props.tooltip} />}
|
||||
<Box sx={props.alignEnd ? _styles.chipGroupEnd : _styles.chipGroup}>
|
||||
{props.options.map((option) => (
|
||||
<Chip
|
||||
key={'opt-' + option.value}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FormLabelStart } from './FormLabelStart';
|
||||
export type FormRadioOption<T extends string> = {
|
||||
value: T,
|
||||
label: string | React.JSX.Element,
|
||||
description?: string,
|
||||
disabled?: boolean
|
||||
};
|
||||
|
||||
@@ -23,18 +24,24 @@ export const FormRadioControl = <TValue extends string>(props: {
|
||||
options: Immutable<FormRadioOption<TValue>[]>;
|
||||
value?: TValue;
|
||||
onChange: (value: TValue) => void;
|
||||
}) =>
|
||||
<FormControl size={props.size} orientation='horizontal' disabled={props.disabled} sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{(!!props.title || !!props.description) && <FormLabelStart title={props.title} description={props.description} tooltip={props.tooltip} />}
|
||||
<RadioGroup
|
||||
size={props.size}
|
||||
orientation='horizontal'
|
||||
value={props.value}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => event.target.value && props.onChange(event.target.value as TValue)}
|
||||
sx={{ flexWrap: 'wrap' }}
|
||||
>
|
||||
{props.options.map((option) =>
|
||||
<Radio key={'opt-' + option.value} value={option.value} label={option.label} disabled={option.disabled || props.disabled} />,
|
||||
)}
|
||||
</RadioGroup>
|
||||
</FormControl>;
|
||||
}) => {
|
||||
const selectedOption = props.options.find(option => option.value === props.value);
|
||||
const description = selectedOption?.description ?? props.description;
|
||||
|
||||
return (
|
||||
<FormControl size={props.size} orientation='horizontal' disabled={props.disabled} sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{(!!props.title || !!description) && <FormLabelStart title={props.title} description={description} tooltip={props.tooltip} />}
|
||||
<RadioGroup
|
||||
size={props.size}
|
||||
orientation='horizontal'
|
||||
value={props.value}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => event.target.value && props.onChange(event.target.value as TValue)}
|
||||
sx={{ flexWrap: 'wrap', gap: 1 }}
|
||||
>
|
||||
{props.options.map((option) =>
|
||||
<Radio key={'opt-' + option.value} value={option.value} label={option.label} disabled={option.disabled || props.disabled} />,
|
||||
)}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { FormControl, IconButton, Input } from '@mui/joy';
|
||||
import KeyIcon from '@mui/icons-material/Key';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
|
||||
import { FormLabelStart } from './FormLabelStart';
|
||||
|
||||
|
||||
const _styles = {
|
||||
formControl: {
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
inputDefault: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
} as const satisfies Record<string, SxProps>;
|
||||
|
||||
|
||||
/**
|
||||
* Secret/API key form field with visibility toggle.
|
||||
* Same inline layout as FormTextField but with secret-specific features:
|
||||
* - Password masking with visibility toggle
|
||||
* - Key icon (customizable)
|
||||
* - Password manager integration
|
||||
*/
|
||||
export function FormSecretField(props: {
|
||||
autoCompleteId: string;
|
||||
title: string | React.JSX.Element;
|
||||
description?: string | React.JSX.Element;
|
||||
tooltip?: string | React.JSX.Element;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (text: string) => void;
|
||||
// Behavior
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
isError?: boolean;
|
||||
// Appearance
|
||||
inputSx?: SxProps;
|
||||
/** Custom start decorator, or false to hide. Default: KeyIcon */
|
||||
startDecorator?: React.ReactNode | false;
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [isVisible, setIsVisible] = React.useState(false);
|
||||
|
||||
// derived
|
||||
const acId = 'secret-' + props.autoCompleteId;
|
||||
// password manager username
|
||||
const ghost = props.autoCompleteId.replace(/-key$/, '').replace(/-/g, ' ');
|
||||
|
||||
const endDecorator = React.useMemo(() => !!props.value && (
|
||||
<IconButton size='sm' onClick={() => setIsVisible(on => !on)}>
|
||||
{isVisible ? <VisibilityIcon sx={{ fontSize: 'md' }} /> : <VisibilityOffIcon sx={{ fontSize: 'md' }} />}
|
||||
</IconButton>
|
||||
), [props.value, isVisible]);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
id={acId}
|
||||
orientation='horizontal'
|
||||
disabled={props.disabled}
|
||||
sx={_styles.formControl}
|
||||
>
|
||||
<FormLabelStart title={props.title} description={props.description} tooltip={props.tooltip} />
|
||||
{/* Hidden username field for password manager association */}
|
||||
<input
|
||||
type='text'
|
||||
autoComplete='username'
|
||||
value={ghost}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<Input
|
||||
name={acId}
|
||||
type={isVisible ? 'text' : 'password'}
|
||||
autoComplete='new-password'
|
||||
variant='outlined'
|
||||
placeholder={props.required && !props.placeholder ? 'required' : props.placeholder}
|
||||
error={props.isError}
|
||||
value={props.value}
|
||||
onChange={event => props.onChange(event.target.value)}
|
||||
startDecorator={props.startDecorator ?? <KeyIcon sx={{ fontSize: 'md' }} />}
|
||||
endDecorator={endDecorator}
|
||||
sx={props.inputSx ?? _styles.inputDefault}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export function FormSliderControl(props: {
|
||||
startAdornment?: React.ReactNode,
|
||||
endAdornment?: React.ReactNode,
|
||||
styleNoTrack?: boolean,
|
||||
sliderSx?: SxProps,
|
||||
}) {
|
||||
|
||||
|
||||
@@ -66,8 +67,7 @@ export function FormSliderControl(props: {
|
||||
onChange={handleChange}
|
||||
onChangeCommitted={handleChangeCommitted}
|
||||
valueLabelDisplay={props.valueLabelDisplay}
|
||||
sx={props.styleNoTrack ? _styleNoTrack : undefined}
|
||||
// sx={{ py: 1, mt: 1.1 }}
|
||||
sx={props.styleNoTrack ? _styleNoTrack : props.sliderSx}
|
||||
/>
|
||||
{props.endAdornment}
|
||||
</FormControl>
|
||||
|
||||
@@ -6,11 +6,16 @@ import { FormControl, Input } from '@mui/joy';
|
||||
import { FormLabelStart } from './FormLabelStart';
|
||||
|
||||
|
||||
const formControlSx: SxProps = {
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
};
|
||||
const _styles = {
|
||||
formControl: {
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
inputDefault: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
} as const satisfies Record<string, SxProps>;
|
||||
|
||||
|
||||
/**
|
||||
@@ -23,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,
|
||||
inputSx?: SxProps,
|
||||
}) {
|
||||
const acId = 'text-' + props.autoCompleteId;
|
||||
return (
|
||||
@@ -30,7 +36,7 @@ export function FormTextField(props: {
|
||||
id={acId}
|
||||
orientation='horizontal'
|
||||
disabled={props.disabled}
|
||||
sx={formControlSx}
|
||||
sx={_styles.formControl}
|
||||
>
|
||||
<FormLabelStart title={props.title} description={props.description} tooltip={props.tooltip} />
|
||||
<Input
|
||||
@@ -39,7 +45,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)}
|
||||
sx={{ flexGrow: 1 }}
|
||||
sx={props.inputSx ?? _styles.inputDefault}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { SvgIcon, SvgIconProps } from '@mui/joy';
|
||||
|
||||
/*
|
||||
* Source: 'https://phosphoricons.com/' - user-sound
|
||||
*/
|
||||
export function PhVoice(props: SvgIconProps) {
|
||||
return (
|
||||
<SvgIcon viewBox='0 0 256 256' stroke='none' fill='currentColor' width='24' height='24' {...props}>
|
||||
<path d='M144,165.68a68,68,0,1,0-71.9,0c-20.65,6.76-39.23,19.39-54.17,37.17a8,8,0,0,0,12.25,10.3C50.25,189.19,77.91,176,108,176s57.75,13.19,77.88,37.15a8,8,0,1,0,12.25-10.3C183.18,185.07,164.6,172.44,144,165.68ZM56,108a52,52,0,1,1,52,52A52.06,52.06,0,0,1,56,108ZM207.36,65.6a108.36,108.36,0,0,1,0,84.8,8,8,0,0,1-7.36,4.86,8,8,0,0,1-7.36-11.15,92.26,92.26,0,0,0,0-72.22,8,8,0,0,1,14.72-6.29ZM248,108a139,139,0,0,1-11.29,55.15,8,8,0,0,1-14.7-6.3,124.43,124.43,0,0,0,0-97.7,8,8,0,1,1,14.7-6.3A139,139,0,0,1,248,108Z' />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { SvgIcon, SvgIconProps } from '@mui/joy';
|
||||
|
||||
export function ElevenLabsIcon(props: SvgIconProps) {
|
||||
return <SvgIcon viewBox='0 0 24 24' width='24' height='24' fill='currentColor' {...props}>
|
||||
<path d='M7 4h3v16H7V4zm7 0h3v16h-3V4z' />
|
||||
</SvgIcon>;
|
||||
}
|
||||
@@ -25,17 +25,6 @@ export interface CapabilityBrowserSpeechRecognition {
|
||||
export { browserSpeechRecognitionCapability as useCapabilityBrowserSpeechRecognition } from './speechrecognition/useSpeechRecognition';
|
||||
|
||||
|
||||
/// Speech Synthesis: ElevenLabs
|
||||
|
||||
export interface CapabilityElevenLabsSpeechSynthesis {
|
||||
mayWork: boolean;
|
||||
isConfiguredServerSide: boolean;
|
||||
isConfiguredClientSide: boolean;
|
||||
}
|
||||
|
||||
export { useCapability as useCapabilityElevenLabs } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
|
||||
|
||||
/// Image Generation
|
||||
|
||||
export interface TextToImageProvider {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Alert, IconButton } from '@mui/joy';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
|
||||
import { isBrowser, isPwa } from '~/common/util/pwaUtils';
|
||||
import { useUICounter } from '~/common/stores/store-ui';
|
||||
|
||||
|
||||
/**
|
||||
* Detects if a mobile PWA is running in Desktop Mode (which causes layout issues).
|
||||
* This happens when Chrome's "Request Desktop Site" is enabled on mobile devices.
|
||||
*
|
||||
* Shows a dismissible warning when:
|
||||
* - App is running as a PWA (standalone mode)
|
||||
* - Device OS is mobile (iOS or Android)
|
||||
* - Viewport width is >= 900px (indicating desktop mode override)
|
||||
*/
|
||||
export function usePWADesktopModeWarning() {
|
||||
|
||||
// state
|
||||
const [hideWarning, setHideWarning] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const { novel: lessThanFive, touch } = useUICounter('acknowledge-pwa-desktop-mode-warning', 5);
|
||||
|
||||
// detect PWA in desktop mode
|
||||
const isInDesktopMode = React.useMemo(() => {
|
||||
if (!isBrowser) return false;
|
||||
|
||||
// if PWA
|
||||
const isInPwaMode = isPwa();
|
||||
// if Mobile device (detected using touch points), while desktop have 0 touch points
|
||||
const isTouchDevice = navigator?.maxTouchPoints > 0;
|
||||
// if Physical Screen is small - typical mobile screen size - e.g. 412 for SGS24 Ultra
|
||||
const isSmallScreen = window.screen?.width < 600;
|
||||
// if Desktop mode - e.g. "Desktop Site" reports a large viewport width, typically 9xx+
|
||||
const isDesktopWidth = window.matchMedia('(min-width: 900px)').matches;
|
||||
|
||||
return isInPwaMode && isTouchDevice && isSmallScreen && isDesktopWidth;
|
||||
}, []);
|
||||
|
||||
const showWarning = isInDesktopMode && !hideWarning && lessThanFive;
|
||||
|
||||
return React.useMemo(() => showWarning ? (
|
||||
<Alert
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='warning'
|
||||
startDecorator={<WarningRoundedIcon />}
|
||||
endDecorator={
|
||||
<IconButton color='warning'>
|
||||
<CloseRoundedIcon onClick={() => {
|
||||
setHideWarning(true);
|
||||
touch();
|
||||
}} />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
This Browser is running in Desktop Mode, which may cause layout issues.<br />
|
||||
To fix: Close this app, open Chrome, visit this site, disable "Desktop site" in the menu, then reopen the app.
|
||||
</Alert>
|
||||
) : null, [showWarning, touch]);
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import { PanelGroup } from 'react-resizable-panels';
|
||||
import { GlobalDragOverlay } from '~/common/components/dnd-dt/GlobalDragOverlay';
|
||||
import { Is } from '~/common/util/pwaUtils';
|
||||
import { checkVisibleNav, navItems } from '~/common/app.nav';
|
||||
import { useBrowserTranslationWarning } from '~/common/components/useIsBrowserTranslating';
|
||||
import { useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { usePWADesktopModeWarning } from '~/common/components/useIsBrowserInPWADesktop';
|
||||
import { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
|
||||
import { ScratchClip } from './scratchclip/ScratchClip';
|
||||
@@ -61,6 +63,10 @@ export function OptimaLayout(props: { suspendAutoModelsSetup?: boolean, children
|
||||
// derived state
|
||||
const currentApp = navItems.apps.find(item => item.route === route);
|
||||
|
||||
// global warnings
|
||||
const translationWarning = useBrowserTranslationWarning();
|
||||
const pwaDesktopModeWarning = usePWADesktopModeWarning();
|
||||
|
||||
// global shortcuts for Optima
|
||||
useGlobalShortcuts('OptimaApp', React.useMemo(() => [
|
||||
// Preferences & Model dialogs
|
||||
@@ -78,6 +84,10 @@ export function OptimaLayout(props: { suspendAutoModelsSetup?: boolean, children
|
||||
|
||||
return <>
|
||||
|
||||
{/* Global Warnings */}
|
||||
{translationWarning}
|
||||
{pwaDesktopModeWarning}
|
||||
|
||||
<PanelGroup direction='horizontal' id='root-layout' style={isMobile ? undoPanelGroupSx : undefined}>
|
||||
|
||||
|
||||
|
||||
@@ -113,7 +113,18 @@ export type DMessageFragmentVendorState = Record<string, unknown> & {
|
||||
|
||||
export type DMessageTextPart = { pt: 'text', text: string };
|
||||
|
||||
export type DMessageErrorPart = { pt: 'error', error: string };
|
||||
export type DMessageErrorPart = { pt: 'error', error: string, hint?: DMessageErrorPartHint };
|
||||
|
||||
type DMessageErrorPartHint =
|
||||
// AIX streaming errors (from aixClassifyStreamingError)
|
||||
| 'aix-client-aborted'
|
||||
| 'aix-net-disconnected'
|
||||
| 'aix-request-exceeded'
|
||||
| 'aix-response-captive'
|
||||
| 'aix-net-unknown'
|
||||
| 'aix-processing-error'
|
||||
// Allow custom hints
|
||||
| string;
|
||||
|
||||
/**
|
||||
* @deprecated replaced by DMessageZyncAssetReferencePart to an image asset; here for migration purposes
|
||||
@@ -380,8 +391,8 @@ export function createTextContentFragment(text: string): DMessageContentFragment
|
||||
return _createContentFragment(_create_Text_Part(text));
|
||||
}
|
||||
|
||||
export function createErrorContentFragment(error: string): DMessageContentFragment {
|
||||
return _createContentFragment(_create_Error_Part(error));
|
||||
export function createErrorContentFragment(error: string, hint?: DMessageErrorPartHint): DMessageContentFragment {
|
||||
return _createContentFragment(_create_Error_Part(error, hint));
|
||||
}
|
||||
|
||||
export function createZyncAssetReferenceContentFragment(assetUuid: ZYNC_Entity.UUID, refSummary: string | undefined, assetType: 'image' | 'audio', legacyImageRefPart?: DMessageZyncAssetReferencePart['_legacyImageRefPart']): DMessageContentFragment {
|
||||
@@ -514,8 +525,8 @@ function _create_Text_Part(text: string): DMessageTextPart {
|
||||
return { pt: 'text', text };
|
||||
}
|
||||
|
||||
function _create_Error_Part(error: string): DMessageErrorPart {
|
||||
return { pt: 'error', error };
|
||||
function _create_Error_Part(error: string, hint?: DMessageErrorPartHint): DMessageErrorPart {
|
||||
return { pt: 'error', error, ...(hint && { hint }) };
|
||||
}
|
||||
|
||||
export function createDMessageZyncAssetReferencePart(zUuid: ZYNC_Entity.UUID, refSummary: string | undefined, assetType: 'image' | 'audio', legacyImageRefPart?: DMessageZyncAssetReferencePart['_legacyImageRefPart']): DMessageZyncAssetReferencePart {
|
||||
@@ -593,7 +604,7 @@ function _duplicate_Part<TPart extends (DMessageContentFragment | DMessageAttach
|
||||
return _create_Doc_Part(part.vdt, _duplicate_InlineData(part.data), part.ref, part.l1Title, newDocVersion, part.meta ? { ...part.meta } : undefined) as TPart;
|
||||
|
||||
case 'error':
|
||||
return _create_Error_Part(part.error) as TPart;
|
||||
return _create_Error_Part(part.error, part.hint) as TPart;
|
||||
|
||||
case 'reference':
|
||||
const rt = part.rt;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { findModelVendor } from '~/modules/llms/vendors/vendors.registry';
|
||||
|
||||
import type { DLLM } from '../llms.types';
|
||||
import type { DModelsService } from '../llms.service.types';
|
||||
import { llmsStoreActions, useModelsStore } from '../store-llms';
|
||||
|
||||
|
||||
const CSF_KEY = 'csf';
|
||||
|
||||
|
||||
/**
|
||||
* Hook to manage client-side fetch setting for a model's service
|
||||
* The CSF setting is stored as 'csf' in service settings for all vendors
|
||||
*/
|
||||
export function useModelServiceClientSideFetch(enabled: boolean, model: DLLM | null) {
|
||||
|
||||
// memo vendor
|
||||
const vendor = React.useMemo(() => {
|
||||
if (!enabled) return null;
|
||||
return findModelVendor(model?.vId);
|
||||
}, [enabled, model?.vId]);
|
||||
|
||||
// external state
|
||||
const service: null | DModelsService = useModelsStore(state => !model?.sId ? null : state.sources.find(s => s.id === model.sId) ?? null);
|
||||
|
||||
// actual state
|
||||
const csfAvailable: boolean | undefined = !!vendor?.csfAvailable && vendor?.csfAvailable?.(service?.setup);
|
||||
const csfActive: boolean | undefined = csfAvailable && (service?.setup as any)?.[CSF_KEY];
|
||||
|
||||
const serviceId = service?.id || '';
|
||||
const csfToggle = React.useCallback((value: boolean) => {
|
||||
if (serviceId)
|
||||
llmsStoreActions().updateServiceSettings(serviceId, { [CSF_KEY]: value });
|
||||
}, [serviceId]);
|
||||
|
||||
const csfReset = React.useCallback(() => {
|
||||
if (serviceId)
|
||||
llmsStoreActions().updateServiceSettings(serviceId, { [CSF_KEY]: false });
|
||||
}, [serviceId]);
|
||||
|
||||
return { csfAvailable, csfActive, csfToggle, csfReset, vendorName: vendor?.name || vendor?.id || 'AI Service' };
|
||||
}
|
||||
@@ -84,6 +84,14 @@ export const DModelParameterRegistry = {
|
||||
// No initialValue - undefined means off (e.g. default 200K context window)
|
||||
} as const,
|
||||
|
||||
llmVndAntEffort: {
|
||||
label: 'Effort',
|
||||
type: 'enum' as const,
|
||||
description: 'Controls token usage vs. thoroughness trade-off. Works alongside thinking budget.',
|
||||
values: ['low', 'medium', 'high'] as const,
|
||||
// No initialValue - undefined means high effort (default, equivalent to omitting the parameter)
|
||||
} as const,
|
||||
|
||||
llmVndAntSkills: {
|
||||
label: 'Document Skills',
|
||||
type: 'string' as const,
|
||||
@@ -96,7 +104,7 @@ export const DModelParameterRegistry = {
|
||||
type: 'integer' as const,
|
||||
description: 'Budget for extended thinking',
|
||||
range: [1024, 65536] as const,
|
||||
initialValue: 8192,
|
||||
initialValue: 16384,
|
||||
nullable: {
|
||||
meaning: 'Disable extended thinking',
|
||||
} as const,
|
||||
@@ -118,6 +126,14 @@ export const DModelParameterRegistry = {
|
||||
// No initialValue - undefined means off (same as 'off')
|
||||
} as const,
|
||||
|
||||
// llmVndAntToolSearch: { // Not user set
|
||||
// label: 'Tool Search',
|
||||
// type: 'enum' as const,
|
||||
// description: 'Search algorithm for discovering tools on-demand (regex=pattern-based, bm25=natural language)',
|
||||
// values: ['regex', 'bm25'] as const,
|
||||
// // No initialValue - undefined means off (tool search disabled)
|
||||
// } as const,
|
||||
|
||||
llmVndGeminiAspectRatio: {
|
||||
label: 'Aspect Ratio',
|
||||
type: 'enum' as const,
|
||||
|
||||
@@ -142,6 +142,7 @@ export type DModelInterfaceV1 =
|
||||
| 'oai-chat'
|
||||
| 'oai-chat-fn'
|
||||
| 'oai-chat-json'
|
||||
| 'ant-tools-search'
|
||||
| 'oai-chat-vision'
|
||||
| 'oai-chat-reasoning'
|
||||
| 'oai-complete'
|
||||
@@ -166,6 +167,7 @@ export type DModelInterfaceV1 =
|
||||
export const LLM_IF_OAI_Chat: DModelInterfaceV1 = 'oai-chat';
|
||||
export const LLM_IF_OAI_Fn: DModelInterfaceV1 = 'oai-chat-fn';
|
||||
export const LLM_IF_OAI_Json: DModelInterfaceV1 = 'oai-chat-json'; // for Structured Outputs (or JSON mode at worst)
|
||||
export const LLM_IF_ANT_ToolsSearch: DModelInterfaceV1 = 'ant-tools-search';
|
||||
// export const LLM_IF_OAI_JsonSchema: ... future?
|
||||
export const LLM_IF_OAI_Vision: DModelInterfaceV1 = 'oai-chat-vision';
|
||||
export const LLM_IF_OAI_Reasoning: DModelInterfaceV1 = 'oai-chat-reasoning';
|
||||
@@ -193,6 +195,7 @@ export const LLMS_ALL_INTERFACES = [
|
||||
LLM_IF_OAI_Vision, // GREAT TO HAVE - image inputs
|
||||
LLM_IF_OAI_Fn, // IMPORTANT - support for function calls
|
||||
LLM_IF_OAI_Json, // not used for now: structured outputs
|
||||
LLM_IF_ANT_ToolsSearch, // Anthropic tool: Tools Search
|
||||
// Generalized capabilities
|
||||
LLM_IF_OAI_Reasoning, // COSMETIC ONLY - may show a 'brain' icon in supported screens
|
||||
LLM_IF_Outputs_Audio, // COSMETIC ONLY FOR NOW - Models that generate audio output (TTS models)
|
||||
|
||||
@@ -245,6 +245,7 @@ export function uiSetPanelGroupCollapsed(key: string, collapsed: boolean): void
|
||||
// 'export-share' // used the export function
|
||||
// 'share-chat-link' // not shared a Chat Link yet
|
||||
type KnownKeys =
|
||||
| 'acknowledge-pwa-desktop-mode-warning' // displayed if mobile PWA is in desktop mode (layout issues)
|
||||
| 'acknowledge-translation-warning' // displayed if Chrome is translating the page (may crash)
|
||||
| 'beam-wizard' // first Beam
|
||||
| 'call-wizard' // first Call
|
||||
|
||||
@@ -19,6 +19,10 @@ export class AudioLivePlayer {
|
||||
this.audioElement.src = URL.createObjectURL(this.mediaSource);
|
||||
this.audioElement.autoplay = true;
|
||||
|
||||
// Suppress Android media notification by clearing media session metadata
|
||||
if ('mediaSession' in navigator)
|
||||
navigator.mediaSession.metadata = null;
|
||||
|
||||
// Connect the audio element to the audio context
|
||||
const sourceNode = this.audioContext.createMediaElementSource(this.audioElement);
|
||||
sourceNode.connect(this.audioContext.destination);
|
||||
@@ -60,14 +64,12 @@ export class AudioLivePlayer {
|
||||
private onSourceBufferUpdateEnd = () => {
|
||||
this.isSourceBufferUpdating = false;
|
||||
|
||||
// Continue appending if there's more data
|
||||
if (!this.isMediaSourceEnded) {
|
||||
// Always continue appending if there's more data in the queue
|
||||
if (this.chunkQueue.length > 0) {
|
||||
this.appendNextChunk();
|
||||
} else {
|
||||
// End the stream if all data has been appended
|
||||
if (this.sourceBuffer && !this.sourceBuffer.updating && this.chunkQueue.length === 0) {
|
||||
this.mediaSource.endOfStream();
|
||||
}
|
||||
} else if (this.isMediaSourceEnded) {
|
||||
// Only end the stream when queue is fully drained
|
||||
this._safeEndOfStream();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,9 +88,17 @@ export class AudioLivePlayer {
|
||||
}
|
||||
}
|
||||
} else if (this.isMediaSourceEnded) {
|
||||
if (this.sourceBuffer && !this.sourceBuffer.updating) {
|
||||
this.mediaSource.endOfStream();
|
||||
}
|
||||
if (this.sourceBuffer && !this.sourceBuffer.updating)
|
||||
this._safeEndOfStream();
|
||||
}
|
||||
}
|
||||
|
||||
private _safeEndOfStream() {
|
||||
if (this.mediaSource.readyState !== 'open') return;
|
||||
try {
|
||||
this.mediaSource.endOfStream();
|
||||
} catch (e) {
|
||||
// Ignore - MediaSource may have already ended
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +116,8 @@ export class AudioLivePlayer {
|
||||
public endPlayback() {
|
||||
this.isMediaSourceEnded = true;
|
||||
// If the sourceBuffer is not updating, we can end the stream
|
||||
if (this.sourceBuffer && !this.sourceBuffer.updating && this.chunkQueue.length === 0) {
|
||||
this.mediaSource.endOfStream();
|
||||
}
|
||||
if (this.sourceBuffer && !this.sourceBuffer.updating && this.chunkQueue.length === 0)
|
||||
this._safeEndOfStream();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,14 +128,13 @@ export class AudioLivePlayer {
|
||||
this.chunkQueue = [];
|
||||
this.isMediaSourceEnded = true;
|
||||
|
||||
if (this.sourceBuffer) {
|
||||
// only abort SourceBuffer when MediaSource is 'open'
|
||||
if (this.sourceBuffer && this.mediaSource.readyState === 'open') {
|
||||
try {
|
||||
if (this.mediaSource.readyState === 'open') {
|
||||
this.mediaSource.endOfStream();
|
||||
}
|
||||
this.sourceBuffer.abort();
|
||||
this.mediaSource.endOfStream();
|
||||
} catch (e) {
|
||||
console.warn('Error stopping playback:', e);
|
||||
// Ignore - may race with natural stream end
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ export const avatarIconSx = {
|
||||
width: 36,
|
||||
} as const;
|
||||
|
||||
const largerAvatarIconsSx = {
|
||||
borderRadius: 'sm',
|
||||
width: 48,
|
||||
height: 48,
|
||||
};
|
||||
// const largerAvatarIconsSx = {
|
||||
// borderRadius: 'sm',
|
||||
// width: 48,
|
||||
// height: 48,
|
||||
// };
|
||||
|
||||
const aixSkipBoxSx = {
|
||||
height: 36,
|
||||
@@ -148,7 +148,8 @@ export function makeMessageAvatarIcon(
|
||||
: isTextToImage ? ANIM_BUSY_PAINTING
|
||||
: isReact ? ANIM_BUSY_THINKING
|
||||
: ANIM_BUSY_TYPING}
|
||||
sx={larger ? largerAvatarIconsSx : avatarIconSx}
|
||||
sx={avatarIconSx}
|
||||
// sx={larger ? largerAvatarIconsSx : avatarIconSx}
|
||||
/>;
|
||||
|
||||
// Purpose image (if present)
|
||||
@@ -428,6 +429,10 @@ export function prettyShortChatModelName(model: string | undefined): string {
|
||||
cutModel = cutModel.slice(0, cutModel.length - dateMatch[0].length); // remove '-05-15'
|
||||
}
|
||||
const geminiName = cutModel
|
||||
// commercial aliases
|
||||
.replace('gemini-3-pro-image', 'Nano Banana Pro')
|
||||
.replace('gemini-2.5-flash-image', 'Nano Banana')
|
||||
// root changes
|
||||
.replace('non-thinking', '') // NOTE: this is our variant, injected in gemini.models.ts
|
||||
.replaceAll('-', ' ')
|
||||
// products
|
||||
|
||||
@@ -65,6 +65,7 @@ export function agiCustomId(digits: number) {
|
||||
type UuidV4Scope =
|
||||
| 'conversation-2'
|
||||
| 'persona-2'
|
||||
| 'speex.engine.instance'
|
||||
;
|
||||
|
||||
|
||||
|
||||
@@ -28,3 +28,182 @@ export function countKeys(obj: object | null | undefined): number {
|
||||
for (const _ in obj) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip undefined fields from an object.
|
||||
*
|
||||
* Useful to prevent undefined becoming null over the wire (JSON serialization).
|
||||
*/
|
||||
export function stripUndefined<T extends object>(obj: T): T;
|
||||
export function stripUndefined<T extends object>(obj: T | null): T | null;
|
||||
export function stripUndefined<T extends object>(obj: T | null): T | null {
|
||||
if (!obj) return null;
|
||||
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;
|
||||
}
|
||||
|
||||
|
||||
// === Size Estimation ===
|
||||
|
||||
/**
|
||||
* Estimates JSON serialized size without actually stringifying.
|
||||
*
|
||||
* Avoids memory allocation spike on large objects. Useful for progress tracking,
|
||||
* batching decisions, or debug output sizing.
|
||||
*
|
||||
* Note: This is an ESTIMATE. It doesn't account for:
|
||||
* - UTF-8 multi-byte characters (assumes 1 byte per char)
|
||||
* - JSON escape sequences (\n, \t, unicode escapes)
|
||||
* - Floating point precision differences
|
||||
*
|
||||
* Returns 0 for the cyclic portion if circular references are detected.
|
||||
*/
|
||||
export function objectEstimateJsonSize(value: unknown, debugCaller: string): number {
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
function estimate(val: unknown): number {
|
||||
if (val === null) return 4; // "null"
|
||||
if (val === undefined) return 0; // omitted in JSON
|
||||
|
||||
switch (typeof val) {
|
||||
case 'string':
|
||||
return val.length + 2; // quotes
|
||||
case 'number':
|
||||
return String(val).length;
|
||||
case 'boolean':
|
||||
return val ? 4 : 5; // "true" or "false"
|
||||
case 'object': {
|
||||
// cycle detection
|
||||
if (seen.has(val as object)) {
|
||||
console.warn(`[estimateJsonSize (${debugCaller})] Circular reference detected, returning 0 for this branch`);
|
||||
return 0;
|
||||
}
|
||||
seen.add(val as object);
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
let size = 2; // []
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
size += estimate(val[i]);
|
||||
if (i < val.length - 1) size += 1; // comma
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
// plain object
|
||||
let size = 2; // {}
|
||||
const keys = Object.keys(val);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
size += key.length + 3; // "key":
|
||||
size += estimate((val as Record<string, unknown>)[key]);
|
||||
if (i < keys.length - 1) size += 1; // comma
|
||||
}
|
||||
return size;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return estimate(value);
|
||||
}
|
||||
|
||||
|
||||
// === Object Traversal ===
|
||||
|
||||
/**
|
||||
* Deep clones an object while truncating strings that exceed maxBytes.
|
||||
*
|
||||
* Useful for debug logging of large objects (e.g., requests with base64 images).
|
||||
* Truncates strings in the middle, preserving start and end with a byte count.
|
||||
*
|
||||
* @returns Deep clone with truncated strings, or "[Circular]" for cyclic refs
|
||||
*/
|
||||
export function objectDeepCloneWithStringLimit(value: unknown, debugCaller: string, maxBytes: number = 2048): unknown {
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
function clone(val: unknown): unknown {
|
||||
// handle primitives first
|
||||
if (val === null || val === undefined) return val;
|
||||
|
||||
// handle strings - truncate if too long
|
||||
if (typeof val === 'string') {
|
||||
if (val.length <= maxBytes) return val;
|
||||
const ellipsis = `...[${(val.length - maxBytes).toLocaleString()} bytes]...`;
|
||||
const half = Math.floor((maxBytes - ellipsis.length) / 2);
|
||||
return val.slice(0, half) + ellipsis + val.slice(-half);
|
||||
}
|
||||
|
||||
// handle other primitives
|
||||
if (typeof val !== 'object') return val;
|
||||
|
||||
// cycle detection
|
||||
if (seen.has(val)) return '[Circular]';
|
||||
seen.add(val);
|
||||
|
||||
// handle arrays - recurse
|
||||
if (Array.isArray(val))
|
||||
return val.map(item => clone(item));
|
||||
|
||||
// handle objects - recurse
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key in val)
|
||||
if (Object.prototype.hasOwnProperty.call(val, key))
|
||||
result[key] = clone((val as Record<string, unknown>)[key]);
|
||||
return result;
|
||||
}
|
||||
|
||||
return clone(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the largest string values in an object tree
|
||||
*
|
||||
* Recursively traverses an object to find the top N largest string values,
|
||||
* returning their paths, lengths, and preview snippets.
|
||||
*
|
||||
* @returns Array of {path, length, preview} sorted by length (descending)
|
||||
*/
|
||||
export function objectFindLargestStringPaths(obj: unknown, debugCaller: string, topN: number = 5, maxDepth: number = 20): Array<{ path: string; length: number; preview: string }> {
|
||||
const results: Array<{ path: string; length: number; preview: string }> = [];
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
function traverse(current: unknown, path: string, depth: number) {
|
||||
// prevent infinite recursion
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
// handle strings
|
||||
if (typeof current === 'string') {
|
||||
results.push({
|
||||
path,
|
||||
length: current.length,
|
||||
preview: current.substring(0, 100) + (current.length > 100 ? '...' : ''),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// handle non-objects
|
||||
if (current === null || typeof current !== 'object') return;
|
||||
|
||||
// cycle detection
|
||||
if (seen.has(current)) {
|
||||
console.warn(`[findLargestStringPaths (${debugCaller})] Circular reference at path: ${path}`);
|
||||
return;
|
||||
}
|
||||
seen.add(current);
|
||||
|
||||
// handle arrays
|
||||
if (Array.isArray(current))
|
||||
return current.forEach((item, index) => traverse(item, `${path}[${index}]`, depth + 1));
|
||||
|
||||
// handle objects
|
||||
for (const [key, value] of Object.entries(current))
|
||||
traverse(value, path ? `${path}.${key}` : key, depth + 1);
|
||||
}
|
||||
|
||||
traverse(obj, '', 0);
|
||||
|
||||
// sort by length descending and return top N
|
||||
return results
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.slice(0, topN);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { addDBImageAsset } from '~/common/stores/blob/dblobs-portability';
|
||||
|
||||
import type { MaybePromise } from '~/common/types/useful.types';
|
||||
import { convert_Base64WithMimeType_To_Blob } from '~/common/util/blobUtils';
|
||||
import { create_CodeExecutionInvocation_ContentFragment, create_CodeExecutionResponse_ContentFragment, create_FunctionCallInvocation_ContentFragment, createAnnotationsVoidFragment, createDMessageDataRefDBlob, createDVoidWebCitation, createErrorContentFragment, createModelAuxVoidFragment, createPlaceholderVoidFragment, createTextContentFragment, createZyncAssetReferenceContentFragment, DVoidModelAuxPart, DVoidPlaceholderModelOp, isContentFragment, isModelAuxPart, isTextContentFragment, isVoidAnnotationsFragment, isVoidFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import { create_CodeExecutionInvocation_ContentFragment, create_CodeExecutionResponse_ContentFragment, create_FunctionCallInvocation_ContentFragment, createAnnotationsVoidFragment, createDMessageDataRefDBlob, createDVoidWebCitation, createErrorContentFragment, createModelAuxVoidFragment, createPlaceholderVoidFragment, createTextContentFragment, createZyncAssetReferenceContentFragment, DMessageErrorPart, DVoidModelAuxPart, DVoidPlaceholderModelOp, isContentFragment, isModelAuxPart, isTextContentFragment, isVoidAnnotationsFragment, isVoidFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import { ellipsizeMiddle } from '~/common/util/textUtils';
|
||||
import { imageBlobTransform, PLATFORM_IMAGE_MIMETYPE } from '~/common/util/imageUtils';
|
||||
import { metricsFinishChatGenerateLg, metricsPendChatGenerateLg } from '~/common/stores/metrics/metrics.chatgenerate';
|
||||
@@ -98,11 +98,11 @@ export class ContentReassembler {
|
||||
await this.#reassembleParticle({ cg: 'end', reason: 'abort-client', tokenStopReason: 'client-abort-signal' });
|
||||
}
|
||||
|
||||
async setClientExcepted(errorAsText: string): Promise<void> {
|
||||
async setClientExcepted(errorAsText: string, errorHint?: DMessageErrorPart['hint']): Promise<void> {
|
||||
if (DEBUG_PARTICLES)
|
||||
console.log('-> aix.p: issue:', errorAsText);
|
||||
|
||||
this.onCGIssue({ cg: 'issue', issueId: 'client-read', issueText: errorAsText });
|
||||
this.onCGIssue({ cg: 'issue', issueId: 'client-read', issueText: errorAsText, issueHint: errorHint });
|
||||
|
||||
// NOTE: this doesn't go to the debugger anymore - as we only publish external particles to the debugger
|
||||
await this.#reassembleParticle({ cg: 'end', reason: 'issue-rpc', tokenStopReason: 'cg-issue' });
|
||||
@@ -481,7 +481,7 @@ export class ContentReassembler {
|
||||
} catch (error: any) {
|
||||
console.warn('[DEV] Failed to add inline audio to DBlobs:', { label: safeLabel, error, mimeType, size: base64Data.length });
|
||||
// Add an error fragment instead
|
||||
this.accumulator.fragments.push(createErrorContentFragment(`Failed to process audio: ${error?.message || 'Unknown error'}`));
|
||||
this.accumulator.fragments.push(createErrorContentFragment(`Failed to process audio: ${error?.message || 'Unknown error'}`, 'aix-audio-processing'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,6 +604,8 @@ export class ContentReassembler {
|
||||
this.accumulator.fragments.unshift(placeholderFragment); // Add to beginning
|
||||
|
||||
// Placeholders don't affect text fragment indexing
|
||||
// NOTE: we could have placeholders breaking text accumulation into new fragments with `this.currentTextFragmentIndex = null;`, however
|
||||
// since placeholders are used a lot with hosted tool calls, this could lead to way too many fragments being created
|
||||
}
|
||||
|
||||
private onSetVendorState(vs: Extract<AixWire_Particles.PartParticleOp, { p: 'svs' }>): void {
|
||||
@@ -619,7 +621,7 @@ export class ContentReassembler {
|
||||
lastFragment.vendorState = {
|
||||
...lastFragment.vendorState,
|
||||
[vendor]: state,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to remove placeholder when real content arrives
|
||||
@@ -677,7 +679,7 @@ export class ContentReassembler {
|
||||
}
|
||||
}
|
||||
|
||||
private onCGIssue({ issueId: _issueId /* Redundant as we add an Error Fragment already */, issueText }: Extract<AixWire_Particles.ChatGenerateOp, { cg: 'issue' }>): void {
|
||||
private onCGIssue({ issueId: _issueId /* Redundant as we add an Error Fragment already */, issueText, issueHint }: Extract<AixWire_Particles.ChatGenerateOp, { cg: 'issue' }> & { issueHint?: DMessageErrorPart['hint'] }): void {
|
||||
// NOTE: not sure I like the flow at all here
|
||||
// there seem to be some bad conditions when issues are raised while the active part is not text
|
||||
if (MERGE_ISSUES_INTO_TEXT_PART_IF_OPEN) {
|
||||
@@ -688,7 +690,7 @@ export class ContentReassembler {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.accumulator.fragments.push(createErrorContentFragment(issueText));
|
||||
this.accumulator.fragments.push(createErrorContentFragment(issueText, issueHint));
|
||||
this.currentTextFragmentIndex = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,13 +36,14 @@ export function aixClassifyStreamingError(error: any, isUserAbort: boolean, hasF
|
||||
|
||||
// Browser-level network connection drops (TypeError, happens below tRPC error wrapping layer)
|
||||
// Network errors - when the client is disconnected (Vercel 5min timeout, Mobile timeout / disconnect, etc) - they show up as TypeErrors
|
||||
// IMPORTANT: we will differentiate between the 2 'net-disconnected' cases in the UI, checking for the errorMessage '**network error**' vs '**connection terminated**'
|
||||
if (error instanceof TypeError && error.message === 'network error')
|
||||
return { errorType: 'net-disconnected', errorMessage: 'An unexpected issue occurred: **network error**.' };
|
||||
return { errorType: 'net-disconnected', errorMessage: 'An unexpected issue occurred: **network error**.' /* DO NOT CHANGE '**network error**' - usually client-side broken */ };
|
||||
|
||||
// tRPC <= 11.5.1 - Vercel Edge network disconnects are thrown form tRPC as 'Stream closed'
|
||||
// NOTE The behavior changed in 11.6+ for which we have an open upstream ticket: #6989
|
||||
if (error instanceof Error && error.message === 'Stream closed')
|
||||
return { errorType: 'net-disconnected', errorMessage: 'An unexpected issue occurred: **connection terminated**.' };
|
||||
return { errorType: 'net-disconnected', errorMessage: 'An unexpected issue occurred: **connection terminated**.' /* DO NOT CHANGE '**connection terminated**' - usually server (Vercel) side broken */ };
|
||||
|
||||
|
||||
// tRPC-level protocol errors (wrapped by tRPC client)
|
||||
|
||||
@@ -47,7 +47,7 @@ export function aixCreateModelFromLLMOptions(
|
||||
// destructure input with the overrides
|
||||
const {
|
||||
llmRef, llmTemperature, llmResponseTokens, llmTopP,
|
||||
llmVndAnt1MContext, llmVndAntSkills, llmVndAntThinkingBudget, llmVndAntWebFetch, llmVndAntWebSearch,
|
||||
llmVndAnt1MContext, llmVndAntSkills, llmVndAntThinkingBudget, llmVndAntWebFetch, llmVndAntWebSearch, llmVndAntEffort,
|
||||
llmVndGeminiAspectRatio, llmVndGeminiImageSize, llmVndGeminiCodeExecution, llmVndGeminiComputerUse, llmVndGeminiGoogleSearch, llmVndGeminiMediaResolution, llmVndGeminiShowThoughts, llmVndGeminiThinkingBudget, llmVndGeminiThinkingLevel,
|
||||
// llmVndMoonshotWebSearch,
|
||||
llmVndOaiReasoningEffort, llmVndOaiReasoningEffort4, llmVndOaiRestoreMarkdown, llmVndOaiVerbosity, llmVndOaiWebSearchContext, llmVndOaiWebSearchGeolocation, llmVndOaiImageGeneration,
|
||||
@@ -105,6 +105,7 @@ export function aixCreateModelFromLLMOptions(
|
||||
...(llmVndAntSkills ? { vndAntSkills: llmVndAntSkills } : {}),
|
||||
...(llmVndAntWebFetch === 'auto' ? { vndAntWebFetch: llmVndAntWebFetch } : {}),
|
||||
...(llmVndAntWebSearch === 'auto' ? { vndAntWebSearch: llmVndAntWebSearch } : {}),
|
||||
...(llmVndAntEffort ? { vndAntEffort: llmVndAntEffort } : {}),
|
||||
...(llmVndGeminiAspectRatio ? { vndGeminiAspectRatio: llmVndGeminiAspectRatio } : {}),
|
||||
...(llmVndGeminiCodeExecution === 'auto' ? { vndGeminiCodeExecution: llmVndGeminiCodeExecution } : {}),
|
||||
...(llmVndGeminiComputerUse ? { vndGeminiComputerUse: llmVndGeminiComputerUse } : {}),
|
||||
@@ -761,8 +762,10 @@ async function _aixChatGenerateContent_LL(
|
||||
// NOT retryable: e.g. client-abort, or missing handle
|
||||
if (errorType === 'client-aborted')
|
||||
await reassembler.setClientAborted().catch(console.error /* never */);
|
||||
else
|
||||
await reassembler.setClientExcepted(errorMessage).catch(console.error);
|
||||
else {
|
||||
const errorHint: DMessageErrorPart['hint'] = `aix-${errorType}`; // MUST MATCH our `aixClassifyStreamingError` hints with 'aix-<type>' in DMessageErrorPart
|
||||
await reassembler.setClientExcepted(errorMessage, errorHint).catch(console.error);
|
||||
}
|
||||
// ... fall through (traditional single path)
|
||||
|
||||
} else {
|
||||
|
||||
@@ -336,6 +336,14 @@ export namespace AixWire_Tooling {
|
||||
properties: z.record(z.string(), OpenAPI_Schema.Object_schema),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
|
||||
/**
|
||||
* WARNING: Anthropic-ONLY for now - support for "Programmatic Tool Calling" - 2 new fields:
|
||||
* - allowed_callers: which contexts can invoke this tool, where 'direct' is the model itself, and 'code_execution' is when invoked from a container, and even both
|
||||
* - input_examples: array of example input objects that demonstrate format conventions, nested object population, etc.
|
||||
*/
|
||||
allowed_callers: z.array(z.enum(['direct', 'code_execution'])).optional(),
|
||||
input_examples: z.array(z.record(z.string(), z.any())).optional(),
|
||||
});
|
||||
|
||||
const _FunctionCallTool_schema = z.object({
|
||||
@@ -422,10 +430,31 @@ export namespace AixWire_API {
|
||||
maxTokens: z.number().min(1).optional(),
|
||||
topP: z.number().min(0).max(1).optional(),
|
||||
forceNoStream: z.boolean().optional(),
|
||||
|
||||
// Cross-vendor Structured Outputs
|
||||
|
||||
/**
|
||||
* Constrain model response to a JSON schema for data extraction. Response will be valid JSON. Schema limitations vary by vendor.
|
||||
* Supported: Anthropic (output_format), OpenAI (response_format), Gemini (responseSchema)
|
||||
*/
|
||||
strictJsonOutput: z.object({
|
||||
name: z.string().optional(), // Required by OpenAI, optional elsewhere
|
||||
description: z.string().optional(), // Helps model understand the schema's purpose
|
||||
schema: z.any(), // JSON Schema object
|
||||
}).optional(),
|
||||
|
||||
/**
|
||||
* Enable strict schema validation for tool/function call invocations. Guarantees tool inputs exactly match the input_schema. Eliminates validation/retry logic.
|
||||
* Supported: Anthropic (strict:true), OpenAI (strict:true). Gemini: not supported yet.
|
||||
*/
|
||||
strictToolInvocations: z.boolean().optional(),
|
||||
|
||||
// Anthropic
|
||||
vndAnt1MContext: z.boolean().optional(),
|
||||
vndAntEffort: z.enum(['low', 'medium', 'high']).optional(),
|
||||
vndAntSkills: z.string().optional(),
|
||||
vndAntThinkingBudget: z.number().nullable().optional(),
|
||||
vndAntToolSearch: z.enum(['regex', 'bm25']).optional(), // Tool Search Tool variant
|
||||
vndAntWebFetch: z.enum(['auto']).optional(),
|
||||
vndAntWebSearch: z.enum(['auto']).optional(),
|
||||
// Gemini
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SERVER_DEBUG_WIRE } from '~/server/wire';
|
||||
import { serverSideId } from '~/server/trpc/trpc.nanoid';
|
||||
|
||||
import { objectDeepCloneWithStringLimit, objectEstimateJsonSize } from '~/common/util/objectUtils';
|
||||
|
||||
import type { AixWire_Particles } from '../../api/aix.wiretypes';
|
||||
|
||||
import type { IParticleTransmitter, ParticleServerLogLevel } from './parsers/IParticleTransmitter';
|
||||
@@ -25,70 +27,6 @@ export const IssueSymbols = {
|
||||
};
|
||||
|
||||
|
||||
/** Estimates JSON size without stringifying (avoids memory spike on large objects). */
|
||||
function _fastEstimateJsonSize(value: any): number {
|
||||
if (value === null) return 4; // "null"
|
||||
if (value === undefined) return 0; // omitted in JSON
|
||||
if (typeof value === 'string')
|
||||
return value.length + 2; // quotes
|
||||
if (typeof value === 'number')
|
||||
return String(value).length;
|
||||
if (typeof value === 'boolean')
|
||||
return value ? 4 : 5; // "true" or "false"
|
||||
if (Array.isArray(value)) {
|
||||
let size = 2; // []
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
size += _fastEstimateJsonSize(value[i]);
|
||||
if (i < value.length - 1) size += 1; // comma
|
||||
}
|
||||
return size;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
let size = 2; // {}
|
||||
const keys = Object.keys(value);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
size += key.length + 3; // "key":
|
||||
size += _fastEstimateJsonSize(value[key]);
|
||||
if (i < keys.length - 1) size += 1; // comma
|
||||
}
|
||||
return size;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Deep-clones an object while ellipsizing any string exceeding maxBytes in the middle. */
|
||||
function _fastEllipsizeStringsInObject(value: any, maxBytes: number = DEBUG_REQUEST_MAX_STRING_BYTES): any {
|
||||
// handle primitives first
|
||||
if (value === null || value === undefined)
|
||||
return value;
|
||||
|
||||
// handle strings - ellipsize if too long
|
||||
if (typeof value === 'string') {
|
||||
if (value.length <= maxBytes)
|
||||
return value;
|
||||
const ellipsis = `...[${(value.length - maxBytes).toLocaleString()} bytes]...`;
|
||||
const half = Math.floor((maxBytes - ellipsis.length) / 2);
|
||||
return value.slice(0, half) + ellipsis + value.slice(-half);
|
||||
}
|
||||
|
||||
// handle other primitives (number, boolean)
|
||||
if (typeof value !== 'object')
|
||||
return value;
|
||||
|
||||
// handle arrays - recurse
|
||||
if (Array.isArray(value))
|
||||
return value.map(item => _fastEllipsizeStringsInObject(item, maxBytes));
|
||||
|
||||
// handle objects - recurse
|
||||
const result: any = {};
|
||||
for (const key in value)
|
||||
if (value.hasOwnProperty(key))
|
||||
result[key] = _fastEllipsizeStringsInObject(value[key], maxBytes);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queues up and emits small messages (particles) to the client, for the purpose of a stateful
|
||||
* full reconstruction of the AixWire_Parts[] objects.
|
||||
@@ -194,7 +132,7 @@ export class ChatGenerateTransmitter implements IParticleTransmitter {
|
||||
|
||||
addDebugRequest(hideSensitiveData: boolean, url: string, headers: HeadersInit, body?: object) {
|
||||
// Ellipsize individual strings in the body object (e.g., base64 images) to reduce debug packet size
|
||||
const ellipsizedBody = body ? _fastEllipsizeStringsInObject(body) : undefined;
|
||||
const ellipsizedBody = body ? objectDeepCloneWithStringLimit(body, 'aix.addDebugRequest', DEBUG_REQUEST_MAX_STRING_BYTES) : undefined;
|
||||
const processedBody = ellipsizedBody ? JSON.stringify(ellipsizedBody, null, 2) : '';
|
||||
|
||||
this.transmissionQueue.push({
|
||||
@@ -204,7 +142,7 @@ export class ChatGenerateTransmitter implements IParticleTransmitter {
|
||||
url: url,
|
||||
headers: hideSensitiveData ? '(hidden sensitive data)' : JSON.stringify(headers, null, 2),
|
||||
body: processedBody,
|
||||
bodySize: body ? _fastEstimateJsonSize(body) : 0,
|
||||
bodySize: body ? objectEstimateJsonSize(body, 'aix.addDebugRequest') : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,13 +103,18 @@ export function aixToAnthropicMessageCreate(model: AixAPI_Model, _chatGenerate:
|
||||
// console.log(`Anthropic: hotFixStartWithUser (${chatMessages.length} messages) - ${hackSystemMessageFirstLine}`);
|
||||
// }
|
||||
|
||||
// [Anthropic, 2025-11-13] constrained output modes - both JSON and tool invocations
|
||||
const strictToolsEnabled = !!model.strictToolInvocations;
|
||||
// [Anthropic, 2025-11-24] Tool Search Tool - when enabled, all custom tools get defer_loading: true
|
||||
const toolSearchEnabled = !!model.vndAntToolSearch;
|
||||
|
||||
// Construct the request payload
|
||||
const payload: TRequest = {
|
||||
max_tokens: model.maxTokens !== undefined ? model.maxTokens : 8192,
|
||||
model: model.id,
|
||||
system: systemMessage,
|
||||
messages: chatMessages,
|
||||
tools: chatGenerate.tools && _toAnthropicTools(chatGenerate.tools),
|
||||
tools: chatGenerate.tools && _toAnthropicTools(chatGenerate.tools, strictToolsEnabled, toolSearchEnabled),
|
||||
tool_choice: chatGenerate.toolsPolicy && _toAnthropicToolChoice(chatGenerate.toolsPolicy),
|
||||
// metadata: { user_id: ... }
|
||||
// stop_sequences: undefined,
|
||||
@@ -138,6 +143,26 @@ export function aixToAnthropicMessageCreate(model: AixAPI_Model, _chatGenerate:
|
||||
delete payload.temperature;
|
||||
}
|
||||
|
||||
// [Anthropic] Effort parameter [Anthropic, effort-2025-11-24]
|
||||
if (model.vndAntEffort /*&& model.vndAntEffort !== 'high'*/)
|
||||
payload.output_config = {
|
||||
effort: model.vndAntEffort,
|
||||
};
|
||||
|
||||
// [Anthropic, 2025-11-13] Structured Outputs - JSON output format
|
||||
if (model.strictJsonOutput) {
|
||||
|
||||
// auto-add additionalProperties: false to root object if not present - required by Anthropic
|
||||
let schema = model.strictJsonOutput.schema;
|
||||
if (schema && typeof schema === 'object' && schema.type === 'object' && schema.additionalProperties === undefined)
|
||||
schema = { ...schema, additionalProperties: false };
|
||||
payload.output_format = { type: 'json_schema', schema };
|
||||
|
||||
// warn about incompatible features (citations are enabled via web_fetch tool)
|
||||
if (model.vndAntWebFetch === 'auto')
|
||||
console.warn('[Anthropic] Structured output_format may conflict with web_fetch citations');
|
||||
}
|
||||
|
||||
// --- Tools ---
|
||||
|
||||
// Allow/deny auto-adding hosted tools when custom tools are present
|
||||
@@ -168,6 +193,18 @@ export function aixToAnthropicMessageCreate(model: AixAPI_Model, _chatGenerate:
|
||||
});
|
||||
}
|
||||
|
||||
// [Anthropic, 2025-11-24] Tool Search Tool(s)
|
||||
if (model.vndAntToolSearch === 'regex')
|
||||
hostedTools.push({
|
||||
type: 'tool_search_tool_regex_20251119',
|
||||
name: 'tool_search_tool_regex',
|
||||
});
|
||||
else if (model.vndAntToolSearch === 'bm25')
|
||||
hostedTools.push({
|
||||
type: 'tool_search_tool_bm25_20251119',
|
||||
name: 'tool_search_tool_bm25',
|
||||
});
|
||||
|
||||
// Merge hosted tools with custom tools
|
||||
if (hostedTools.length > 0) {
|
||||
payload.tools = payload.tools ? [...payload.tools, ...hostedTools] : hostedTools;
|
||||
@@ -353,12 +390,12 @@ function* _generateAnthropicMessagesContentBlocks({ parts, role }: AixMessages_C
|
||||
}
|
||||
}
|
||||
|
||||
function _toAnthropicTools(itds: AixTools_ToolDefinition[]): NonNullable<TRequest['tools']> {
|
||||
function _toAnthropicTools(itds: AixTools_ToolDefinition[], strictToolsEnabled: boolean, toolSearchToolEnabled: boolean): NonNullable<TRequest['tools']> {
|
||||
return itds.map(itd => {
|
||||
switch (itd.type) {
|
||||
|
||||
case 'function_call':
|
||||
const { name, description, input_schema } = itd.function_call;
|
||||
const { name, description, input_schema, allowed_callers, input_examples } = itd.function_call;
|
||||
return {
|
||||
type: 'custom', // we could not set it, but it helps our typesystem with discrimination
|
||||
name,
|
||||
@@ -367,7 +404,16 @@ function _toAnthropicTools(itds: AixTools_ToolDefinition[]): NonNullable<TReques
|
||||
type: 'object',
|
||||
properties: input_schema?.properties || null, // Anthropic valid values for input_schema.properties are 'object' or 'null' (null is used to declare functions with no inputs)
|
||||
required: input_schema?.required,
|
||||
// [Anthropic, 2025-11-13] Structured Outputs requires additionalProperties: false
|
||||
...(strictToolsEnabled ? { additionalProperties: false } : {}),
|
||||
},
|
||||
// [Anthropic, 2025-11-13] Structured Outputs: strict mode guarantees tool inputs match schema
|
||||
...(strictToolsEnabled ? { strict: true } : {}),
|
||||
// [Anthropic, 2025-11-24] Tool Search Tool - auto-defer all custom tools
|
||||
...(toolSearchToolEnabled ? { defer_loading: true } : {}),
|
||||
// [Anthropic, 2025-11-24] Programmatic Tool Calling - pass through allowed_callers and input_examples
|
||||
...(allowed_callers ? { allowed_callers: allowed_callers.map(c => c === 'code_execution' ? 'code_execution_20250825' : c) } : {}),
|
||||
...(input_examples ? { input_examples } : {}),
|
||||
};
|
||||
|
||||
case 'code_execution':
|
||||
|
||||
@@ -61,6 +61,11 @@ export function aixToGeminiGenerateContent(model: AixAPI_Model, _chatGenerate: A
|
||||
// Chat Messages
|
||||
const contents: TRequest['contents'] = _toGeminiContents(chatGenerate.chatSequence, api3RequiresSignatures);
|
||||
|
||||
// constrained output modes - only JSON (not tool invocations for now)
|
||||
const jsonOutputEnabled = !!model.strictJsonOutput || jsonOutput;
|
||||
const jsonOutputSchema = model.strictJsonOutput?.schema;
|
||||
// const strictToolInvocation = model.strictToolInvocations; // Gemini does not seem to support this yet - need to confirm
|
||||
|
||||
// Construct the request payload
|
||||
const payload: TRequest = {
|
||||
contents,
|
||||
@@ -68,8 +73,8 @@ export function aixToGeminiGenerateContent(model: AixAPI_Model, _chatGenerate: A
|
||||
systemInstruction,
|
||||
generationConfig: {
|
||||
stopSequences: undefined, // (default, optional)
|
||||
responseMimeType: jsonOutput ? 'application/json' : undefined,
|
||||
responseSchema: undefined, // (default, optional) NOTE: for JSON output, we'd take the schema here
|
||||
responseMimeType: jsonOutputEnabled ? 'application/json' : undefined,
|
||||
responseSchema: jsonOutputSchema,
|
||||
candidateCount: undefined, // (default, optional)
|
||||
maxOutputTokens: model.maxTokens !== undefined ? model.maxTokens : undefined,
|
||||
...(model.temperature !== null ? { temperature: model.temperature !== undefined ? model.temperature : undefined } : {}),
|
||||
@@ -384,7 +389,8 @@ function _toGeminiContents(chatSequence: AixMessages_ChatMessage[], apiRequiresS
|
||||
// if not applied yet, and required for this part type, apply bypass dummy and warn
|
||||
else if (partRequiresSignature) {
|
||||
tsTarget.thoughtSignature = GEMINI_BYPASS_THOUGHT_SIGNATURE;
|
||||
console.log('[Gemini 3] Message part missing thoughtSignature - using bypass dummy (cross-provider or edited content)');
|
||||
// [Gemini 3, 2025-11-20] Cross-provider or edited content warning
|
||||
console.log(`[Gemini 3] ${part.pt} missing thoughtSignature - bypass applied`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const approxSystemMessageJoiner = '\n\n---\n\n';
|
||||
type TRequest = OpenAIWire_API_Chat_Completions.Request;
|
||||
type TRequestMessages = TRequest['messages'];
|
||||
|
||||
export function aixToOpenAIChatCompletions(openAIDialect: OpenAIDialects, model: AixAPI_Model, _chatGenerate: AixAPIChatGenerate_Request, jsonOutput: boolean, streaming: boolean): TRequest {
|
||||
export function aixToOpenAIChatCompletions(openAIDialect: OpenAIDialects, model: AixAPI_Model, _chatGenerate: AixAPIChatGenerate_Request, streaming: boolean): TRequest {
|
||||
|
||||
// Pre-process CGR - approximate spill of System to User message
|
||||
const chatGenerate = aixSpillSystemToUser(_chatGenerate);
|
||||
@@ -70,11 +70,15 @@ export function aixToOpenAIChatCompletions(openAIDialect: OpenAIDialects, model:
|
||||
chatMessages = _fixAlternateUserAssistantRoles(chatMessages);
|
||||
|
||||
|
||||
// constrained output modes - both JSON and tool invocations
|
||||
// const strictJsonOutput = !!model.strictJsonOutput;
|
||||
const strictToolInvocations = !!model.strictToolInvocations;
|
||||
|
||||
// Construct the request payload
|
||||
let payload: TRequest = {
|
||||
model: model.id,
|
||||
messages: chatMessages,
|
||||
tools: chatGenerate.tools && _toOpenAITools(chatGenerate.tools),
|
||||
tools: chatGenerate.tools && _toOpenAITools(chatGenerate.tools, strictToolInvocations),
|
||||
tool_choice: chatGenerate.toolsPolicy && _toOpenAIToolChoice(openAIDialect, chatGenerate.toolsPolicy),
|
||||
parallel_tool_calls: undefined,
|
||||
max_tokens: model.maxTokens !== undefined ? model.maxTokens : undefined,
|
||||
@@ -83,7 +87,15 @@ export function aixToOpenAIChatCompletions(openAIDialect: OpenAIDialects, model:
|
||||
n: hotFixOnlySupportN1 ? undefined : 0, // NOTE: we choose to not support this at the API level - most downstram ecosystem supports 1 only, which is the default
|
||||
stream: streaming,
|
||||
stream_options: streaming ? { include_usage: true } : undefined,
|
||||
response_format: jsonOutput ? { type: 'json_object' } : undefined,
|
||||
response_format: model.strictJsonOutput ? {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: model.strictJsonOutput.name || 'response',
|
||||
description: model.strictJsonOutput.description,
|
||||
schema: model.strictJsonOutput.schema,
|
||||
strict: true,
|
||||
},
|
||||
} : undefined,
|
||||
seed: undefined,
|
||||
stop: undefined,
|
||||
user: undefined,
|
||||
@@ -623,7 +635,7 @@ function _toOpenAIMessages(systemMessage: AixMessages_SystemMessage | null, chat
|
||||
return chatMessages;
|
||||
}
|
||||
|
||||
function _toOpenAITools(itds: AixTools_ToolDefinition[]): NonNullable<TRequest['tools']> {
|
||||
function _toOpenAITools(itds: AixTools_ToolDefinition[], strictToolInvocations: boolean): NonNullable<TRequest['tools']> {
|
||||
return itds.map(itd => {
|
||||
const itdType = itd.type;
|
||||
switch (itdType) {
|
||||
@@ -639,7 +651,9 @@ function _toOpenAITools(itds: AixTools_ToolDefinition[]): NonNullable<TRequest['
|
||||
type: 'object',
|
||||
properties: input_schema?.properties ?? {},
|
||||
required: input_schema?.required,
|
||||
...(strictToolInvocations ? { additionalProperties: false } : {}), // required for strict tool invocations
|
||||
},
|
||||
...(strictToolInvocations ? { strict: true } : {}), // enable strict (grammar-constrained) tool invocation inputs
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export function aixToOpenAIResponses(
|
||||
openAIDialect: OpenAIDialects,
|
||||
model: AixAPI_Model,
|
||||
_chatGenerate: AixAPIChatGenerate_Request,
|
||||
jsonOutput: boolean,
|
||||
streaming: boolean,
|
||||
enableResumability: boolean,
|
||||
): TRequest {
|
||||
@@ -51,6 +50,10 @@ export function aixToOpenAIResponses(
|
||||
// NOTE: the zod parsing will remove the undefined values from the upstream request, enabling an easier construction
|
||||
// ---
|
||||
|
||||
// constrained output modes - both JSON and tool invocations
|
||||
// const strictJsonOutput = !!model.strictJsonOutput;
|
||||
const strictToolInvocations = !!model.strictToolInvocations;
|
||||
|
||||
const { requestInput, requestInstructions } = _toOpenAIResponsesRequestInput(chatGenerate.systemMessage, chatGenerate.chatSequence);
|
||||
const payload: TRequest = {
|
||||
|
||||
@@ -65,7 +68,7 @@ export function aixToOpenAIResponses(
|
||||
input: requestInput,
|
||||
|
||||
// Tools
|
||||
tools: chatGenerate.tools && _toOpenAIResponsesTools(chatGenerate.tools),
|
||||
tools: chatGenerate.tools && _toOpenAIResponsesTools(chatGenerate.tools, strictToolInvocations),
|
||||
tool_choice: chatGenerate.toolsPolicy && _toOpenAIResponsesToolChoice(chatGenerate.toolsPolicy),
|
||||
// parallel_tool_calls: undefined, // response if unset: true
|
||||
|
||||
@@ -98,15 +101,18 @@ export function aixToOpenAIResponses(
|
||||
payload.top_p = model.topP;
|
||||
}
|
||||
|
||||
// JSON output: not implemented yet - will need a schema definition (similar to the tool args definition)
|
||||
if (jsonOutput) {
|
||||
console.warn('[DEV] notImplemented: responses: jsonOutput');
|
||||
// payload.text = {
|
||||
// format: {
|
||||
// type: 'json_schema',
|
||||
// },
|
||||
// };
|
||||
}
|
||||
// Structured Outputs - JSON output grammar
|
||||
if (model.strictJsonOutput)
|
||||
payload.text = {
|
||||
...payload.text,
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: model.strictJsonOutput.name || 'response',
|
||||
description: model.strictJsonOutput.description,
|
||||
schema: model.strictJsonOutput.schema,
|
||||
strict: true,
|
||||
},
|
||||
};
|
||||
|
||||
// GPT-5 Verbosity: Add to existing text config or create new one
|
||||
if (model.vndOaiVerbosity) {
|
||||
@@ -481,7 +487,7 @@ function _toOpenAIResponsesRequestInput(systemMessage: AixMessages_SystemMessage
|
||||
};
|
||||
}
|
||||
|
||||
function _toOpenAIResponsesTools(itds: AixTools_ToolDefinition[]): NonNullable<TRequestTool[]> {
|
||||
function _toOpenAIResponsesTools(itds: AixTools_ToolDefinition[], strictToolInvocations: boolean): NonNullable<TRequestTool[]> {
|
||||
return itds.map(itd => {
|
||||
const itdType = itd.type;
|
||||
switch (itdType) {
|
||||
@@ -496,7 +502,9 @@ function _toOpenAIResponsesTools(itds: AixTools_ToolDefinition[]): NonNullable<T
|
||||
type: 'object',
|
||||
properties: input_schema?.properties ?? {},
|
||||
required: input_schema?.required,
|
||||
...(strictToolInvocations ? { additionalProperties: false } : {}), // required for strict tool invocations
|
||||
},
|
||||
...(strictToolInvocations ? { strict: true } : {}), // enable strict (grammar-constrained) tool invocation inputs
|
||||
};
|
||||
|
||||
case 'code_execution':
|
||||
|
||||
@@ -49,11 +49,24 @@ export function createChatGenerateDispatch(access: AixAPI_Access, model: AixAPI_
|
||||
const { dialect } = access;
|
||||
switch (dialect) {
|
||||
case 'anthropic': {
|
||||
|
||||
// [Anthropic, 2025-11-24] Detect if any tool uses Programmatic Tool Calling features (allowed_callers, input_examples)
|
||||
const usesProgrammaticToolCalling = chatGenerate.tools?.some(tool =>
|
||||
tool.type === 'function_call' && (
|
||||
tool.function_call.allowed_callers?.includes('code_execution') ||
|
||||
(tool.function_call.input_examples && tool.function_call.input_examples.length > 0)
|
||||
),
|
||||
) ?? false;
|
||||
|
||||
const anthropicRequest = anthropicAccess(access, '/v1/messages', {
|
||||
modelIdForBetaFeatures: model.id,
|
||||
vndAntWebFetch: model.vndAntWebFetch === 'auto',
|
||||
vndAnt1MContext: model.vndAnt1MContext === true,
|
||||
vndAntEffort: !!model.vndAntEffort,
|
||||
enableSkills: !!model.vndAntSkills,
|
||||
enableStrictOutputs: !!model.strictJsonOutput || !!model.strictToolInvocations, // [Anthropic, 2025-11-13] for both JSON output and grammar-constrained tool invocations inputs
|
||||
enableToolSearch: !!model.vndAntToolSearch,
|
||||
enableProgrammaticToolCalling: usesProgrammaticToolCalling,
|
||||
// enableCodeExecution: ...
|
||||
});
|
||||
|
||||
@@ -96,8 +109,8 @@ export function createChatGenerateDispatch(access: AixAPI_Access, model: AixAPI_
|
||||
request: {
|
||||
...ollamaAccess(access, '/v1/chat/completions'), // use the OpenAI-compatible endpoint
|
||||
method: 'POST',
|
||||
// body: ollamaChatCompletionPayload(model, _hist, access.ollamaJson, streaming),
|
||||
body: aixToOpenAIChatCompletions('openai', model, chatGenerate, access.ollamaJson, streaming),
|
||||
// body: ollamaChatCompletionPayload(model, _hist, streaming),
|
||||
body: aixToOpenAIChatCompletions('openai', model, chatGenerate, streaming),
|
||||
},
|
||||
// demuxerFormat: streaming ? 'json-nl' : null,
|
||||
demuxerFormat: streaming ? 'fast-sse' : null,
|
||||
@@ -130,7 +143,7 @@ export function createChatGenerateDispatch(access: AixAPI_Access, model: AixAPI_
|
||||
request: {
|
||||
...openAIAccess(access, model.id, '/v1/responses'),
|
||||
method: 'POST',
|
||||
body: aixToOpenAIResponses(dialect, model, chatGenerate, false, streaming, enableResumability),
|
||||
body: aixToOpenAIResponses(dialect, model, chatGenerate, streaming, enableResumability),
|
||||
},
|
||||
demuxerFormat: streaming ? 'fast-sse' : null,
|
||||
chatGenerateParse: streaming ? createOpenAIResponsesEventParser() : createOpenAIResponseParserNS(),
|
||||
@@ -141,7 +154,7 @@ export function createChatGenerateDispatch(access: AixAPI_Access, model: AixAPI_
|
||||
request: {
|
||||
...openAIAccess(access, model.id, '/v1/chat/completions'),
|
||||
method: 'POST',
|
||||
body: aixToOpenAIChatCompletions(dialect, model, chatGenerate, false, streaming),
|
||||
body: aixToOpenAIChatCompletions(dialect, model, chatGenerate, streaming),
|
||||
},
|
||||
demuxerFormat: streaming ? 'fast-sse' : null,
|
||||
chatGenerateParse: streaming ? createOpenAIChatCompletionsChunkParser() : createOpenAIChatCompletionsParserNS(),
|
||||
|
||||
@@ -61,6 +61,7 @@ export function createAnthropicMessageParser(): ChatGenerateParseFunction {
|
||||
let timeToFirstEvent: number;
|
||||
let messageStartTime: number | undefined = undefined;
|
||||
let chatInTokens: number | undefined = undefined;
|
||||
let needsTextSeparator = false; // insert text separator when text follows server tool
|
||||
|
||||
return function(pt: IParticleTransmitter, eventData: string, eventName?: string, context?: { retriesAvailable: boolean }): void {
|
||||
|
||||
@@ -153,9 +154,11 @@ export function createAnthropicMessageParser(): ChatGenerateParseFunction {
|
||||
console.log(`ant content_block_start[${index}]: type=${content_block.type}, ${debugInfo}`);
|
||||
}
|
||||
|
||||
switch (content_block.type) {
|
||||
switch (content_block.type) { // .content_block_start.type
|
||||
case 'text':
|
||||
pt.appendText(content_block.text);
|
||||
// add separator when text follows server tool execution
|
||||
pt.appendText(!needsTextSeparator ? content_block.text : '\n\n' + content_block.text);
|
||||
needsTextSeparator = false;
|
||||
// Note: In streaming mode, citations arrive via citations_delta events, not on content_block_start
|
||||
break;
|
||||
|
||||
@@ -173,6 +176,12 @@ export function createAnthropicMessageParser(): ChatGenerateParseFunction {
|
||||
// [Anthropic] Note: .input={} and is parsed as an object - if that's the case, we zap it to ''
|
||||
if (content_block && typeof content_block.input === 'object' && Object.keys(content_block.input).length === 0)
|
||||
content_block.input = null;
|
||||
|
||||
// [Anthropic, 2025-11-24] Programmatic Tool Calling - detect if called from code execution
|
||||
const isProgrammaticCall = content_block.caller?.type === 'code_execution_20250825';
|
||||
if (isProgrammaticCall && ANTHROPIC_DEBUG_EVENT_SEQUENCE)
|
||||
console.log(`[Anthropic] Programmatic tool call: ${content_block.name} called from code_execution (tool_id: ${content_block.caller?.type === 'code_execution_20250825' ? content_block.caller.tool_id : 'n/a'})`);
|
||||
|
||||
pt.startFunctionCallInvocation(content_block.id, content_block.name, 'incr_str', content_block.input! ?? null);
|
||||
break;
|
||||
|
||||
@@ -183,7 +192,7 @@ export function createAnthropicMessageParser(): ChatGenerateParseFunction {
|
||||
content_block.input = null;
|
||||
|
||||
// Show placeholder for known server tools
|
||||
switch (content_block.name) {
|
||||
switch (content_block.name) { // .server_tool_use.name
|
||||
case 'web_search':
|
||||
pt.sendVoidPlaceholder('search-web', 'Searching the web...');
|
||||
break;
|
||||
@@ -197,6 +206,11 @@ export function createAnthropicMessageParser(): ChatGenerateParseFunction {
|
||||
case 'text_editor_code_execution':
|
||||
pt.sendVoidPlaceholder('code-exec', '⚡ Executing code...');
|
||||
break;
|
||||
// [Anthropic, 2025-11-24] Tool Search Tool
|
||||
case 'tool_search_tool_regex':
|
||||
case 'tool_search_tool_bm25':
|
||||
pt.sendVoidPlaceholder('code-exec', '🔍 Searching available tools...');
|
||||
break;
|
||||
default:
|
||||
// For unknown server tools (e.g., future Skills), show a generic placeholder instead of throwing
|
||||
console.warn(`[Anthropic Parser] Unknown server tool: ${content_block.name}`);
|
||||
@@ -351,10 +365,28 @@ export function createAnthropicMessageParser(): ChatGenerateParseFunction {
|
||||
// using the Files API with content_block.file_id
|
||||
break;
|
||||
|
||||
case 'tool_result': // [Anthropic, 2025-11-24] Tool Search Tool - The actual tool definitions are auto-expanded by Anthropic's API
|
||||
if (Array.isArray(content_block.content)) {
|
||||
// success
|
||||
const toolNames = content_block.content.map((ref: { type: string; tool_name: string }) => ref.tool_name);
|
||||
pt.sendVoidPlaceholder('code-exec', `🔍 Discovered ${toolNames.length} tool(s): ${toolNames.join(', ')}`);
|
||||
// Log for future debugging
|
||||
console.log('[Anthropic] Tool search discovered:', { tools: toolNames });
|
||||
} else if (content_block.content?.type === 'tool_search_tool_result_error') {
|
||||
// error during tool search
|
||||
pt.sendVoidPlaceholder('code-exec', `🔍 Tool search error: ${content_block.content.error_code}`);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
const _exhaustiveCheck: never = content_block;
|
||||
throw new Error(`Unexpected content block type: ${(content_block as any).type}`);
|
||||
}
|
||||
|
||||
// set separator flag when server tools complete (text after tools needs visual separation)
|
||||
if (content_block.type.includes('tool_use') || content_block.type.includes('tool_result'))
|
||||
needsTextSeparator = true;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -474,6 +506,7 @@ export function createAnthropicMessageParser(): ChatGenerateParseFunction {
|
||||
if (tokenStopReason !== null)
|
||||
pt.setTokenStopReason(tokenStopReason);
|
||||
|
||||
// NOTE: we have more fields we're not parsing yet - https://platform.claude.com/docs/en/api/typescript/messages#message_delta_usage
|
||||
if (usage?.output_tokens && messageStartTime) {
|
||||
const elapsedTimeMilliseconds = Date.now() - messageStartTime;
|
||||
const elapsedTimeSeconds = elapsedTimeMilliseconds / 1000;
|
||||
@@ -551,6 +584,7 @@ export function createAnthropicMessageParser(): ChatGenerateParseFunction {
|
||||
|
||||
export function createAnthropicMessageParserNS(): ChatGenerateParseFunction {
|
||||
const parserCreationTimestamp = Date.now();
|
||||
let needsTextSeparator = false; // insert text separator when text follows server tool
|
||||
|
||||
return function(pt: IParticleTransmitter, fullData: string /*, eventName?: string, context?: { retriesAvailable: boolean } */): void {
|
||||
|
||||
@@ -570,9 +604,11 @@ export function createAnthropicMessageParserNS(): ChatGenerateParseFunction {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const contentBlock = content[i];
|
||||
const isLastBlock = i === content.length - 1;
|
||||
switch (contentBlock.type) {
|
||||
switch (contentBlock.type) { // .content_block (non-streaming)
|
||||
case 'text':
|
||||
pt.appendText(contentBlock.text);
|
||||
// add separator when text follows server tool execution
|
||||
pt.appendText(!needsTextSeparator ? contentBlock.text : '\n\n' + contentBlock.text);
|
||||
needsTextSeparator = false;
|
||||
// Handle citations if present (non-streaming mode has all citations attached)
|
||||
if (contentBlock.citations && Array.isArray(contentBlock.citations)) {
|
||||
for (const citation of contentBlock.citations) {
|
||||
@@ -603,6 +639,12 @@ export function createAnthropicMessageParserNS(): ChatGenerateParseFunction {
|
||||
|
||||
case 'tool_use':
|
||||
// NOTE: this gets parsed as an object, not string deltas of a json!
|
||||
|
||||
// [Anthropic, 2025-11-24] Programmatic Tool Calling - detect if called from code execution
|
||||
const isProgrammaticCallNS = contentBlock.caller?.type === 'code_execution_20250825';
|
||||
if (isProgrammaticCallNS)
|
||||
console.log(`[Anthropic] Programmatic tool call (non-streaming): ${contentBlock.name} called from code_execution (tool_id: ${contentBlock.caller?.type === 'code_execution_20250825' ? contentBlock.caller.tool_id : 'n/a'})`);
|
||||
|
||||
pt.startFunctionCallInvocation(contentBlock.id, contentBlock.name, 'json_object', (contentBlock.input as object) || null);
|
||||
pt.endMessagePart();
|
||||
break;
|
||||
@@ -610,7 +652,7 @@ export function createAnthropicMessageParserNS(): ChatGenerateParseFunction {
|
||||
case 'server_tool_use':
|
||||
// Server tool use in non-streaming mode
|
||||
// NOTE: We don't create tool invocations for server tools - just show placeholders
|
||||
switch (contentBlock.name) {
|
||||
switch (contentBlock.name) { // .server_tool_use.name
|
||||
case 'web_search':
|
||||
pt.sendVoidPlaceholder('search-web', 'Searching the web...');
|
||||
break;
|
||||
@@ -623,6 +665,11 @@ export function createAnthropicMessageParserNS(): ChatGenerateParseFunction {
|
||||
case 'text_editor_code_execution':
|
||||
pt.sendVoidPlaceholder('code-exec', '⚡ Executing code...');
|
||||
break;
|
||||
// [Anthropic, 2025-11-24] Tool Search Tool
|
||||
case 'tool_search_tool_regex':
|
||||
case 'tool_search_tool_bm25':
|
||||
pt.sendVoidPlaceholder('code-exec', '🔍 Searching available tools...');
|
||||
break;
|
||||
default:
|
||||
console.warn(`[Anthropic Parser] Unknown server tool (non-streaming): ${contentBlock.name}`);
|
||||
pt.sendVoidPlaceholder('code-exec', `⚡ Using ${contentBlock.name}...`);
|
||||
@@ -771,10 +818,27 @@ export function createAnthropicMessageParserNS(): ChatGenerateParseFunction {
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool_result': // [Anthropic, 2025-11-24] Tool Search Tool - The actual tool definitions are auto-expanded by Anthropic's API
|
||||
if (Array.isArray(contentBlock.content)) {
|
||||
// success
|
||||
const toolNames = contentBlock.content.map((ref: { type: string; tool_name: string }) => ref.tool_name);
|
||||
pt.sendVoidPlaceholder('code-exec', `🔍 Discovered ${toolNames.length} tool(s): ${toolNames.join(', ')}`);
|
||||
// Log for future debugging
|
||||
console.log('[Anthropic] Tool search discovered (non-streaming):', { tools: toolNames });
|
||||
} else if ((contentBlock.content as any)?.type === 'tool_search_tool_result_error') {
|
||||
// error during tool search
|
||||
pt.sendVoidPlaceholder('code-exec', `🔍 Tool search error: ${(contentBlock.content as any).error_code}`);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
const _exhaustiveCheck: never = contentBlock;
|
||||
throw new Error(`Unexpected content block type: ${(contentBlock as any).type}`);
|
||||
}
|
||||
|
||||
// set separator flag when server tools complete (text after tools needs visual separation)
|
||||
if (contentBlock.type.includes('tool_use') || contentBlock.type.includes('tool_result'))
|
||||
needsTextSeparator = true;
|
||||
}
|
||||
|
||||
// -> Token Stop Reason
|
||||
|
||||
@@ -10,6 +10,12 @@ import * as z from 'zod/v4';
|
||||
*
|
||||
* ## Updates
|
||||
*
|
||||
* ### 2025-11-24 - Programmatic Tool Calling (Beta: advanced-tool-use-2025-11-20)
|
||||
* - ToolUseBlock: added 'caller' field to indicate direct vs programmatic invocation
|
||||
* - CustomToolDefinition: added 'allowed_callers' field to restrict tool invocation contexts
|
||||
* - CustomToolDefinition: added 'input_examples' field for improved accuracy
|
||||
* - New ToolUseCaller_schema for discriminating caller types
|
||||
*
|
||||
* ### 2025-10-17 - MAJOR: Server Tools & 2025 API Additions
|
||||
* - ContentBlockOutput: added 9 new server tool response block types
|
||||
* - ToolDefinition: added 9 new 2025 tool types (web_search, web_fetch, memory, code_execution, etc.)
|
||||
@@ -119,6 +125,17 @@ export namespace AnthropicWire_Blocks {
|
||||
id: z.string(),
|
||||
name: z.string(), // length: 1-64
|
||||
input: z.any(), // Formally an 'object', but relaxed for robust parsing, and code-enforced
|
||||
/**
|
||||
* [Anthropic, 2025-11-24] Programmatic Tool Calling - indicates how this tool was invoked.
|
||||
* Requires the advanced-tool-use-2025-11-20 beta feature.
|
||||
*/
|
||||
caller: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('direct') }), // model called tool directly
|
||||
z.object({
|
||||
type: z.literal('code_execution_20250825'), // tool called programmatically from within code execution
|
||||
tool_id: z.string(), // ref the server_tool_use (code_execution) that made this call
|
||||
}),
|
||||
]).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -224,6 +241,8 @@ export namespace AnthropicWire_Blocks {
|
||||
'code_execution',
|
||||
'bash_code_execution', // sub-tool of 'code_execution'
|
||||
'text_editor_code_execution', // sub-tool of 'code_execution'
|
||||
'tool_search_tool_regex', // Tool Search Tool - regex variant
|
||||
'tool_search_tool_bm25', // Tool Search Tool - BM25 (natural text) variant
|
||||
]),
|
||||
z.string(), // forward-compatibility parsing
|
||||
]),
|
||||
@@ -369,6 +388,27 @@ export namespace AnthropicWire_Blocks {
|
||||
file_id: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* [Anthropic, 2025-11-24] Tool Search Tool - Result of tool search operation
|
||||
* Contains either an array of tool references or an error.
|
||||
*/
|
||||
export const ToolSearchToolResultBlock_schema = _CommonBlock_schema.extend({
|
||||
type: z.literal('tool_result'),
|
||||
tool_use_id: z.string(),
|
||||
content: z.union([
|
||||
// success - array of tool references
|
||||
z.array(z.object({
|
||||
type: z.literal('tool_reference'),
|
||||
tool_name: z.string(),
|
||||
})),
|
||||
// error
|
||||
z.object({
|
||||
type: z.literal('tool_search_tool_result_error'),
|
||||
error_code: z.union([z.enum(['too_many_requests', 'invalid_pattern', 'pattern_too_long', 'unavailable']), z.string() /* forward-compatibility */]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
|
||||
/// Block Constructors
|
||||
|
||||
@@ -457,6 +497,7 @@ export namespace AnthropicWire_Messages {
|
||||
* - Code execution tool result, Bash code execution tool result, Text editor code execution tool result
|
||||
* - MCP tool use, MCP tool result
|
||||
* - Container upload
|
||||
* - Tool reference
|
||||
*/
|
||||
export const ContentBlockOutput_schema = z.discriminatedUnion('type', [
|
||||
// Common Blocks (both input and output)
|
||||
@@ -474,6 +515,7 @@ export namespace AnthropicWire_Messages {
|
||||
AnthropicWire_Blocks.MCPToolUseBlock_schema,
|
||||
AnthropicWire_Blocks.MCPToolResultBlock_schema,
|
||||
AnthropicWire_Blocks.ContainerUploadBlock_schema,
|
||||
AnthropicWire_Blocks.ToolSearchToolResultBlock_schema, // [Anthropic, 2025-11-24] Tool Search Tool
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -543,6 +585,24 @@ export namespace AnthropicWire_Tools {
|
||||
properties: z.record(z.string(), z.any()).nullish(), // FC-DEF params schema - WAS: z.json().nullable(),
|
||||
required: z.array(z.string()).optional(), // 2025-02-24: seems to be removed; we may still have this, but it may also be within the 'properties' object
|
||||
}),
|
||||
|
||||
/**
|
||||
* [Anthropic, 2025-11-13] Structured Outputs - guarantees tool inputs to match `input_schema` exactly.
|
||||
*/
|
||||
strict: z.boolean().optional(),
|
||||
|
||||
/**
|
||||
* [Anthropic, 2025-11-24] Tool Search Tool - when true, this tool is not loaded into context initially and can be discovered via the tool search tool when needed.
|
||||
*/
|
||||
defer_loading: z.boolean().optional(),
|
||||
|
||||
/**
|
||||
* [Anthropic, 2025-11-24] Programmatic Tool Calling - 2 new fields:
|
||||
* - specifies which contexts can invoke this tool
|
||||
* - concrete usage examples to improve accuracy - can increase accuracy (e.g. 72% -> 90% in examples)
|
||||
*/
|
||||
allowed_callers: z.array(z.enum(['direct', 'code_execution_20250825'])).optional(), // can be both ['direct', 'code_execution_20250825']
|
||||
input_examples: z.array(z.record(z.string(), z.any())).optional(),
|
||||
});
|
||||
|
||||
// Latest Tool Versions (sorted alphabetically by tool name)
|
||||
@@ -589,6 +649,18 @@ export namespace AnthropicWire_Tools {
|
||||
max_characters: z.number().nullish(),
|
||||
});
|
||||
|
||||
/** [Anthropic, 2025-11-24] Tool Search Tool - constructs regex patterns (e.g., "weather", "get_.*_data") to search tool names/descriptions. */
|
||||
const _ToolSearchToolRegex_20251119_schema = _ToolDefinitionBase_schema.extend({
|
||||
type: z.literal('tool_search_tool_regex_20251119'),
|
||||
name: z.literal('tool_search_tool_regex'),
|
||||
});
|
||||
|
||||
/** [Anthropic, 2025-11-24] Tool Search Tool - BM25 variant (natural language search) - uses natural language queries to search for tools. */
|
||||
const _ToolSearchToolBM25_20251119_schema = _ToolDefinitionBase_schema.extend({
|
||||
type: z.literal('tool_search_tool_bm25_20251119'),
|
||||
name: z.literal('tool_search_tool_bm25'),
|
||||
});
|
||||
|
||||
const _WebFetchTool_20250910_schema = _ToolDefinitionBase_schema.extend({
|
||||
type: z.literal('web_fetch_20250910'),
|
||||
name: z.literal('web_fetch'),
|
||||
@@ -617,6 +689,8 @@ export namespace AnthropicWire_Tools {
|
||||
_ComputerUseTool_20250124_schema,
|
||||
_MemoryTool_20250818_schema,
|
||||
_TextEditor_20250728_schema,
|
||||
_ToolSearchToolBM25_20251119_schema, // [Anthropic, 2025-11-24] Tool Search Tool - BM25 variant
|
||||
_ToolSearchToolRegex_20251119_schema, // [Anthropic, 2025-11-24] Tool Search Tool - Regex variant
|
||||
_WebFetchTool_20250910_schema,
|
||||
_WebSearchTool_20250305_schema,
|
||||
]);
|
||||
@@ -769,6 +843,14 @@ export namespace AnthropicWire_API_Message_Create {
|
||||
z.object({ type: z.literal('disabled') }),
|
||||
]).optional(),
|
||||
|
||||
/**
|
||||
* [Anthropic, effort-2025-11-24] Output configuration for effort-based token control.
|
||||
* Allows trading off response thoroughness for efficiency (Claude Opus 4.5+ only).
|
||||
*/
|
||||
output_config: z.object({
|
||||
effort: z.enum(['low', 'medium', 'high']).optional(),
|
||||
}).optional(),
|
||||
|
||||
/**
|
||||
* Defaults to 1.0. Ranges from 0.0 to 1.0. Use temperature closer to 0.0 for analytical / multiple choice, and closer to 1.0 for creative and generative tasks.
|
||||
*/
|
||||
@@ -785,6 +867,17 @@ export namespace AnthropicWire_API_Message_Create {
|
||||
* Recommended for advanced use cases only. You usually only need to use `temperature`.
|
||||
* */
|
||||
top_p: z.number().optional(),
|
||||
|
||||
/**
|
||||
* [Anthropic, 2025-11-13] Structured Outputs - JSON output format configuration.
|
||||
* Constrains Claude's response to follow a specific JSON schema.
|
||||
* Beta feature requiring header: "structured-outputs-2025-11-13"
|
||||
* Available for Claude Sonnet 4.5 and Claude Opus 4.1+.
|
||||
*/
|
||||
output_format: z.object({
|
||||
type: z.literal('json_schema'),
|
||||
schema: z.any(), // JSON Schema object - validated by Anthropic
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
/// Response
|
||||
@@ -831,6 +924,7 @@ export namespace AnthropicWire_API_Message_Create {
|
||||
server_tool_use: z.object({
|
||||
web_fetch_requests: z.number(),
|
||||
web_search_requests: z.number(),
|
||||
tool_search_requests: z.number().optional(), // [Anthropic, 2025-11-24] Tool Search Tool usage
|
||||
}).nullish(),
|
||||
service_tier: z.enum(['standard', 'priority', 'batch']).nullish(),
|
||||
}),
|
||||
@@ -872,8 +966,18 @@ export namespace AnthropicWire_API_Message_Create {
|
||||
stop_reason: StopReason_schema.nullable(),
|
||||
stop_sequence: z.string().nullable(),
|
||||
}),
|
||||
// MessageDeltaUsage
|
||||
usage: z.object({ output_tokens: z.number() }),
|
||||
// MessageDeltaUsage - extended to include cache and server tool metrics
|
||||
usage: z.object({
|
||||
cache_creation_input_tokens: z.number().nullish(),
|
||||
cache_read_input_tokens: z.number().nullish(),
|
||||
input_tokens: z.number().nullish(),
|
||||
output_tokens: z.number(),
|
||||
server_tool_use: z.object({
|
||||
web_fetch_requests: z.number().optional(),
|
||||
web_search_requests: z.number().optional(),
|
||||
tool_search_requests: z.number().optional(),
|
||||
}).nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const event_ContentBlockStart_schema = z.object({
|
||||
|
||||
@@ -143,13 +143,31 @@ async function workerPuppeteer(
|
||||
};
|
||||
|
||||
// [puppeteer] start the remote session
|
||||
const browser: Browser = await puppeteer.connect({
|
||||
browserWSEndpoint,
|
||||
// Add default options for better stability
|
||||
// defaultViewport: { width: 1024, height: 768 },
|
||||
// acceptInsecureCerts: true,
|
||||
protocolTimeout: WORKER_TIMEOUT + 2000, // 2s extra for taking the screenshot?
|
||||
});
|
||||
let browser: Browser;
|
||||
try {
|
||||
browser = await puppeteer.connect({
|
||||
browserWSEndpoint,
|
||||
// Add default options for better stability
|
||||
// defaultViewport: { width: 1024, height: 768 },
|
||||
// acceptInsecureCerts: true,
|
||||
protocolTimeout: WORKER_TIMEOUT + 2000, // 2s extra for taking the screenshot?
|
||||
});
|
||||
} catch (connectError: any) {
|
||||
// Transform connection errors into user-friendly messages
|
||||
const errorMessage = connectError?.message || '';
|
||||
if (errorMessage.includes('403'))
|
||||
throw new Error('Browse service authentication failed (403). Please check your browser endpoint credentials.');
|
||||
if (errorMessage.includes('401'))
|
||||
throw new Error('Browse service unauthorized (401). Invalid credentials for the browser endpoint.');
|
||||
if (errorMessage.includes('429'))
|
||||
throw new Error('Browse service rate limited (429). Too many requests, please try again later.');
|
||||
if (errorMessage.includes('502') || errorMessage.includes('503') || errorMessage.includes('504'))
|
||||
throw new Error('Browse service temporarily unavailable. Please try again later.');
|
||||
if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ENOTFOUND'))
|
||||
throw new Error('Browse service unreachable. The browser endpoint is not accessible.');
|
||||
// Re-throw with a cleaner message for other connection errors
|
||||
throw new Error(`Browse service connection failed: ${errorMessage || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// for local testing, open an incognito context, to separate cookies
|
||||
let incognitoContext: BrowserContext | null = null;
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { FormControl } from '@mui/joy';
|
||||
|
||||
import { useChatAutoAI } from '../../apps/chat/store-app-chat';
|
||||
|
||||
import { AlreadySet } from '~/common/components/AlreadySet';
|
||||
import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { FormRadioControl } from '~/common/components/forms/FormRadioControl';
|
||||
import { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
|
||||
|
||||
import { isElevenLabsEnabled } from './elevenlabs.client';
|
||||
import { useElevenLabsVoiceDropdown, useElevenLabsVoices } from './useElevenLabsVoiceDropdown';
|
||||
import { useElevenLabsApiKey } from './store-module-elevenlabs';
|
||||
|
||||
|
||||
export function ElevenlabsSettings() {
|
||||
|
||||
// state
|
||||
const [apiKey, setApiKey] = useElevenLabsApiKey();
|
||||
|
||||
// external state
|
||||
const { autoSpeak, setAutoSpeak } = useChatAutoAI();
|
||||
const { hasVoices } = useElevenLabsVoices();
|
||||
const { isConfiguredServerSide } = useCapabilityElevenLabs();
|
||||
const { voicesDropdown } = useElevenLabsVoiceDropdown(true);
|
||||
|
||||
|
||||
// derived state
|
||||
const isValidKey = isElevenLabsEnabled(apiKey);
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
{/*<FormHelperText>*/}
|
||||
{/* 📢 Hear AI responses, even in your own voice*/}
|
||||
{/*</FormHelperText>*/}
|
||||
|
||||
<FormRadioControl
|
||||
title='Speak Responses'
|
||||
description={autoSpeak === 'off' ? 'Off' : 'First paragraph'}
|
||||
tooltip={!hasVoices ? 'No voices available, please configure a voice synthesis service' : undefined}
|
||||
disabled={!hasVoices}
|
||||
options={[
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'firstLine', label: 'Start' },
|
||||
{ value: 'all', label: 'Full' },
|
||||
]}
|
||||
value={autoSpeak} onChange={setAutoSpeak}
|
||||
/>
|
||||
|
||||
|
||||
{!isConfiguredServerSide && <FormInputKey
|
||||
autoCompleteId='elevenlabs-key' label='ElevenLabs API Key'
|
||||
rightLabel={<AlreadySet required={!isConfiguredServerSide} />}
|
||||
value={apiKey} onChange={setApiKey}
|
||||
required={!isConfiguredServerSide} isError={!isValidKey}
|
||||
/>}
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart title='Assistant Voice' />
|
||||
{voicesDropdown}
|
||||
</FormControl>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { getBackendCapabilities } from '~/modules/backend/store-backend-capabilities';
|
||||
|
||||
import { AudioLivePlayer } from '~/common/util/audio/AudioLivePlayer';
|
||||
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
import { CapabilityElevenLabsSpeechSynthesis } from '~/common/components/useCapabilities';
|
||||
import { apiStream } from '~/common/util/trpc.client';
|
||||
import { convert_Base64_To_UInt8Array } from '~/common/util/blobUtils';
|
||||
import { useUIPreferencesStore } from '~/common/stores/store-ui';
|
||||
|
||||
import { getElevenLabsData, useElevenLabsData } from './store-module-elevenlabs';
|
||||
|
||||
|
||||
export const isValidElevenLabsApiKey = (apiKey?: string) => !!apiKey && apiKey.trim()?.length >= 32;
|
||||
|
||||
export const isElevenLabsEnabled = (apiKey?: string) =>
|
||||
apiKey ? isValidElevenLabsApiKey(apiKey)
|
||||
: getBackendCapabilities().hasVoiceElevenLabs;
|
||||
|
||||
|
||||
export function useCapability(): CapabilityElevenLabsSpeechSynthesis {
|
||||
const [clientApiKey, voiceId] = useElevenLabsData();
|
||||
const isConfiguredServerSide = getBackendCapabilities().hasVoiceElevenLabs;
|
||||
const isConfiguredClientSide = clientApiKey ? isValidElevenLabsApiKey(clientApiKey) : false;
|
||||
const mayWork = isConfiguredServerSide || isConfiguredClientSide || !!voiceId;
|
||||
return { mayWork, isConfiguredServerSide, isConfiguredClientSide };
|
||||
}
|
||||
|
||||
|
||||
interface ElevenLabsSpeakResult {
|
||||
success: boolean;
|
||||
audioBase64?: string; // Available when not streaming
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Speaks text using ElevenLabs TTS
|
||||
* @returns Object with success status and optionally the audio base64 (when not streaming)
|
||||
*/
|
||||
export async function elevenLabsSpeakText(text: string, voiceId: string | undefined, audioStreaming: boolean, audioTurbo: boolean): Promise<ElevenLabsSpeakResult> {
|
||||
// Early validation
|
||||
if (!(text?.trim())) {
|
||||
// console.log('ElevenLabs: No text to speak');
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const { elevenLabsApiKey, elevenLabsVoiceId } = getElevenLabsData();
|
||||
if (!isElevenLabsEnabled(elevenLabsApiKey)) {
|
||||
// console.warn('ElevenLabs: Service not enabled or configured');
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const { preferredLanguage } = useUIPreferencesStore.getState();
|
||||
const nonEnglish = !(preferredLanguage?.toLowerCase()?.startsWith('en'));
|
||||
|
||||
// audio live player instance, if needed
|
||||
let liveAudioPlayer: AudioLivePlayer | undefined;
|
||||
let playbackStarted = false;
|
||||
let audioBase64: string | undefined;
|
||||
|
||||
try {
|
||||
|
||||
const stream = await apiStream.elevenlabs.speech.mutate({
|
||||
xiKey: elevenLabsApiKey,
|
||||
voiceId: voiceId || elevenLabsVoiceId,
|
||||
text: text,
|
||||
nonEnglish,
|
||||
audioStreaming,
|
||||
audioTurbo,
|
||||
});
|
||||
|
||||
for await (const piece of stream) {
|
||||
|
||||
// ElevenLabs stream buffer
|
||||
if (piece.audioChunk) {
|
||||
try {
|
||||
// create the live audio player as needed
|
||||
// NOTE: in the future we can have a centralized audio playing system
|
||||
if (!liveAudioPlayer)
|
||||
liveAudioPlayer = new AudioLivePlayer();
|
||||
|
||||
// enqueue a decoded audio chunk - this will throw on malformed base64 data
|
||||
const chunkArray = convert_Base64_To_UInt8Array(piece.audioChunk.base64, 'elevenLabsSpeakText (chunk)');
|
||||
liveAudioPlayer.enqueueChunk(chunkArray.buffer);
|
||||
playbackStarted = true;
|
||||
} catch (audioError) {
|
||||
console.error('ElevenLabs audio chunk error:', audioError);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ElevenLabs full audio buffer
|
||||
else if (piece.audio) {
|
||||
try {
|
||||
// return base64 for potential reuse
|
||||
if (!audioStreaming)
|
||||
audioBase64 = piece.audio.base64;
|
||||
|
||||
// also consider merging LiveAudioPlayer into AudioPlayer - note this will throw on malformed base64 data
|
||||
const audioArray = convert_Base64_To_UInt8Array(piece.audio.base64, 'elevenLabsSpeakText');
|
||||
void AudioPlayer.playBuffer(audioArray.buffer); // fire/forget - it's a single piece of audio (could be long tho)
|
||||
playbackStarted = true;
|
||||
} catch (audioError) {
|
||||
console.error('ElevenLabs audio buffer error:', audioError);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Errors
|
||||
else if (piece.errorMessage) {
|
||||
console.error('ElevenLabs error:', piece.errorMessage);
|
||||
return { success: false };
|
||||
} else if (piece.warningMessage) {
|
||||
console.warn('ElevenLabs warning:', piece.warningMessage);
|
||||
// Continue processing warnings
|
||||
} else if (piece.control === 'start' || piece.control === 'end') {
|
||||
// Control messages - continue processing
|
||||
} else {
|
||||
console.log('ElevenLabs unknown piece:', piece);
|
||||
}
|
||||
}
|
||||
return { success: playbackStarted, audioBase64 };
|
||||
} catch (error) {
|
||||
console.error('ElevenLabs playback error:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '~/server/trpc/trpc.server';
|
||||
import { env } from '~/server/env.server';
|
||||
import { fetchJsonOrTRPCThrow, fetchResponseOrTRPCThrow } from '~/server/trpc/trpc.router.fetchers';
|
||||
|
||||
|
||||
// configuration
|
||||
const SAFETY_TEXT_LENGTH = 1000;
|
||||
const MIN_CHUNK_SIZE = 4096; // Minimum chunk size in bytes
|
||||
|
||||
|
||||
// Schema definitions
|
||||
export type SpeechInputSchema = z.infer<typeof speechInputSchema>;
|
||||
export const speechInputSchema = z.object({
|
||||
xiKey: z.string().optional(),
|
||||
voiceId: z.string().optional(),
|
||||
text: z.string(),
|
||||
nonEnglish: z.boolean(),
|
||||
audioStreaming: z.boolean(),
|
||||
audioTurbo: z.boolean(),
|
||||
});
|
||||
|
||||
export type VoiceSchema = z.infer<typeof voiceSchema>;
|
||||
const voiceSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
previewUrl: z.string().nullable(),
|
||||
category: z.string(),
|
||||
default: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
export const elevenlabsRouter = createTRPCRouter({
|
||||
|
||||
/**
|
||||
* List Voices available to this API key
|
||||
*/
|
||||
listVoices: publicProcedure
|
||||
.input(z.object({
|
||||
elevenKey: z.string().optional(),
|
||||
}))
|
||||
.output(z.object({
|
||||
voices: z.array(voiceSchema),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
|
||||
const { elevenKey } = input;
|
||||
const { headers, url } = elevenlabsAccess(elevenKey, '/v1/voices');
|
||||
|
||||
const voicesList = await fetchJsonOrTRPCThrow<ElevenlabsWire.VoicesList>({
|
||||
url,
|
||||
headers,
|
||||
name: 'ElevenLabs',
|
||||
});
|
||||
|
||||
// bring category != 'premade' to the top
|
||||
voicesList.voices.sort((a, b) => {
|
||||
if (a.category === 'premade' && b.category !== 'premade') return 1;
|
||||
if (a.category !== 'premade' && b.category === 'premade') return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return {
|
||||
voices: voicesList.voices.map((voice, idx) => ({
|
||||
id: voice.voice_id,
|
||||
name: voice.name,
|
||||
description: voice.description,
|
||||
previewUrl: voice.preview_url,
|
||||
category: voice.category,
|
||||
default: idx === 0,
|
||||
})),
|
||||
};
|
||||
|
||||
}),
|
||||
|
||||
/**
|
||||
* Speech synthesis procedure using tRPC streaming
|
||||
*/
|
||||
speech: publicProcedure
|
||||
.input(speechInputSchema)
|
||||
.mutation(async function* ({ input: { xiKey, text, voiceId, nonEnglish, audioStreaming, audioTurbo }, ctx }) {
|
||||
|
||||
// start streaming back
|
||||
yield { control: 'start' };
|
||||
|
||||
// Safety check: trim text that's too long
|
||||
if (text.length > SAFETY_TEXT_LENGTH) {
|
||||
text = text.slice(0, SAFETY_TEXT_LENGTH);
|
||||
yield { warningMessage: 'text was truncated to maximum length' };
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
|
||||
// Prepare the upstream request
|
||||
const path = `/v1/text-to-speech/${elevenlabsVoiceId(voiceId)}${audioStreaming ? '/stream' : ''}`;
|
||||
const { headers, url } = elevenlabsAccess(xiKey, path);
|
||||
const body: ElevenlabsWire.TTSRequest = {
|
||||
text: text,
|
||||
model_id:
|
||||
audioTurbo ? 'eleven_turbo_v2_5'
|
||||
: nonEnglish ? 'eleven_multilingual_v2'
|
||||
: 'eleven_multilingual_v2', // even for english, use the latest multilingual model
|
||||
};
|
||||
|
||||
// Blocking fetch
|
||||
response = await fetchResponseOrTRPCThrow({ url, method: 'POST', headers, body, signal: ctx.reqSignal, name: 'ElevenLabs' });
|
||||
|
||||
} catch (error: any) {
|
||||
yield { errorMessage: `fetch issue: ${error.message || 'Unknown error'}` };
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
const responseHeaders = _safeParseTTSResponseHeaders(response.headers);
|
||||
|
||||
// If not streaming, return the entire audio
|
||||
if (!audioStreaming) {
|
||||
const audioArrayBuffer = await response.arrayBuffer();
|
||||
yield {
|
||||
audio: {
|
||||
base64: Buffer.from(audioArrayBuffer).toString('base64'),
|
||||
contentType: responseHeaders.contentType,
|
||||
characterCost: responseHeaders.characterCost,
|
||||
ttsLatencyMs: responseHeaders.ttsLatencyMs,
|
||||
},
|
||||
};
|
||||
yield { control: 'end' };
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
yield { errorMessage: 'stream issue: No reader' };
|
||||
return;
|
||||
}
|
||||
|
||||
// STREAM the audio chunks back to the client
|
||||
try {
|
||||
|
||||
// Initialize a buffer to accumulate chunks
|
||||
const accumulatedChunks: Uint8Array[] = [];
|
||||
let accumulatedSize = 0;
|
||||
|
||||
// Read loop
|
||||
while (true) {
|
||||
const { value, done: readerDone } = await reader.read();
|
||||
if (readerDone) break;
|
||||
if (!value) continue;
|
||||
|
||||
// Accumulate chunks
|
||||
accumulatedChunks.push(value);
|
||||
accumulatedSize += value.length;
|
||||
|
||||
// When accumulated size reaches or exceeds MIN_CHUNK_SIZE, yield the chunk
|
||||
if (accumulatedSize >= MIN_CHUNK_SIZE) {
|
||||
yield {
|
||||
audioChunk: {
|
||||
base64: Buffer.concat(accumulatedChunks).toString('base64'),
|
||||
},
|
||||
};
|
||||
// Reset the accumulation
|
||||
accumulatedChunks.length = 0;
|
||||
accumulatedSize = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// If there's any remaining data, yield it as well
|
||||
if (accumulatedSize) {
|
||||
yield {
|
||||
audioChunk: {
|
||||
base64: Buffer.concat(accumulatedChunks).toString('base64'),
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
yield { errorMessage: `stream issue: ${error.message || 'Unknown error'}` };
|
||||
return;
|
||||
}
|
||||
|
||||
// end streaming (if a control error wasn't thrown)
|
||||
yield { control: 'end' };
|
||||
}),
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to construct ElevenLabs API access details
|
||||
*/
|
||||
export function elevenlabsAccess(elevenKey: string | undefined, apiPath: string): { headers: HeadersInit; url: string } {
|
||||
// API key
|
||||
elevenKey = (elevenKey || env.ELEVENLABS_API_KEY || '').trim();
|
||||
if (!elevenKey)
|
||||
throw new Error('Missing ElevenLabs API key.');
|
||||
|
||||
// API host
|
||||
let host = (env.ELEVENLABS_API_HOST || 'api.elevenlabs.io').trim();
|
||||
if (!host.startsWith('http'))
|
||||
host = `https://${host}`;
|
||||
if (host.endsWith('/') && apiPath.startsWith('/'))
|
||||
host = host.slice(0, -1);
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'Accept': 'audio/mpeg',
|
||||
'Content-Type': 'application/json',
|
||||
'xi-api-key': elevenKey,
|
||||
},
|
||||
url: host + apiPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function elevenlabsVoiceId(voiceId?: string): string {
|
||||
return voiceId?.trim() || env.ELEVENLABS_VOICE_ID || '21m00Tcm4TlvDq8ikWAM';
|
||||
}
|
||||
|
||||
|
||||
function _safeParseTTSResponseHeaders(headers: Headers): ElevenlabsWire.TTSResponseHeaders {
|
||||
return {
|
||||
contentType: headers.get('content-type') || 'audio/mpeg',
|
||||
characterCost: parseInt(headers.get('character-cost') || '0'),
|
||||
currentConcurrentRequests: parseInt(headers.get('current-concurrent-requests') || '0'),
|
||||
maximumConcurrentRequests: parseInt(headers.get('maximum-concurrent-requests') || '0'),
|
||||
ttsLatencyMs: parseInt(headers.get('tts-latency-ms') || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// This is the upstream API [rev-eng on 2023-04-12]
|
||||
export namespace ElevenlabsWire {
|
||||
export interface TTSRequest {
|
||||
text: string;
|
||||
model_id?:
|
||||
| 'eleven_monolingual_v1'
|
||||
| 'eleven_multilingual_v1'
|
||||
| 'eleven_multilingual_v2'
|
||||
| 'eleven_turbo_v2'
|
||||
| 'eleven_turbo_v2_5';
|
||||
voice_settings?: {
|
||||
stability: number;
|
||||
similarity_boost: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TTSResponseHeaders {
|
||||
// Response metadata
|
||||
contentType: string; // Should be 'audio/mpeg'
|
||||
|
||||
// Cost and usage metrics
|
||||
characterCost: number; // Cost in characters for this generation
|
||||
currentConcurrentRequests: number; // Current number of concurrent requests
|
||||
maximumConcurrentRequests: number; // Maximum allowed concurrent requests
|
||||
ttsLatencyMs?: number; // Time taken to generate speech (not in streaming mode)
|
||||
}
|
||||
|
||||
export interface VoicesList {
|
||||
voices: Voice[];
|
||||
}
|
||||
|
||||
interface Voice {
|
||||
voice_id: string;
|
||||
name: string;
|
||||
//samples: Sample[];
|
||||
category: string;
|
||||
// fine_tuning: FineTuning;
|
||||
labels: Record<string, string>;
|
||||
description: string;
|
||||
preview_url: string;
|
||||
// available_for_tiers: string[];
|
||||
settings: {
|
||||
stability: number;
|
||||
similarity_boost: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
||||
interface ModuleElevenlabsStore {
|
||||
|
||||
// ElevenLabs Text to Speech settings
|
||||
|
||||
elevenLabsApiKey: string;
|
||||
setElevenLabsApiKey: (apiKey: string) => void;
|
||||
|
||||
elevenLabsVoiceId: string;
|
||||
setElevenLabsVoiceId: (voiceId: string) => void;
|
||||
|
||||
}
|
||||
|
||||
const useElevenlabsStore = create<ModuleElevenlabsStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
|
||||
// ElevenLabs Text to Speech settings
|
||||
|
||||
elevenLabsApiKey: '',
|
||||
setElevenLabsApiKey: (elevenLabsApiKey: string) => set({ elevenLabsApiKey }),
|
||||
|
||||
elevenLabsVoiceId: '',
|
||||
setElevenLabsVoiceId: (elevenLabsVoiceId: string) => set({ elevenLabsVoiceId }),
|
||||
|
||||
}),
|
||||
{
|
||||
name: 'app-module-elevenlabs',
|
||||
}),
|
||||
);
|
||||
|
||||
export const useElevenLabsApiKey = (): [string, (apiKey: string) => void] => {
|
||||
const apiKey = useElevenlabsStore(state => state.elevenLabsApiKey);
|
||||
return [apiKey, useElevenlabsStore.getState().setElevenLabsApiKey];
|
||||
};
|
||||
|
||||
export const useElevenLabsVoiceId = (): [string, (voiceId: string) => void] => {
|
||||
const voiceId = useElevenlabsStore(state => state.elevenLabsVoiceId);
|
||||
return [voiceId, useElevenlabsStore.getState().setElevenLabsVoiceId];
|
||||
};
|
||||
|
||||
export const useElevenLabsData = (): [string, string] =>
|
||||
useElevenlabsStore(useShallow(state => [state.elevenLabsApiKey, state.elevenLabsVoiceId]));
|
||||
|
||||
export const getElevenLabsData = (): { elevenLabsApiKey: string, elevenLabsVoiceId: string } =>
|
||||
useElevenlabsStore.getState();
|
||||
@@ -1,102 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { CircularProgress, Option, Select } from '@mui/joy';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTone';
|
||||
|
||||
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
import { apiQuery } from '~/common/util/trpc.client';
|
||||
|
||||
import { VoiceSchema } from './elevenlabs.router';
|
||||
import { isElevenLabsEnabled } from './elevenlabs.client';
|
||||
import { useElevenLabsApiKey, useElevenLabsVoiceId } from './store-module-elevenlabs';
|
||||
|
||||
|
||||
function VoicesDropdown(props: {
|
||||
isValidKey: boolean,
|
||||
isFetchingVoices: boolean,
|
||||
isErrorVoices: boolean,
|
||||
disabled?: boolean,
|
||||
voices: VoiceSchema[],
|
||||
voiceId: string | null,
|
||||
setVoiceId: (voiceId: string) => void,
|
||||
}) {
|
||||
|
||||
const handleVoiceChange = (_event: any, value: string | null) => props.setVoiceId(value || '');
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={props.voiceId} onChange={handleVoiceChange}
|
||||
variant='outlined' disabled={props.disabled || !props.voices.length}
|
||||
// color={props.isErrorVoices ? 'danger' : undefined}
|
||||
placeholder={props.isErrorVoices ? 'Issue loading voices' : props.isValidKey ? 'Select a voice' : 'Missing API Key'}
|
||||
startDecorator={<RecordVoiceOverTwoToneIcon />}
|
||||
endDecorator={props.isValidKey && props.isFetchingVoices && <CircularProgress size='sm' />}
|
||||
indicator={<KeyboardArrowDownIcon />}
|
||||
slotProps={{
|
||||
root: { sx: { width: '100%' } },
|
||||
indicator: { sx: { opacity: 0.5 } },
|
||||
}}
|
||||
>
|
||||
{props.voices.map(voice => (
|
||||
<Option key={voice.id} value={voice.id}>
|
||||
{voice.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function useElevenLabsVoices() {
|
||||
const [apiKey] = useElevenLabsApiKey();
|
||||
|
||||
const isConfigured = isElevenLabsEnabled(apiKey);
|
||||
|
||||
const { data, isError, isFetching, isPending } = apiQuery.elevenlabs.listVoices.useQuery({ elevenKey: apiKey }, {
|
||||
enabled: isConfigured,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
return {
|
||||
isConfigured,
|
||||
isError,
|
||||
isFetching,
|
||||
hasVoices: !isPending && !!data?.voices.length,
|
||||
voices: data?.voices || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function useElevenLabsVoiceDropdown(autoSpeak: boolean, disabled?: boolean) {
|
||||
|
||||
// external state
|
||||
const { isConfigured, isError, isFetching, hasVoices, voices } = useElevenLabsVoices();
|
||||
const [voiceId, setVoiceId] = useElevenLabsVoiceId();
|
||||
|
||||
// derived state
|
||||
const voice: VoiceSchema | undefined = voices.find(voice => voice.id === voiceId);
|
||||
|
||||
// [E] autoSpeak
|
||||
const previewUrl = (autoSpeak && voice?.previewUrl) || null;
|
||||
React.useEffect(() => {
|
||||
if (previewUrl)
|
||||
void AudioPlayer.playUrl(previewUrl);
|
||||
}, [previewUrl]);
|
||||
|
||||
const voicesDropdown = React.useMemo(() =>
|
||||
<VoicesDropdown
|
||||
isValidKey={isConfigured} isFetchingVoices={isFetching} isErrorVoices={isError} disabled={disabled}
|
||||
voices={voices}
|
||||
voiceId={voiceId} setVoiceId={setVoiceId}
|
||||
/>,
|
||||
[disabled, isConfigured, isError, isFetching, setVoiceId, voiceId, voices],
|
||||
);
|
||||
|
||||
return {
|
||||
hasVoices,
|
||||
voiceId,
|
||||
voiceName: voice?.name,
|
||||
voicesDropdown,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hasGoogleAnalytics, sendGAEvent } from '~/common/components/3rdparty/GoogleAnalytics';
|
||||
|
||||
import type { DModelsService, DModelsServiceId } from '~/common/stores/llms/llms.service.types';
|
||||
import { DLLM, LLM_IF_HOTFIX_NoTemperature, LLM_IF_OAI_Chat, LLM_IF_OAI_Fn } from '~/common/stores/llms/llms.types';
|
||||
import { DLLM, DModelInterfaceV1, LLM_IF_HOTFIX_NoTemperature, LLM_IF_OAI_Chat, LLM_IF_OAI_Fn } from '~/common/stores/llms/llms.types';
|
||||
import { applyModelParameterInitialValues, FALLBACK_LLM_PARAM_TEMPERATURE } from '~/common/stores/llms/llms.parameters';
|
||||
import { isModelPricingFree } from '~/common/stores/llms/llms.pricing';
|
||||
import { llmsStoreActions } from '~/common/stores/llms/store-llms';
|
||||
@@ -88,7 +88,7 @@ function _createDLLMFromModelDescription(d: ModelDescriptionSchema, service: DMo
|
||||
contextTokens,
|
||||
maxOutputTokens,
|
||||
trainingDataCutoff: d.trainingDataCutoff,
|
||||
interfaces: d.interfaces?.length ? d.interfaces : _fallbackInterfaces,
|
||||
interfaces: d.interfaces?.length ? d.interfaces as DModelInterfaceV1[] : _fallbackInterfaces,
|
||||
benchmark: d.benchmark,
|
||||
// pricing?: ..., // set below, since it needs some adaptation
|
||||
|
||||
|
||||
@@ -120,6 +120,12 @@ const _antWebFetchOptions = [
|
||||
{ value: _UNSPECIFIED, label: 'Off', description: 'Disabled (default)' },
|
||||
] as const;
|
||||
|
||||
const _antEffortOptions = [
|
||||
{ value: _UNSPECIFIED, label: 'High', description: 'Maximum capability (default)' },
|
||||
{ value: 'medium', label: 'Medium', description: 'Balanced speed and quality' },
|
||||
{ value: 'low', label: 'Low', description: 'Fastest, most efficient' },
|
||||
] as const;
|
||||
|
||||
// const _moonshotWebSearchOptions = [
|
||||
// { value: 'auto', label: 'On', description: 'Enable Kimi $web_search ($0.005 per search)' },
|
||||
// { value: _UNSPECIFIED, label: 'Off', description: 'Disabled (default)' },
|
||||
@@ -187,6 +193,7 @@ export function LLMParametersEditor(props: {
|
||||
llmTemperature = FALLBACK_LLM_PARAM_TEMPERATURE, // fallback for undefined, result is number | null
|
||||
llmForceNoStream,
|
||||
llmVndAnt1MContext,
|
||||
llmVndAntEffort,
|
||||
llmVndAntSkills,
|
||||
llmVndAntThinkingBudget,
|
||||
llmVndAntWebFetch,
|
||||
@@ -317,6 +324,19 @@ export function LLMParametersEditor(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showParam('llmVndAntEffort') && (
|
||||
<FormSelectControl
|
||||
title='Effort'
|
||||
tooltip='Controls token usage vs. thoroughness. Low = fastest, most efficient. High = maximum capability (default). Works alongside thinking budget.'
|
||||
value={llmVndAntEffort ?? _UNSPECIFIED}
|
||||
onChange={(value) => {
|
||||
if (value === _UNSPECIFIED || !value || value === 'high') onRemoveParameter('llmVndAntEffort');
|
||||
else onChangeParameter({ llmVndAntEffort: value });
|
||||
}}
|
||||
options={_antEffortOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showParam('llmVndAntWebSearch') && (
|
||||
<FormSelectControl
|
||||
title='Web Search'
|
||||
|
||||
@@ -74,8 +74,12 @@ export type AnthropicHeaderOptions = {
|
||||
modelIdForBetaFeatures?: string;
|
||||
vndAntWebFetch?: boolean;
|
||||
vndAnt1MContext?: boolean;
|
||||
vndAntEffort?: boolean; // [Anthropic, effort-2025-11-24]
|
||||
enableSkills?: boolean;
|
||||
enableCodeExecution?: boolean;
|
||||
enableStrictOutputs?: boolean; // [Anthropic, 2025-11-13] Structured Outputs (JSON outputs & strict tool use)
|
||||
enableToolSearch?: boolean; // [Anthropic, 2025-11-24] Tool Search Tool
|
||||
enableProgrammaticToolCalling?: boolean; // [Anthropic, 2025-11-24] Programmatic Tool Calling (allowed_callers, input_examples)
|
||||
clientSideFetch?: boolean; // whether the request will be made from client-side (browser) - adds CORS header
|
||||
};
|
||||
|
||||
@@ -156,6 +160,19 @@ function _anthropicHeaders(options?: AnthropicHeaderOptions): Record<string, str
|
||||
betaFeatures.push('code-execution-2025-08-25');
|
||||
}
|
||||
|
||||
// [Anthropic, 2025-11-24] Add beta feature for effort parameter (Claude Opus 4.5+)
|
||||
if (options?.vndAntEffort)
|
||||
betaFeatures.push('effort-2025-11-24');
|
||||
|
||||
// [Anthropic, 2025-11-24] Add beta feature for Advanced Tool Use (Tool Search Tool, Programmatic Tool Calling)
|
||||
// Same beta header covers both features: tool discovery and programmatic calling from code execution
|
||||
if (options?.enableToolSearch || options?.enableProgrammaticToolCalling)
|
||||
betaFeatures.push('advanced-tool-use-2025-11-20');
|
||||
|
||||
// [Anthropic, 2025-11-13] Add beta feature for Structured Outputs (JSON outputs & strict tool use)
|
||||
if (options?.enableStrictOutputs)
|
||||
betaFeatures.push('structured-outputs-2025-11-13');
|
||||
|
||||
return {
|
||||
...DEFAULT_ANTHROPIC_HEADERS,
|
||||
// CORS: allow browser access to Anthropic API servers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import { LLM_IF_ANT_PromptCaching, LLM_IF_OAI_Chat, LLM_IF_OAI_Fn, LLM_IF_OAI_Reasoning, LLM_IF_OAI_Vision, LLM_IF_Tools_WebSearch } from '~/common/stores/llms/llms.types';
|
||||
import { LLM_IF_ANT_PromptCaching, LLM_IF_ANT_ToolsSearch, LLM_IF_OAI_Chat, LLM_IF_OAI_Fn, LLM_IF_OAI_Reasoning, LLM_IF_OAI_Vision, LLM_IF_Tools_WebSearch } from '~/common/stores/llms/llms.types';
|
||||
import { Release } from '~/common/app.release';
|
||||
|
||||
import type { ModelDescriptionSchema } from '../llm.server.types';
|
||||
@@ -10,6 +10,9 @@ import type { ModelDescriptionSchema } from '../llm.server.types';
|
||||
export const DEV_DEBUG_ANTHROPIC_MODELS = Release.IsNodeDevBuild;
|
||||
|
||||
|
||||
const IF_4 = [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching];
|
||||
const IF_4_R = [...IF_4, LLM_IF_OAI_Reasoning];
|
||||
|
||||
const ANT_PAR_WEB: ModelDescriptionSchema['parameterSpecs'] = [
|
||||
{ paramId: 'llmVndAntWebSearch' },
|
||||
{ paramId: 'llmVndAntWebFetch' },
|
||||
@@ -26,13 +29,22 @@ export const hardcodedAnthropicVariants: { [modelId: string]: Partial<ModelDescr
|
||||
// NOTE: what's not redefined below is inherited from the underlying model definition
|
||||
|
||||
// Claude 4.5 models with thinking variants
|
||||
'claude-opus-4-5-20251101': {
|
||||
idVariant: 'thinking',
|
||||
label: 'Claude Opus 4.5 (Thinking)',
|
||||
description: 'Claude Opus 4.5 with extended thinking mode for complex reasoning and agentic workflows',
|
||||
interfaces: [...IF_4_R, LLM_IF_ANT_ToolsSearch],
|
||||
parameterSpecs: [...ANT_PAR_WEB_THINKING, { paramId: 'llmVndAntEffort' }, { paramId: 'llmVndAntSkills' }],
|
||||
maxCompletionTokens: 32000,
|
||||
},
|
||||
|
||||
'claude-sonnet-4-5-20250929': {
|
||||
idVariant: 'thinking',
|
||||
label: 'Claude Sonnet 4.5 (Thinking)',
|
||||
description: 'Claude Sonnet 4.5 with extended thinking mode enabled for complex reasoning',
|
||||
parameterSpecs: [...ANT_PAR_WEB_THINKING, { paramId: 'llmVndAnt1MContext' }, { paramId: 'llmVndAntSkills' }],
|
||||
maxCompletionTokens: 64000,
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching, LLM_IF_OAI_Reasoning],
|
||||
interfaces: [...IF_4_R, LLM_IF_ANT_ToolsSearch],
|
||||
parameterSpecs: [...ANT_PAR_WEB_THINKING, { paramId: 'llmVndAnt1MContext' }, { paramId: 'llmVndAntSkills' }],
|
||||
benchmark: { cbaElo: 1451 + 1 }, // FALLBACK-UNTIL-AVAILABLE: claude-opus-4-1-20250805-thinking-16k + 1
|
||||
},
|
||||
|
||||
@@ -40,9 +52,9 @@ export const hardcodedAnthropicVariants: { [modelId: string]: Partial<ModelDescr
|
||||
idVariant: 'thinking',
|
||||
label: 'Claude Haiku 4.5 (Thinking)',
|
||||
description: 'Claude Haiku 4.5 with extended thinking mode - first Haiku model with reasoning capabilities',
|
||||
parameterSpecs: [...ANT_PAR_WEB_THINKING, { paramId: 'llmVndAntSkills' }],
|
||||
maxCompletionTokens: 64000,
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching, LLM_IF_OAI_Reasoning],
|
||||
interfaces: IF_4_R,
|
||||
parameterSpecs: [...ANT_PAR_WEB_THINKING, { paramId: 'llmVndAntSkills' }],
|
||||
},
|
||||
|
||||
// Claude 4.1 models with thinking variants
|
||||
@@ -50,9 +62,9 @@ export const hardcodedAnthropicVariants: { [modelId: string]: Partial<ModelDescr
|
||||
idVariant: 'thinking',
|
||||
label: 'Claude Opus 4.1 (Thinking)',
|
||||
description: 'Claude Opus 4.1 with extended thinking mode enabled for complex reasoning',
|
||||
parameterSpecs: ANT_PAR_WEB_THINKING,
|
||||
maxCompletionTokens: 32000,
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching, LLM_IF_OAI_Reasoning],
|
||||
interfaces: IF_4_R,
|
||||
parameterSpecs: ANT_PAR_WEB_THINKING,
|
||||
benchmark: { cbaElo: 1451 }, // claude-opus-4-1-20250805-thinking-16k
|
||||
},
|
||||
|
||||
@@ -62,9 +74,9 @@ export const hardcodedAnthropicVariants: { [modelId: string]: Partial<ModelDescr
|
||||
idVariant: 'thinking',
|
||||
label: 'Claude Opus 4 (Thinking)',
|
||||
description: 'Claude Opus 4 with extended thinking mode enabled for complex reasoning',
|
||||
parameterSpecs: ANT_PAR_WEB_THINKING,
|
||||
maxCompletionTokens: 32000,
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching, LLM_IF_OAI_Reasoning],
|
||||
interfaces: IF_4_R,
|
||||
parameterSpecs: ANT_PAR_WEB_THINKING,
|
||||
benchmark: { cbaElo: 1420 }, // claude-opus-4-20250514-thinking-16k
|
||||
},
|
||||
|
||||
@@ -72,9 +84,9 @@ export const hardcodedAnthropicVariants: { [modelId: string]: Partial<ModelDescr
|
||||
idVariant: 'thinking',
|
||||
label: 'Claude Sonnet 4 (Thinking)',
|
||||
description: 'Claude Sonnet 4 with extended thinking mode enabled for complex reasoning',
|
||||
parameterSpecs: [...ANT_PAR_WEB_THINKING, { paramId: 'llmVndAnt1MContext' }],
|
||||
maxCompletionTokens: 64000,
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching, LLM_IF_OAI_Reasoning],
|
||||
interfaces: IF_4_R,
|
||||
parameterSpecs: [...ANT_PAR_WEB_THINKING, { paramId: 'llmVndAnt1MContext' }],
|
||||
benchmark: { cbaElo: 1400 }, // claude-sonnet-4-20250514-thinking-32k
|
||||
},
|
||||
|
||||
@@ -83,9 +95,9 @@ export const hardcodedAnthropicVariants: { [modelId: string]: Partial<ModelDescr
|
||||
idVariant: 'thinking',
|
||||
label: 'Claude Sonnet 3.7 (Thinking)',
|
||||
description: 'Claude 3.7 with extended thinking mode enabled for complex reasoning',
|
||||
parameterSpecs: ANT_PAR_WEB_THINKING,
|
||||
maxCompletionTokens: 64000,
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching, LLM_IF_OAI_Reasoning],
|
||||
interfaces: IF_4_R,
|
||||
parameterSpecs: ANT_PAR_WEB_THINKING,
|
||||
benchmark: { cbaElo: 1385 }, // claude-3-7-sonnet-20250219-thinking-32k
|
||||
},
|
||||
|
||||
@@ -95,6 +107,17 @@ export const hardcodedAnthropicVariants: { [modelId: string]: Partial<ModelDescr
|
||||
export const hardcodedAnthropicModels: (ModelDescriptionSchema & { isLegacy?: boolean })[] = [
|
||||
|
||||
// Claude 4.5 models
|
||||
{
|
||||
id: 'claude-opus-4-5-20251101', // Active
|
||||
label: 'Claude Opus 4.5', // 🌟
|
||||
description: 'Most intelligent model with advanced reasoning for complex agentic workflows',
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
trainingDataCutoff: 'Jan 2025',
|
||||
interfaces: [...IF_4, LLM_IF_ANT_ToolsSearch],
|
||||
parameterSpecs: [...ANT_PAR_WEB, { paramId: 'llmVndAntEffort' }],
|
||||
chatPrice: { input: 5, output: 25, cache: { cType: 'ant-bp', read: 0.50, write: 6.25, duration: 300 } },
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-5-20250929', // Active
|
||||
label: 'Claude Sonnet 4.5', // 🌟
|
||||
@@ -102,7 +125,7 @@ export const hardcodedAnthropicModels: (ModelDescriptionSchema & { isLegacy?: bo
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
trainingDataCutoff: 'Jan 2025',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: [...IF_4, LLM_IF_ANT_ToolsSearch],
|
||||
parameterSpecs: [...ANT_PAR_WEB, { paramId: 'llmVndAnt1MContext' }, { paramId: 'llmVndAntSkills' }],
|
||||
// Note: Tiered pricing - ≤200K: $3/$15, >200K: $6/$22.50 (with 1M context enabled)
|
||||
// Cache pricing also tiered: write 1.25× input, read 0.10× input
|
||||
@@ -125,7 +148,7 @@ export const hardcodedAnthropicModels: (ModelDescriptionSchema & { isLegacy?: bo
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
trainingDataCutoff: 'Feb 2025',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: IF_4,
|
||||
parameterSpecs: [...ANT_PAR_WEB, { paramId: 'llmVndAntSkills' }],
|
||||
chatPrice: { input: 1, output: 5, cache: { cType: 'ant-bp', read: 0.10, write: 1.25, duration: 300 } },
|
||||
},
|
||||
@@ -133,12 +156,12 @@ export const hardcodedAnthropicModels: (ModelDescriptionSchema & { isLegacy?: bo
|
||||
// Claude 4.1 models
|
||||
{
|
||||
id: 'claude-opus-4-1-20250805', // Active
|
||||
label: 'Claude Opus 4.1', // 🌟
|
||||
label: 'Claude Opus 4.1',
|
||||
description: 'Exceptional model for specialized complex tasks requiring advanced reasoning',
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 32000,
|
||||
trainingDataCutoff: 'Jan 2025',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: IF_4,
|
||||
parameterSpecs: ANT_PAR_WEB,
|
||||
chatPrice: { input: 15, output: 75, cache: { cType: 'ant-bp', read: 1.50, write: 18.75, duration: 300 } },
|
||||
benchmark: { cbaElo: 1438 }, // claude-opus-4-1-20250805
|
||||
@@ -153,19 +176,19 @@ export const hardcodedAnthropicModels: (ModelDescriptionSchema & { isLegacy?: bo
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 32000,
|
||||
trainingDataCutoff: 'Mar 2025',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: IF_4,
|
||||
parameterSpecs: ANT_PAR_WEB,
|
||||
chatPrice: { input: 15, output: 75, cache: { cType: 'ant-bp', read: 1.50, write: 18.75, duration: 300 } },
|
||||
benchmark: { cbaElo: 1411 }, // claude-opus-4-20250514
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514', // Active
|
||||
label: 'Claude Sonnet 4', // 🌟
|
||||
label: 'Claude Sonnet 4',
|
||||
description: 'High-performance model',
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
trainingDataCutoff: 'Mar 2025',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: IF_4,
|
||||
parameterSpecs: [...ANT_PAR_WEB, { paramId: 'llmVndAnt1MContext' }],
|
||||
// Note: Tiered pricing - ≤200K: $3/$15, >200K: $6/$22.50 (with 1M context enabled)
|
||||
// Cache pricing also tiered: write 1.25× input, read 0.10× input
|
||||
@@ -190,7 +213,7 @@ export const hardcodedAnthropicModels: (ModelDescriptionSchema & { isLegacy?: bo
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
trainingDataCutoff: 'Nov 2024',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: IF_4,
|
||||
parameterSpecs: ANT_PAR_WEB,
|
||||
chatPrice: { input: 3, output: 15, cache: { cType: 'ant-bp', read: 0.30, write: 3.75, duration: 300 } },
|
||||
benchmark: { cbaElo: 1369 }, // claude-3-7-sonnet-20250219
|
||||
@@ -208,7 +231,7 @@ export const hardcodedAnthropicModels: (ModelDescriptionSchema & { isLegacy?: bo
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 8192,
|
||||
trainingDataCutoff: 'Jul 2024',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: IF_4,
|
||||
parameterSpecs: ANT_PAR_WEB,
|
||||
chatPrice: { input: 0.80, output: 4.00, cache: { cType: 'ant-bp', read: 0.08, write: 1.00, duration: 300 } },
|
||||
benchmark: { cbaElo: 1319, cbaMmlu: 75.2 }, // claude-3-5-haiku-20241022
|
||||
@@ -222,7 +245,7 @@ export const hardcodedAnthropicModels: (ModelDescriptionSchema & { isLegacy?: bo
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 4096,
|
||||
trainingDataCutoff: 'Aug 2023',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: IF_4,
|
||||
chatPrice: { input: 15, output: 75, cache: { cType: 'ant-bp', read: 1.50, write: 18.75, duration: 300 } },
|
||||
benchmark: { cbaElo: 1322, cbaMmlu: 86.8 },
|
||||
hidden: true, // deprecated
|
||||
@@ -236,7 +259,7 @@ export const hardcodedAnthropicModels: (ModelDescriptionSchema & { isLegacy?: bo
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 4096,
|
||||
trainingDataCutoff: 'Aug 2023',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: IF_4,
|
||||
chatPrice: { input: 0.25, output: 1.25, cache: { cType: 'ant-bp', read: 0.03, write: 0.30, duration: 300 } },
|
||||
benchmark: { cbaElo: 1263, cbaMmlu: 75.1 },
|
||||
},
|
||||
@@ -302,7 +325,7 @@ export function llmsAntCreatePlaceholderModel(model: AnthropicWire_API_Models_Li
|
||||
contextWindow: 200000,
|
||||
maxCompletionTokens: 8192,
|
||||
trainingDataCutoff: 'Latest',
|
||||
interfaces: [LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, LLM_IF_OAI_Fn, LLM_IF_ANT_PromptCaching],
|
||||
interfaces: IF_4,
|
||||
// chatPrice: ...
|
||||
// benchmark: ...
|
||||
};
|
||||
|
||||
@@ -79,6 +79,7 @@ const ModelParameterSpec_schema = z.object({
|
||||
'llmForceNoStream',
|
||||
// Anthropic
|
||||
'llmVndAnt1MContext',
|
||||
'llmVndAntEffort',
|
||||
'llmVndAntSkills',
|
||||
'llmVndAntThinkingBudget',
|
||||
'llmVndAntWebFetch',
|
||||
@@ -129,7 +130,7 @@ export const ModelDescription_schema = z.object({
|
||||
updated: z.number().optional(),
|
||||
description: z.string(),
|
||||
contextWindow: z.number().nullable(),
|
||||
interfaces: z.array(z.enum(LLMS_ALL_INTERFACES)),
|
||||
interfaces: z.array(z.union([z.enum(LLMS_ALL_INTERFACES), z.string()])), // backward compatibility: to not Break client-side interface parsing on newer server
|
||||
parameterSpecs: z.array(ModelParameterSpec_schema).optional(),
|
||||
maxCompletionTokens: z.number().optional(),
|
||||
// rateLimits: rateLimitsSchema.optional(),
|
||||
|
||||
@@ -23,7 +23,6 @@ export const ollamaAccessSchema = z.object({
|
||||
dialect: z.enum(['ollama']),
|
||||
clientSideFetch: z.boolean().optional(), // optional: backward compatibility from newer server version - can remove once all clients are updated
|
||||
ollamaHost: z.string().trim(),
|
||||
ollamaJson: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
|
||||
+3
@@ -19,6 +19,9 @@ export interface IModelVendor<TServiceSettings extends Record<string, any> = {},
|
||||
readonly hasServerConfigFn?: (backendCapabilities: BackendCapabilities) => boolean; // used to show a 'green checkmark' in the list of vendors when adding services
|
||||
readonly hasServerConfigKey?: keyof BackendCapabilities;
|
||||
|
||||
/// client-side-fetch ///
|
||||
readonly csfAvailable?: (setup?: Partial<TServiceSettings>) => boolean; // undefined: not supported, false: conditions not met
|
||||
|
||||
/// abstraction interface ///
|
||||
|
||||
initializeSetup?(): TServiceSettings;
|
||||
|
||||
+11
-2
@@ -9,6 +9,7 @@ import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { FormTextField } from '~/common/components/forms/FormTextField';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SetupFormClientSideToggle } from '~/common/components/forms/SetupFormClientSideToggle';
|
||||
import { SetupFormRefetchButton } from '~/common/components/forms/SetupFormRefetchButton';
|
||||
import { useToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
|
||||
@@ -36,10 +37,11 @@ export function AlibabaServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
} = useServiceSetup(props.serviceId, ModelVendorAlibaba);
|
||||
|
||||
// derived state
|
||||
const { oaiKey: alibabaOaiKey, oaiHost: alibabaOaiHost } = serviceAccess;
|
||||
const { clientSideFetch, oaiKey: alibabaOaiKey, oaiHost: alibabaOaiHost } = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const shallFetchSucceed = !needsUserKey || (!!alibabaOaiKey && serviceSetupValid);
|
||||
const showKeyError = !!alibabaOaiKey && !serviceSetupValid;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
// fetch models
|
||||
const { isFetching, refetch, isError, error } =
|
||||
@@ -73,7 +75,7 @@ export function AlibabaServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
{/* See the <ExternalLink href={ALIBABA_REG_LINK}>Alibaba Cloud Model Studio</ExternalLink> for more information.*/}
|
||||
{/*</Typography>*/}
|
||||
|
||||
{advanced.on && <FormTextField
|
||||
{showAdvanced && <FormTextField
|
||||
autoCompleteId='alibaba-host'
|
||||
title='API Endpoint'
|
||||
tooltip={`The API endpoint for the Alibaba Cloud OpenAI service, to be used instead of the default endpoint.`}
|
||||
@@ -82,6 +84,13 @@ export function AlibabaServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
onChange={text => updateSettings({ alibabaOaiHost: text })}
|
||||
/>}
|
||||
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!alibabaOaiKey}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText='Connect directly to Alibaba Cloud API from your browser instead of through the server.'
|
||||
/>}
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={/*!shallFetchSucceed ||*/ isFetching} loading={isFetching} error={isError} advanced={advanced} />
|
||||
|
||||
{isError && <InlineError error={error} />}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ModelVendorOpenAI } from '../openai/openai.vendor';
|
||||
interface DAlibabaServiceSettings {
|
||||
alibabaOaiKey: string;
|
||||
alibabaOaiHost: string;
|
||||
csf?: boolean;
|
||||
}
|
||||
|
||||
export const ModelVendorAlibaba: IModelVendor<DAlibabaServiceSettings, OpenAIAccessSchema> = {
|
||||
@@ -17,6 +18,9 @@ export const ModelVendorAlibaba: IModelVendor<DAlibabaServiceSettings, OpenAIAcc
|
||||
instanceLimit: 1,
|
||||
hasServerConfigKey: 'hasLlmAlibaba',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfAlibabaAvailable,
|
||||
|
||||
// functions
|
||||
initializeSetup: () => ({
|
||||
alibabaOaiKey: '',
|
||||
@@ -27,6 +31,7 @@ export const ModelVendorAlibaba: IModelVendor<DAlibabaServiceSettings, OpenAIAcc
|
||||
},
|
||||
getTransportAccess: (partialSetup) => ({
|
||||
dialect: 'alibaba',
|
||||
clientSideFetch: _csfAlibabaAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
oaiKey: partialSetup?.alibabaOaiKey || '',
|
||||
oaiOrg: '',
|
||||
oaiHost: partialSetup?.alibabaOaiHost || '',
|
||||
@@ -37,3 +42,7 @@ export const ModelVendorAlibaba: IModelVendor<DAlibabaServiceSettings, OpenAIAcc
|
||||
// OpenAI transport ('alibaba' dialect in 'access')
|
||||
rpcUpdateModelsOrThrow: ModelVendorOpenAI.rpcUpdateModelsOrThrow,
|
||||
};
|
||||
|
||||
function _csfAlibabaAvailable(s?: Partial<DAlibabaServiceSettings>) {
|
||||
return !!s?.alibabaOaiKey;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useChatAutoAI } from '../../../../apps/chat/store-app-chat';
|
||||
|
||||
import type { DModelsServiceId } from '~/common/stores/llms/llms.service.types';
|
||||
import { AlreadySet } from '~/common/components/AlreadySet';
|
||||
import { ExternalLink } from '~/common/components/ExternalLink';
|
||||
import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
|
||||
@@ -38,6 +37,7 @@ export function AnthropicServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
// derived state
|
||||
const { anthropicKey, anthropicHost, clientSideFetch, heliconeKey } = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
const keyValid = isValidAnthropicApiKey(anthropicKey);
|
||||
const keyError = (/*needsUserKey ||*/ !!anthropicKey) && !keyValid;
|
||||
@@ -67,7 +67,7 @@ export function AnthropicServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
placeholder='sk-...'
|
||||
/>
|
||||
|
||||
{advanced.on && <FormSwitchControl
|
||||
{showAdvanced && <FormSwitchControl
|
||||
title='Auto-Caching' on='Enabled' off='Disabled'
|
||||
tooltip='Auto-breakpoints: 3 breakpoints are always set on the System instruction and on the last 2 User messages. This leaves the user with 1 breakpoint of their choice. (max 4)'
|
||||
description={autoVndAntBreakpoints ? <>Last 2 user messages</> : 'Disabled'}
|
||||
@@ -76,7 +76,7 @@ export function AnthropicServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
/>}
|
||||
|
||||
|
||||
{advanced.on && <FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{showAdvanced && <FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart
|
||||
title='Caching'
|
||||
description='Toggle per-Message'
|
||||
@@ -87,7 +87,7 @@ export function AnthropicServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
</Typography>
|
||||
</FormControl>}
|
||||
|
||||
{advanced.on && <FormTextField
|
||||
{showAdvanced && <FormTextField
|
||||
autoCompleteId='anthropic-host'
|
||||
title='API Host'
|
||||
description={<>e.g., <Link level='body-sm' href='https://github.com/enricoros/big-agi/blob/main/docs/config-aws-bedrock.md' target='_blank'>bedrock-claude</Link></>}
|
||||
@@ -97,7 +97,7 @@ export function AnthropicServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
onChange={text => updateSettings({ anthropicHost: text })}
|
||||
/>}
|
||||
|
||||
{advanced.on && <FormTextField
|
||||
{showAdvanced && <FormTextField
|
||||
autoCompleteId='anthropic-helicone-key'
|
||||
title='Helicone Key' disabled={!!anthropicHost}
|
||||
description={<>Generate <Link level='body-sm' href='https://www.helicone.ai/keys' target='_blank'>here</Link></>}
|
||||
@@ -106,10 +106,10 @@ export function AnthropicServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
onChange={text => updateSettings({ heliconeKey: text })}
|
||||
/>}
|
||||
|
||||
{advanced.on && <SetupFormClientSideToggle
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!anthropicKey}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ anthropicCSF: on })}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText="Fetch models and make requests directly to Anthropic's API using your browser instead of through the server. Useful for bypassing server limitations or ensuring requests use your API key directly."
|
||||
/>}
|
||||
|
||||
|
||||
+9
-3
@@ -10,7 +10,7 @@ export const isValidAnthropicApiKey = (apiKey?: string) => !!apiKey && (apiKey.s
|
||||
interface DAnthropicServiceSettings {
|
||||
anthropicKey: string;
|
||||
anthropicHost: string;
|
||||
anthropicCSF?: boolean;
|
||||
csf?: boolean;
|
||||
heliconeKey: string;
|
||||
}
|
||||
|
||||
@@ -24,17 +24,23 @@ export const ModelVendorAnthropic: IModelVendor<DAnthropicServiceSettings, Anthr
|
||||
instanceLimit: 1,
|
||||
hasServerConfigKey: 'hasLlmAnthropic',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfAnthropicAvailable,
|
||||
|
||||
// functions
|
||||
getTransportAccess: (partialSetup): AnthropicAccessSchema => ({
|
||||
dialect: 'anthropic',
|
||||
clientSideFetch: !!(partialSetup?.anthropicKey && partialSetup?.anthropicCSF),
|
||||
clientSideFetch: _csfAnthropicAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
anthropicKey: partialSetup?.anthropicKey || '',
|
||||
anthropicHost: partialSetup?.anthropicHost || null,
|
||||
heliconeKey: partialSetup?.heliconeKey || null,
|
||||
}),
|
||||
|
||||
|
||||
// List Models
|
||||
rpcUpdateModelsOrThrow: async (access) => await apiAsync.llmAnthropic.listModels.query({ access }),
|
||||
|
||||
};
|
||||
|
||||
function _csfAnthropicAvailable(s?: Partial<DAnthropicServiceSettings>) {
|
||||
return !!s?.anthropicKey;
|
||||
}
|
||||
|
||||
+13
-2
@@ -9,8 +9,10 @@ import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { FormTextField } from '~/common/components/forms/FormTextField';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SetupFormClientSideToggle } from '~/common/components/forms/SetupFormClientSideToggle';
|
||||
import { SetupFormRefetchButton } from '~/common/components/forms/SetupFormRefetchButton';
|
||||
import { asValidURL } from '~/common/util/urlUtils';
|
||||
import { useToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
|
||||
import { ApproximateCosts } from '../ApproximateCosts';
|
||||
import { useLlmUpdateModels } from '../../llm.client.hooks';
|
||||
@@ -22,6 +24,7 @@ import { isValidAzureApiKey, ModelVendorAzure } from './azure.vendor';
|
||||
export function AzureServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
|
||||
// state
|
||||
const advanced = useToggleableBoolean();
|
||||
const [checkboxExpanded, setCheckboxExpanded] = React.useState(false);
|
||||
|
||||
// external state
|
||||
@@ -29,8 +32,9 @@ export function AzureServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
useServiceSetup(props.serviceId, ModelVendorAzure);
|
||||
|
||||
// derived state
|
||||
const { oaiKey: azureKey, oaiHost: azureEndpoint } = serviceAccess;
|
||||
const { clientSideFetch, oaiKey: azureKey, oaiHost: azureEndpoint } = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
const keyValid = isValidAzureApiKey(azureKey);
|
||||
const keyError = (/*needsUserKey ||*/ !!azureKey) && !keyValid;
|
||||
@@ -81,7 +85,14 @@ export function AzureServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
placeholder='...'
|
||||
/>
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={!shallFetchSucceed || isFetching} loading={isFetching} error={isError} />
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!(azureKey && azureEndpoint)}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText='Connect directly to Azure OpenAI API from your browser instead of through the server.'
|
||||
/>}
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={!shallFetchSucceed || isFetching} loading={isFetching} error={isError} advanced={advanced} />
|
||||
|
||||
{isError && <InlineError error={error} />}
|
||||
|
||||
|
||||
+10
-1
@@ -10,6 +10,7 @@ export const isValidAzureApiKey = (apiKey?: string) => !!apiKey && apiKey.length
|
||||
interface DAzureServiceSettings {
|
||||
azureEndpoint: string;
|
||||
azureKey: string;
|
||||
csf?: boolean;
|
||||
}
|
||||
|
||||
/** Implementation Notes for the Azure Vendor
|
||||
@@ -37,9 +38,13 @@ export const ModelVendorAzure: IModelVendor<DAzureServiceSettings, OpenAIAccessS
|
||||
instanceLimit: 2,
|
||||
hasServerConfigKey: 'hasLlmAzureOpenAI',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfAzureAvailable,
|
||||
|
||||
// functions
|
||||
getTransportAccess: (partialSetup): OpenAIAccessSchema => ({
|
||||
dialect: 'azure',
|
||||
clientSideFetch: _csfAzureAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
oaiKey: partialSetup?.azureKey || '',
|
||||
oaiOrg: '',
|
||||
oaiHost: partialSetup?.azureEndpoint || '',
|
||||
@@ -50,4 +55,8 @@ export const ModelVendorAzure: IModelVendor<DAzureServiceSettings, OpenAIAccessS
|
||||
// OpenAI transport ('azure' dialect in 'access')
|
||||
rpcUpdateModelsOrThrow: ModelVendorOpenAI.rpcUpdateModelsOrThrow,
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
function _csfAzureAvailable(s?: Partial<DAzureServiceSettings>) {
|
||||
return !!(s?.azureKey && s?.azureEndpoint);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { AlreadySet } from '~/common/components/AlreadySet';
|
||||
import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SetupFormClientSideToggle } from '~/common/components/forms/SetupFormClientSideToggle';
|
||||
import { SetupFormRefetchButton } from '~/common/components/forms/SetupFormRefetchButton';
|
||||
import { useToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
|
||||
@@ -30,8 +31,9 @@ export function DeepseekAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
} = useServiceSetup(props.serviceId, ModelVendorDeepseek);
|
||||
|
||||
// derived state
|
||||
const { oaiKey: deepseekKey } = serviceAccess;
|
||||
const { clientSideFetch, oaiKey: deepseekKey } = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
// validate if url is a well formed proper url with zod
|
||||
const shallFetchSucceed = !needsUserKey || (!!deepseekKey && serviceSetupValid);
|
||||
@@ -57,6 +59,13 @@ export function DeepseekAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
placeholder='...'
|
||||
/>
|
||||
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!deepseekKey}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText='Connect directly to Deepseek API from your browser instead of through the server.'
|
||||
/>}
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={/*!shallFetchSucceed ||*/ isFetching} loading={isFetching} error={isError} advanced={advanced} />
|
||||
|
||||
{isError && <InlineError error={error} />}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ModelVendorOpenAI } from '../openai/openai.vendor';
|
||||
|
||||
export interface DDeepseekServiceSettings {
|
||||
deepseekKey: string;
|
||||
csf?: boolean;
|
||||
}
|
||||
|
||||
export const ModelVendorDeepseek: IModelVendor<DDeepseekServiceSettings, OpenAIAccessSchema> = {
|
||||
@@ -17,6 +18,9 @@ export const ModelVendorDeepseek: IModelVendor<DDeepseekServiceSettings, OpenAIA
|
||||
instanceLimit: 1,
|
||||
hasServerConfigKey: 'hasLlmDeepseek',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfDeepseekAvailable,
|
||||
|
||||
// functions
|
||||
initializeSetup: () => ({
|
||||
deepseekKey: '',
|
||||
@@ -26,6 +30,7 @@ export const ModelVendorDeepseek: IModelVendor<DDeepseekServiceSettings, OpenAIA
|
||||
},
|
||||
getTransportAccess: (partialSetup) => ({
|
||||
dialect: 'deepseek',
|
||||
clientSideFetch: _csfDeepseekAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
oaiKey: partialSetup?.deepseekKey || '',
|
||||
oaiOrg: '',
|
||||
oaiHost: '',
|
||||
@@ -37,3 +42,7 @@ export const ModelVendorDeepseek: IModelVendor<DDeepseekServiceSettings, OpenAIA
|
||||
rpcUpdateModelsOrThrow: ModelVendorOpenAI.rpcUpdateModelsOrThrow,
|
||||
|
||||
};
|
||||
|
||||
function _csfDeepseekAvailable(s?: Partial<DDeepseekServiceSettings>) {
|
||||
return !!s?.deepseekKey;
|
||||
}
|
||||
|
||||
+6
-5
@@ -46,6 +46,7 @@ export function GeminiServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
// derived state
|
||||
const { clientSideFetch, geminiKey, geminiHost, minSafetyLevel} = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
const shallFetchSucceed = !needsUserKey || (!!geminiKey && serviceSetupValid);
|
||||
const showKeyError = !!geminiKey && !serviceSetupValid;
|
||||
@@ -69,7 +70,7 @@ export function GeminiServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
placeholder='...'
|
||||
/>
|
||||
|
||||
{advanced.on && <FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{showAdvanced && <FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart title='Safety Settings'
|
||||
description='Threshold' />
|
||||
<Select
|
||||
@@ -89,7 +90,7 @@ export function GeminiServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
</Select>
|
||||
</FormControl>}
|
||||
|
||||
{advanced.on && <FormHelperText sx={{ display: 'block' }}>
|
||||
{showAdvanced && <FormHelperText sx={{ display: 'block' }}>
|
||||
Gemini has advanced <Link href='https://ai.google.dev/docs/safety_setting_gemini' target='_blank' noLinkStyle>
|
||||
safety settings</Link> on: harassment, hate speech,
|
||||
sexually explicit, civic integrity, and dangerous content, in addition to non-adjustable built-in filters.
|
||||
@@ -97,7 +98,7 @@ export function GeminiServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
{/*of being unsafe.*/}
|
||||
</FormHelperText>}
|
||||
|
||||
{advanced.on && <FormTextField
|
||||
{showAdvanced && <FormTextField
|
||||
autoCompleteId='gemini-host'
|
||||
title='API Endpoint'
|
||||
placeholder={`https://generativelanguage.googleapis.com`}
|
||||
@@ -105,10 +106,10 @@ export function GeminiServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
onChange={text => updateSettings({ geminiHost: text })}
|
||||
/>}
|
||||
|
||||
{advanced.on && <SetupFormClientSideToggle
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!geminiKey}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ geminiCSF: on })}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText="Fetch models and make requests directly to Google's Gemini API using your browser instead of through the server."
|
||||
/>}
|
||||
|
||||
|
||||
+9
-2
@@ -9,7 +9,7 @@ import type { IModelVendor } from '../IModelVendor';
|
||||
interface DGeminiServiceSettings {
|
||||
geminiKey: string;
|
||||
geminiHost: string;
|
||||
geminiCSF?: boolean;
|
||||
csf?: boolean;
|
||||
minSafetyLevel: GeminiWire_Safety.HarmBlockThreshold;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export const ModelVendorGemini: IModelVendor<DGeminiServiceSettings, GeminiAcces
|
||||
instanceLimit: 1,
|
||||
hasServerConfigKey: 'hasLlmGemini',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfGeminiAvailable,
|
||||
|
||||
// functions
|
||||
initializeSetup: () => ({
|
||||
geminiKey: '',
|
||||
@@ -44,7 +47,7 @@ export const ModelVendorGemini: IModelVendor<DGeminiServiceSettings, GeminiAcces
|
||||
},
|
||||
getTransportAccess: (partialSetup): GeminiAccessSchema => ({
|
||||
dialect: 'gemini',
|
||||
clientSideFetch: !!(partialSetup?.geminiKey && partialSetup?.geminiCSF),
|
||||
clientSideFetch: _csfGeminiAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
geminiKey: partialSetup?.geminiKey || '',
|
||||
geminiHost: partialSetup?.geminiHost || '',
|
||||
minSafetyLevel: partialSetup?.minSafetyLevel || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED',
|
||||
@@ -54,3 +57,7 @@ export const ModelVendorGemini: IModelVendor<DGeminiServiceSettings, GeminiAcces
|
||||
rpcUpdateModelsOrThrow: async (access) => await apiAsync.llmGemini.listModels.query({ access }),
|
||||
|
||||
};
|
||||
|
||||
function _csfGeminiAvailable(s?: Partial<DGeminiServiceSettings>) {
|
||||
return !!s?.geminiKey;
|
||||
}
|
||||
|
||||
+15
-4
@@ -1,13 +1,13 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Typography } from '@mui/joy';
|
||||
|
||||
import type { DModelsServiceId } from '~/common/stores/llms/llms.service.types';
|
||||
import { AlreadySet } from '~/common/components/AlreadySet';
|
||||
import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SetupFormClientSideToggle } from '~/common/components/forms/SetupFormClientSideToggle';
|
||||
import { SetupFormRefetchButton } from '~/common/components/forms/SetupFormRefetchButton';
|
||||
import { useToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
|
||||
import { ApproximateCosts } from '../ApproximateCosts';
|
||||
import { ModelVendorGroq } from './groq.vendor';
|
||||
@@ -20,6 +20,9 @@ const GROQ_REG_LINK = 'https://console.groq.com/keys';
|
||||
|
||||
export function GroqServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
|
||||
// state
|
||||
const advanced = useToggleableBoolean();
|
||||
|
||||
// external state
|
||||
const {
|
||||
service, serviceAccess, serviceHasCloudTenantConfig, serviceHasLLMs,
|
||||
@@ -27,8 +30,9 @@ export function GroqServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
} = useServiceSetup(props.serviceId, ModelVendorGroq);
|
||||
|
||||
// derived state
|
||||
const { oaiKey: groqKey } = serviceAccess;
|
||||
const { clientSideFetch, oaiKey: groqKey } = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
// key validation
|
||||
const shallFetchSucceed = !needsUserKey || (!!groqKey && serviceSetupValid);
|
||||
@@ -54,7 +58,14 @@ export function GroqServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
placeholder='...'
|
||||
/>
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={/*!shallFetchSucceed ||*/ isFetching} loading={isFetching} error={isError} />
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!groqKey}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText='Connect directly to Groq API from your browser instead of through the server.'
|
||||
/>}
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={/*!shallFetchSucceed ||*/ isFetching} loading={isFetching} error={isError} advanced={advanced} />
|
||||
|
||||
{isError && <InlineError error={error} />}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ModelVendorOpenAI } from '../openai/openai.vendor';
|
||||
|
||||
interface DGroqServiceSettings {
|
||||
groqKey: string;
|
||||
csf?: boolean;
|
||||
}
|
||||
|
||||
export const ModelVendorGroq: IModelVendor<DGroqServiceSettings, OpenAIAccessSchema> = {
|
||||
@@ -17,6 +18,9 @@ export const ModelVendorGroq: IModelVendor<DGroqServiceSettings, OpenAIAccessSch
|
||||
instanceLimit: 1,
|
||||
hasServerConfigKey: 'hasLlmGroq',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfGroqAvailable,
|
||||
|
||||
// functions
|
||||
initializeSetup: () => ({
|
||||
groqKey: '',
|
||||
@@ -26,6 +30,7 @@ export const ModelVendorGroq: IModelVendor<DGroqServiceSettings, OpenAIAccessSch
|
||||
},
|
||||
getTransportAccess: (partialSetup) => ({
|
||||
dialect: 'groq',
|
||||
clientSideFetch: _csfGroqAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
oaiKey: partialSetup?.groqKey || '',
|
||||
oaiOrg: '',
|
||||
oaiHost: '',
|
||||
@@ -37,3 +42,7 @@ export const ModelVendorGroq: IModelVendor<DGroqServiceSettings, OpenAIAccessSch
|
||||
rpcUpdateModelsOrThrow: ModelVendorOpenAI.rpcUpdateModelsOrThrow,
|
||||
|
||||
};
|
||||
|
||||
function _csfGroqAvailable(s?: Partial<DGroqServiceSettings>) {
|
||||
return !!s?.groqKey;
|
||||
}
|
||||
|
||||
+15
-2
@@ -9,8 +9,10 @@ import { ExpanderAccordion } from '~/common/components/ExpanderAccordion';
|
||||
import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SetupFormClientSideToggle } from '~/common/components/forms/SetupFormClientSideToggle';
|
||||
import { SetupFormRefetchButton } from '~/common/components/forms/SetupFormRefetchButton';
|
||||
import { VideoPlayerYouTube } from '~/common/components/VideoPlayerYouTube';
|
||||
import { useToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
|
||||
import { useLlmUpdateModels } from '../../llm.client.hooks';
|
||||
import { useServiceSetup } from '../useServiceSetup';
|
||||
@@ -20,12 +22,16 @@ import { ModelVendorLMStudio } from './lmstudio.vendor';
|
||||
|
||||
export function LMStudioServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
|
||||
// state
|
||||
const advanced = useToggleableBoolean();
|
||||
|
||||
// external state
|
||||
const { service, serviceAccess, updateSettings } =
|
||||
useServiceSetup(props.serviceId, ModelVendorLMStudio);
|
||||
|
||||
// derived state
|
||||
const { oaiHost } = serviceAccess;
|
||||
const { clientSideFetch, oaiHost } = serviceAccess;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
// validate if url is a well formed proper url with zod
|
||||
const urlSchema = z.url().startsWith('http');
|
||||
@@ -62,7 +68,14 @@ export function LMStudioServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
value={oaiHost} onChange={value => updateSettings({ oaiHost: value })}
|
||||
/>
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={!shallFetchSucceed || isFetching} loading={isFetching} error={isError} />
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!oaiHost}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText='Connect directly to LM Studio from your browser. Requires CORS to be enabled in LM Studio.'
|
||||
/>}
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={!shallFetchSucceed || isFetching} loading={isFetching} error={isError} advanced={advanced} />
|
||||
|
||||
{isError && <InlineError error={error} />}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ModelVendorOpenAI } from '../openai/openai.vendor';
|
||||
|
||||
interface DLMStudioServiceSettings {
|
||||
oaiHost: string; // use OpenAI-compatible non-default hosts (full origin path)
|
||||
csf?: boolean;
|
||||
}
|
||||
|
||||
export const ModelVendorLMStudio: IModelVendor<DLMStudioServiceSettings, OpenAIAccessSchema> = {
|
||||
@@ -16,12 +17,16 @@ export const ModelVendorLMStudio: IModelVendor<DLMStudioServiceSettings, OpenAIA
|
||||
location: 'local',
|
||||
instanceLimit: 1,
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfLMStudioAvailable,
|
||||
|
||||
// functions
|
||||
initializeSetup: () => ({
|
||||
oaiHost: 'http://localhost:1234',
|
||||
}),
|
||||
getTransportAccess: (partialSetup) => ({
|
||||
dialect: 'lmstudio',
|
||||
clientSideFetch: _csfLMStudioAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
oaiKey: '',
|
||||
oaiOrg: '',
|
||||
oaiHost: partialSetup?.oaiHost || '',
|
||||
@@ -33,3 +38,7 @@ export const ModelVendorLMStudio: IModelVendor<DLMStudioServiceSettings, OpenAIA
|
||||
rpcUpdateModelsOrThrow: ModelVendorOpenAI.rpcUpdateModelsOrThrow,
|
||||
|
||||
};
|
||||
|
||||
function _csfLMStudioAvailable(s?: Partial<DLMStudioServiceSettings>) {
|
||||
return !!s?.oaiHost;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function LocalAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
<SetupFormClientSideToggle
|
||||
visible={true}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ localAICSF: on })}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText="Fetch models and make requests directly from your LocalAI instance using the browser. Recommended for local setups."
|
||||
/>
|
||||
|
||||
|
||||
+11
-4
@@ -4,10 +4,10 @@ import type { OpenAIAccessSchema } from '../../server/openai/openai.access';
|
||||
import { ModelVendorOpenAI } from '../openai/openai.vendor';
|
||||
|
||||
|
||||
interface DLocalAIServiceSettings {
|
||||
export interface DLocalAIServiceSettings {
|
||||
localAIHost: string; // use OpenAI-compatible non-default hosts (full origin path)
|
||||
localAIKey: string; // use OpenAI-compatible API keys
|
||||
localAICSF?: boolean;
|
||||
csf?: boolean;
|
||||
}
|
||||
|
||||
export const ModelVendorLocalAI: IModelVendor<DLocalAIServiceSettings, OpenAIAccessSchema> = {
|
||||
@@ -23,15 +23,18 @@ export const ModelVendorLocalAI: IModelVendor<DLocalAIServiceSettings, OpenAIAcc
|
||||
return backendCapabilities.hasLlmLocalAIHost || backendCapabilities.hasLlmLocalAIKey;
|
||||
},
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfLocalAIAvailable,
|
||||
|
||||
// functions
|
||||
initializeSetup: () => ({
|
||||
localAIHost: '',
|
||||
localAIKey: '',
|
||||
// localAICSF: true, // eventually, but requires CORS support on the server: -e CORS=true -e CORS_ALLOW_ORIGINS="*"
|
||||
// csf: true, // eventually, but requires CORS support on the server: -e CORS=true -e CORS_ALLOW_ORIGINS="*"
|
||||
}),
|
||||
getTransportAccess: (partialSetup) => ({
|
||||
dialect: 'localai',
|
||||
clientSideFetch: !!(partialSetup?.localAIHost && partialSetup?.localAICSF),
|
||||
clientSideFetch: _csfLocalAIAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
oaiKey: partialSetup?.localAIKey || '',
|
||||
oaiOrg: '',
|
||||
oaiHost: partialSetup?.localAIHost || '',
|
||||
@@ -43,3 +46,7 @@ export const ModelVendorLocalAI: IModelVendor<DLocalAIServiceSettings, OpenAIAcc
|
||||
rpcUpdateModelsOrThrow: ModelVendorOpenAI.rpcUpdateModelsOrThrow,
|
||||
|
||||
};
|
||||
|
||||
function _csfLocalAIAvailable(s?: Partial<DLocalAIServiceSettings>) {
|
||||
return !!s?.localAIHost;
|
||||
}
|
||||
|
||||
+15
-2
@@ -7,7 +7,9 @@ import { AlreadySet } from '~/common/components/AlreadySet';
|
||||
import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SetupFormClientSideToggle } from '~/common/components/forms/SetupFormClientSideToggle';
|
||||
import { SetupFormRefetchButton } from '~/common/components/forms/SetupFormRefetchButton';
|
||||
import { useToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
|
||||
import { ApproximateCosts } from '../ApproximateCosts';
|
||||
import { useLlmUpdateModels } from '../../llm.client.hooks';
|
||||
@@ -21,13 +23,17 @@ const MISTRAL_REG_LINK = 'https://console.mistral.ai/';
|
||||
|
||||
export function MistralServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
|
||||
// state
|
||||
const advanced = useToggleableBoolean();
|
||||
|
||||
// external state
|
||||
const { service, serviceAccess, serviceHasCloudTenantConfig, serviceHasLLMs, serviceSetupValid, updateSettings } =
|
||||
useServiceSetup(props.serviceId, ModelVendorMistral);
|
||||
|
||||
// derived state
|
||||
const { oaiKey: mistralKey } = serviceAccess;
|
||||
const { clientSideFetch, oaiKey: mistralKey } = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
const shallFetchSucceed = !needsUserKey || (!!mistralKey && serviceSetupValid);
|
||||
const showKeyError = !!mistralKey && !serviceSetupValid;
|
||||
@@ -56,7 +62,14 @@ export function MistralServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
{/* Note the elegance of the numbers, representing the Year and Month or release (YYMM).*/}
|
||||
{/*</Typography>*/}
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={/*!shallFetchSucceed ||*/ isFetching} loading={isFetching} error={isError} />
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!mistralKey}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText='Connect directly to Mistral API from your browser instead of through the server.'
|
||||
/>}
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={/*!shallFetchSucceed ||*/ isFetching} loading={isFetching} error={isError} advanced={advanced} />
|
||||
|
||||
{isError && <InlineError error={error} />}
|
||||
|
||||
|
||||
+10
-2
@@ -6,7 +6,7 @@ import { DOpenAIServiceSettings, ModelVendorOpenAI } from '../openai/openai.vend
|
||||
|
||||
// special symbols
|
||||
|
||||
type DMistralServiceSettings = Pick<DOpenAIServiceSettings, 'oaiKey' | 'oaiHost'>;
|
||||
type DMistralServiceSettings = Pick<DOpenAIServiceSettings, 'oaiKey' | 'oaiHost' | 'csf'>;
|
||||
|
||||
|
||||
/** Implementation Notes for the Mistral vendor
|
||||
@@ -20,6 +20,9 @@ export const ModelVendorMistral: IModelVendor<DMistralServiceSettings, OpenAIAcc
|
||||
instanceLimit: 1,
|
||||
hasServerConfigKey: 'hasLlmMistral',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfMistralAvailable,
|
||||
|
||||
// functions
|
||||
initializeSetup: () => ({
|
||||
oaiHost: 'https://api.mistral.ai/',
|
||||
@@ -30,6 +33,7 @@ export const ModelVendorMistral: IModelVendor<DMistralServiceSettings, OpenAIAcc
|
||||
},
|
||||
getTransportAccess: (partialSetup): OpenAIAccessSchema => ({
|
||||
dialect: 'mistral',
|
||||
clientSideFetch: _csfMistralAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
oaiKey: partialSetup?.oaiKey || '',
|
||||
oaiOrg: '',
|
||||
oaiHost: partialSetup?.oaiHost || '',
|
||||
@@ -40,4 +44,8 @@ export const ModelVendorMistral: IModelVendor<DMistralServiceSettings, OpenAIAcc
|
||||
// OpenAI transport ('mistral' dialect in 'access')
|
||||
rpcUpdateModelsOrThrow: ModelVendorOpenAI.rpcUpdateModelsOrThrow,
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
function _csfMistralAvailable(s?: Partial<DMistralServiceSettings>) {
|
||||
return !!s?.oaiKey;
|
||||
}
|
||||
+15
-2
@@ -5,7 +5,9 @@ import { AlreadySet } from '~/common/components/AlreadySet';
|
||||
import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SetupFormClientSideToggle } from '~/common/components/forms/SetupFormClientSideToggle';
|
||||
import { SetupFormRefetchButton } from '~/common/components/forms/SetupFormRefetchButton';
|
||||
import { useToggleableBoolean } from '~/common/util/hooks/useToggleableBoolean';
|
||||
|
||||
import { ApproximateCosts } from '../ApproximateCosts';
|
||||
import { ModelVendorMoonshot } from './moonshot.vendor';
|
||||
@@ -18,6 +20,9 @@ const MOONSHOT_API_LINK = 'https://platform.moonshot.ai/console/api-keys';
|
||||
|
||||
export function MoonshotServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
|
||||
// state
|
||||
const advanced = useToggleableBoolean();
|
||||
|
||||
// external state
|
||||
const {
|
||||
service, serviceAccess, serviceHasCloudTenantConfig, serviceHasLLMs,
|
||||
@@ -25,8 +30,9 @@ export function MoonshotServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
} = useServiceSetup(props.serviceId, ModelVendorMoonshot);
|
||||
|
||||
// derived state
|
||||
const { oaiKey: moonshotKey } = serviceAccess;
|
||||
const { clientSideFetch, oaiKey: moonshotKey } = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
// key validation
|
||||
const shallFetchSucceed = !needsUserKey || (!!moonshotKey && serviceSetupValid);
|
||||
@@ -52,7 +58,14 @@ export function MoonshotServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
placeholder='...'
|
||||
/>
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={/*!shallFetchSucceed ||*/ isFetching} loading={isFetching} error={isError} />
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!moonshotKey}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText='Connect directly to Moonshot API from your browser instead of through the server.'
|
||||
/>}
|
||||
|
||||
<SetupFormRefetchButton refetch={refetch} disabled={/*!shallFetchSucceed ||*/ isFetching} loading={isFetching} error={isError} advanced={advanced} />
|
||||
|
||||
{isError && <InlineError error={error} />}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ModelVendorOpenAI } from '../openai/openai.vendor';
|
||||
|
||||
interface DMoonshotServiceSettings {
|
||||
moonshotKey: string;
|
||||
csf?: boolean;
|
||||
}
|
||||
|
||||
export const ModelVendorMoonshot: IModelVendor<DMoonshotServiceSettings, OpenAIAccessSchema> = {
|
||||
@@ -17,6 +18,9 @@ export const ModelVendorMoonshot: IModelVendor<DMoonshotServiceSettings, OpenAIA
|
||||
instanceLimit: 1,
|
||||
hasServerConfigKey: 'hasLlmMoonshot',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfMoonshotAvailable,
|
||||
|
||||
// functions
|
||||
initializeSetup: () => ({
|
||||
moonshotKey: '',
|
||||
@@ -26,6 +30,7 @@ export const ModelVendorMoonshot: IModelVendor<DMoonshotServiceSettings, OpenAIA
|
||||
},
|
||||
getTransportAccess: (partialSetup) => ({
|
||||
dialect: 'moonshot',
|
||||
clientSideFetch: _csfMoonshotAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
oaiKey: partialSetup?.moonshotKey || '',
|
||||
oaiOrg: '',
|
||||
oaiHost: '',
|
||||
@@ -37,3 +42,7 @@ export const ModelVendorMoonshot: IModelVendor<DMoonshotServiceSettings, OpenAIA
|
||||
rpcUpdateModelsOrThrow: ModelVendorOpenAI.rpcUpdateModelsOrThrow,
|
||||
|
||||
};
|
||||
|
||||
function _csfMoonshotAvailable(s?: Partial<DMoonshotServiceSettings>) {
|
||||
return !!s?.moonshotKey;
|
||||
}
|
||||
|
||||
+3
-22
@@ -1,11 +1,9 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, FormControl, Tooltip, Typography } from '@mui/joy';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
import { Button, FormControl, Typography } from '@mui/joy';
|
||||
|
||||
import type { DModelsServiceId } from '~/common/stores/llms/llms.service.types';
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
|
||||
import { FormTextField } from '~/common/components/forms/FormTextField';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { Link } from '~/common/components/Link';
|
||||
@@ -31,7 +29,7 @@ export function OllamaServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
useServiceSetup(props.serviceId, ModelVendorOllama);
|
||||
|
||||
// derived state
|
||||
const { clientSideFetch, ollamaHost, ollamaJson } = serviceAccess;
|
||||
const { clientSideFetch, ollamaHost } = serviceAccess;
|
||||
|
||||
const hostValid = !!asValidURL(ollamaHost);
|
||||
const hostError = !!ollamaHost && !hostValid;
|
||||
@@ -61,27 +59,10 @@ export function OllamaServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
</Typography>
|
||||
</FormControl>
|
||||
|
||||
<FormSwitchControl
|
||||
title='JSON mode'
|
||||
on={<Typography level='title-sm' endDecorator={<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />}>Force JSON</Typography>}
|
||||
off='Off (default)'
|
||||
fullWidth
|
||||
description={
|
||||
<Tooltip arrow title='Models will output only JSON, including empty {} objects.'>
|
||||
<Link level='body-sm' href='https://github.com/ollama/ollama/blob/main/docs/api.md#generate-a-chat-completion' target='_blank'>Information</Link>
|
||||
</Tooltip>
|
||||
}
|
||||
checked={ollamaJson}
|
||||
onChange={on => {
|
||||
updateSettings({ ollamaJson: on });
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<SetupFormClientSideToggle
|
||||
visible={true}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ ollamaCSF: on })}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText="Fetch models and make requests directly from your local Ollama instance using the browser. Recommended for local setups."
|
||||
/>
|
||||
|
||||
|
||||
+10
-6
@@ -6,8 +6,7 @@ import type { OllamaAccessSchema } from '../../server/ollama/ollama.access';
|
||||
|
||||
interface DOllamaServiceSettings {
|
||||
ollamaHost: string;
|
||||
ollamaJson: boolean;
|
||||
ollamaCSF?: boolean;
|
||||
csf?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,20 +19,25 @@ export const ModelVendorOllama: IModelVendor<DOllamaServiceSettings, OllamaAcces
|
||||
instanceLimit: 2,
|
||||
hasServerConfigKey: 'hasLlmOllama',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfOllamaAvailable,
|
||||
|
||||
// functions
|
||||
initializeSetup: () => ({
|
||||
ollamaHost: '',
|
||||
ollamaJson: false,
|
||||
// ollamaCSF: true, // eventually
|
||||
// csf: true, // eventually
|
||||
}),
|
||||
getTransportAccess: (partialSetup): OllamaAccessSchema => ({
|
||||
dialect: 'ollama',
|
||||
clientSideFetch: !!(partialSetup?.ollamaHost && partialSetup?.ollamaCSF),
|
||||
clientSideFetch: _csfOllamaAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
ollamaHost: partialSetup?.ollamaHost || '',
|
||||
ollamaJson: partialSetup?.ollamaJson || false,
|
||||
}),
|
||||
|
||||
// List Models
|
||||
rpcUpdateModelsOrThrow: async (access) => await apiAsync.llmOllama.listModels.query({ access }),
|
||||
|
||||
};
|
||||
|
||||
function _csfOllamaAvailable(s?: Partial<DOllamaServiceSettings>) {
|
||||
return !!s?.ollamaHost;
|
||||
}
|
||||
|
||||
+7
-6
@@ -37,6 +37,7 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
// derived state
|
||||
const { clientSideFetch, oaiKey, oaiOrg, oaiHost, heliKey, moderationCheck } = serviceAccess;
|
||||
const needsUserKey = !serviceHasCloudTenantConfig;
|
||||
const showAdvanced = advanced.on || !!clientSideFetch;
|
||||
|
||||
const keyValid = true; //isValidOpenAIApiKey(oaiKey);
|
||||
const keyError = (/*needsUserKey ||*/ !!oaiKey) && !keyValid;
|
||||
@@ -62,7 +63,7 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
placeholder='sk-...'
|
||||
/>
|
||||
|
||||
{advanced.on && <FormTextField
|
||||
{showAdvanced && <FormTextField
|
||||
autoCompleteId='openai-host'
|
||||
title='API Endpoint'
|
||||
tooltip={`An OpenAI compatible endpoint to be used in place of 'api.openai.com'.\n\nCould be used for Helicone, Cloudflare, or other OpenAI compatible cloud or local services.\n\nExamples:\n - ${HELICONE_OPENAI_HOST}\n - localhost:1234`}
|
||||
@@ -72,7 +73,7 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
onChange={text => updateSettings({ oaiHost: text })}
|
||||
/>}
|
||||
|
||||
{advanced.on && <FormTextField
|
||||
{showAdvanced && <FormTextField
|
||||
autoCompleteId='openai-org'
|
||||
title='Organization ID'
|
||||
description={<Link level='body-sm' href={BaseProduct.OpenSourceRepo + '/issues/63'} target='_blank'>What is this</Link>}
|
||||
@@ -81,7 +82,7 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
onChange={text => updateSettings({ oaiOrg: text })}
|
||||
/>}
|
||||
|
||||
{advanced.on && <FormTextField
|
||||
{showAdvanced && <FormTextField
|
||||
autoCompleteId='openai-helicone-key'
|
||||
title='Helicone Key'
|
||||
description={<>Generate <Link level='body-sm' href='https://www.helicone.ai/keys' target='_blank'>here</Link></>}
|
||||
@@ -96,7 +97,7 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
: 'OpenAI traffic will now be routed through Helicone.'}
|
||||
</Alert>}
|
||||
|
||||
{advanced.on && <FormSwitchControl
|
||||
{showAdvanced && <FormSwitchControl
|
||||
title='Moderation' on='Enabled' fullWidth
|
||||
description={<>
|
||||
<Link level='body-sm' href='https://platform.openai.com/docs/guides/moderation/moderation' target='_blank'>Overview</Link>,
|
||||
@@ -106,10 +107,10 @@ export function OpenAIServiceSetup(props: { serviceId: DModelsServiceId }) {
|
||||
onChange={on => updateSettings({ moderationCheck: on })}
|
||||
/>}
|
||||
|
||||
{advanced.on && <SetupFormClientSideToggle
|
||||
{showAdvanced && <SetupFormClientSideToggle
|
||||
visible={!!oaiHost || !!oaiKey}
|
||||
checked={!!clientSideFetch}
|
||||
onChange={on => updateSettings({ oaiCSF: on })}
|
||||
onChange={on => updateSettings({ csf: on })}
|
||||
helpText="Fetch models and make requests directly to OpenAI's Responses / Completions and List Models API using your browser instead of through the server."
|
||||
/>}
|
||||
|
||||
|
||||
+9
-2
@@ -11,7 +11,7 @@ export interface DOpenAIServiceSettings {
|
||||
oaiKey: string;
|
||||
oaiOrg: string;
|
||||
oaiHost: string; // use OpenAI-compatible non-default hosts (full origin path)
|
||||
oaiCSF?: boolean;
|
||||
csf?: boolean;
|
||||
heliKey: string; // helicone key (works in conjunction with oaiHost)
|
||||
moderationCheck: boolean;
|
||||
}
|
||||
@@ -25,10 +25,13 @@ export const ModelVendorOpenAI: IModelVendor<DOpenAIServiceSettings, OpenAIAcces
|
||||
instanceLimit: 5,
|
||||
hasServerConfigKey: 'hasLlmOpenAI',
|
||||
|
||||
/// client-side-fetch ///
|
||||
csfAvailable: _csfOpenAIAvailable,
|
||||
|
||||
// functions
|
||||
getTransportAccess: (partialSetup): OpenAIAccessSchema => ({
|
||||
dialect: 'openai',
|
||||
clientSideFetch: !!((partialSetup?.oaiHost || partialSetup?.oaiKey) && partialSetup?.oaiCSF),
|
||||
clientSideFetch: _csfOpenAIAvailable(partialSetup) && !!partialSetup?.csf,
|
||||
oaiKey: '',
|
||||
oaiOrg: '',
|
||||
oaiHost: '',
|
||||
@@ -41,3 +44,7 @@ export const ModelVendorOpenAI: IModelVendor<DOpenAIServiceSettings, OpenAIAcces
|
||||
rpcUpdateModelsOrThrow: async (access) => await apiAsync.llmOpenAI.listModels.query({ access }),
|
||||
|
||||
};
|
||||
|
||||
function _csfOpenAIAvailable(s?: Partial<DOpenAIServiceSettings>) {
|
||||
return !!(s?.oaiHost || s?.oaiKey);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user