diff --git a/docs/docs/waveai-modes.mdx b/docs/docs/waveai-modes.mdx index ccdcdfd7f4..d4f0c30a69 100644 --- a/docs/docs/waveai-modes.mdx +++ b/docs/docs/waveai-modes.mdx @@ -28,12 +28,12 @@ Wave AI now supports provider-based configuration which automatically applies se ### Supported Providers -- **`openai`** - OpenAI API (automatically configures endpoint and secret name) -- **`openrouter`** - OpenRouter API (automatically configures endpoint and secret name) -- **`google`** - Google AI (Gemini) -- **`azure`** - Azure OpenAI Service (modern API) -- **`azure-legacy`** - Azure OpenAI Service (legacy deployment API) -- **`custom`** - Custom API endpoint (fully manual configuration) +- **`openai`** - OpenAI API (automatically configures endpoint and secret name) [[see example](#openai)] +- **`openrouter`** - OpenRouter API (automatically configures endpoint and secret name) [[see example](#openrouter)] +- **`google`** - Google AI (Gemini) [[see example](#google-ai-gemini)] +- **`azure`** - Azure OpenAI Service (modern API) [[see example](#azure-openai-modern-api)] +- **`azure-legacy`** - Azure OpenAI Service (legacy deployment API) [[see example](#azure-openai-legacy-deployment-api)] +- **`custom`** - Custom API endpoint (fully manual configuration) [[see examples](#local-model-examples)] ### Supported API Types diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx index a30bc0136e..1878af2d13 100644 --- a/frontend/app/aipanel/aimode.tsx +++ b/frontend/app/aipanel/aimode.tsx @@ -1,14 +1,128 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms, createBlock, getSettingsKeyAtom } from "@/app/store/global"; +import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { cn, fireAndForget, makeIconClass } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useRef, useState } from "react"; import { getFilteredAIModeConfigs } from "./ai-utils"; import { WaveAIModel } from "./waveai-model"; -export const AIModeDropdown = memo(() => { +interface AIModeMenuItemProps { + config: any; + isSelected: boolean; + isDisabled: boolean; + onClick: () => void; + isFirst?: boolean; + isLast?: boolean; +} + +const AIModeMenuItem = memo(({ config, isSelected, isDisabled, onClick, isFirst, isLast }: AIModeMenuItemProps) => { + return ( + + ); +}); + +AIModeMenuItem.displayName = "AIModeMenuItem"; + +interface ConfigSection { + sectionName: string; + configs: any[]; + isIncompatible?: boolean; +} + +function computeCompatibleSections( + currentMode: string, + aiModeConfigs: Record, + waveProviderConfigs: any[], + otherProviderConfigs: any[] +): ConfigSection[] { + const currentConfig = aiModeConfigs[currentMode]; + const allConfigs = [...waveProviderConfigs, ...otherProviderConfigs]; + + if (!currentConfig) { + return [{ sectionName: "Incompatible Modes", configs: allConfigs, isIncompatible: true }]; + } + + const currentSwitchCompat = currentConfig["ai:switchcompat"] || []; + const compatibleConfigs: any[] = [currentConfig]; + const incompatibleConfigs: any[] = []; + + if (currentSwitchCompat.length === 0) { + allConfigs.forEach((config) => { + if (config.mode !== currentMode) { + incompatibleConfigs.push(config); + } + }); + } else { + allConfigs.forEach((config) => { + if (config.mode === currentMode) return; + + const configSwitchCompat = config["ai:switchcompat"] || []; + const hasMatch = currentSwitchCompat.some((currentTag: string) => + configSwitchCompat.includes(currentTag) + ); + + if (hasMatch) { + compatibleConfigs.push(config); + } else { + incompatibleConfigs.push(config); + } + }); + } + + const sections: ConfigSection[] = []; + const compatibleSectionName = compatibleConfigs.length === 1 ? "Current" : "Compatible Modes"; + sections.push({ sectionName: compatibleSectionName, configs: compatibleConfigs }); + + if (incompatibleConfigs.length > 0) { + sections.push({ sectionName: "Incompatible Modes", configs: incompatibleConfigs, isIncompatible: true }); + } + + return sections; +} + +function computeWaveCloudSections(waveProviderConfigs: any[], otherProviderConfigs: any[]): ConfigSection[] { + const sections: ConfigSection[] = []; + + if (waveProviderConfigs.length > 0) { + sections.push({ sectionName: "Wave AI Cloud", configs: waveProviderConfigs }); + } + if (otherProviderConfigs.length > 0) { + sections.push({ sectionName: "Custom", configs: otherProviderConfigs }); + } + + return sections; +} + +interface AIModeDropdownProps { + compatibilityMode?: boolean; +} + +export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdownProps) => { const model = WaveAIModel.getInstance(); const aiMode = useAtomValue(model.currentAIMode); const aiModeConfigs = useAtomValue(model.aiModeConfigs); @@ -27,18 +141,6 @@ export const AIModeDropdown = memo(() => { hasPremium ); - const hasBothModeTypes = waveProviderConfigs.length > 0 && otherProviderConfigs.length > 0; - - const handleSelect = (mode: string) => { - const config = aiModeConfigs[mode]; - if (!config) return; - if (!hasPremium && config["waveai:premium"]) { - return; - } - model.setAIMode(mode); - setIsOpen(false); - }; - let currentMode = aiMode || defaultMode; const currentConfig = aiModeConfigs[currentMode]; if (currentConfig) { @@ -50,12 +152,35 @@ export const AIModeDropdown = memo(() => { } } + const sections: ConfigSection[] = compatibilityMode + ? computeCompatibleSections(currentMode, aiModeConfigs, waveProviderConfigs, otherProviderConfigs) + : computeWaveCloudSections(waveProviderConfigs, otherProviderConfigs); + + const showSectionHeaders = compatibilityMode || sections.length > 1; + + const handleSelect = (mode: string) => { + const config = aiModeConfigs[mode]; + if (!config) return; + if (!hasPremium && config["waveai:premium"]) { + return; + } + model.setAIMode(mode); + setIsOpen(false); + }; + const displayConfig = aiModeConfigs[currentMode] || { "display:name": "? Unknown", "display:icon": "question", }; - return ( + const handleConfigureClick = () => { + fireAndForget(async () => { + await model.openWaveAIConfig(); + setIsOpen(false); + }); + }; + + return (
- ); - })} - {hasBothModeTypes && ( -
- )} - {hasBothModeTypes && ( -
- Custom -
- )} - {otherProviderConfigs.map((config, index) => { - const isFirst = index === 0 && !hasBothModeTypes; - const isLast = index === otherProviderConfigs.length - 1; - const isDisabled = !hasPremium && config["waveai:premium"]; - const isSelected = currentMode === config.mode; + {sections.map((section, sectionIndex) => { + const isFirstSection = sectionIndex === 0; + const isLastSection = sectionIndex === sections.length - 1; + return ( - + {section.configs.map((config, index) => { + const isFirst = index === 0 && isFirstSection && !showSectionHeaders; + const isLast = index === section.configs.length - 1 && isLastSection; + const isPremiumDisabled = !hasPremium && config["waveai:premium"]; + const isIncompatibleDisabled = section.isIncompatible || false; + const isDisabled = isPremiumDisabled || isIncompatibleDisabled; + const isSelected = currentMode === config.mode; + return ( + handleSelect(config.mode)} + isFirst={isFirst} + isLast={isLast} + /> + ); + })} +
); })}
- + + + + + +
diff --git a/frontend/app/aipanel/aipanelmessages.tsx b/frontend/app/aipanel/aipanelmessages.tsx index 1c55f1f071..ac24d6145f 100644 --- a/frontend/app/aipanel/aipanelmessages.tsx +++ b/frontend/app/aipanel/aipanelmessages.tsx @@ -62,7 +62,7 @@ export const AIPanelMessages = memo(({ messages, status, onContextMenu }: AIPane onContextMenu={onContextMenu} >
- +
{messages.map((message, index) => { const isLastMessage = index === messages.length - 1; diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 270796c4e4..abc2615868 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -547,6 +547,16 @@ export class WaveAIModel { await createBlock(blockDef, false, true); } + async openWaveAIConfig() { + const blockDef: BlockDef = { + meta: { + view: "waveconfig", + file: "waveai.json", + }, + }; + await createBlock(blockDef, false, true); + } + openRestoreBackupModal(toolcallid: string) { globalStore.set(this.restoreBackupModalToolCallId, toolcallid); } diff --git a/frontend/app/view/waveconfig/waveconfig-model.ts b/frontend/app/view/waveconfig/waveconfig-model.ts index 5c942a5810..cb2630ad1b 100644 --- a/frontend/app/view/waveconfig/waveconfig-model.ts +++ b/frontend/app/view/waveconfig/waveconfig-model.ts @@ -8,7 +8,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { SecretsContent } from "@/app/view/waveconfig/secretscontent"; import { WaveConfigView } from "@/app/view/waveconfig/waveconfig"; -import { WaveAIVisualContent } from "@/app/view/waveconfig/waveaivisual"; +import { isWindows } from "@/util/platformutil"; import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type PrimitiveAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; @@ -22,6 +22,7 @@ export type ConfigFile = { path: string; language?: string; deprecated?: boolean; + description?: string; docsUrl?: string; validator?: ConfigValidator; isSecrets?: boolean; @@ -77,10 +78,11 @@ const configFiles: ConfigFile[] = [ path: "connections.json", language: "json", docsUrl: "https://docs.waveterm.dev/connections", + description: isWindows() ? "SSH hosts and WSL distros" : "SSH hosts", hasJsonView: true, }, { - name: "Widgets", + name: "Sidebar Widgets", path: "widgets.json", language: "json", docsUrl: "https://docs.waveterm.dev/customwidgets", @@ -90,12 +92,14 @@ const configFiles: ConfigFile[] = [ name: "Wave AI Modes", path: "waveai.json", language: "json", + description: "Local models and BYOK", + docsUrl: "https://docs.waveterm.dev/waveai-modes", validator: validateWaveAiJson, hasJsonView: true, // visualComponent: WaveAIVisualContent, }, { - name: "Backgrounds", + name: "Tab Backgrounds", path: "presets/bg.json", language: "json", docsUrl: "https://docs.waveterm.dev/presets#background-configurations", diff --git a/frontend/app/view/waveconfig/waveconfig.tsx b/frontend/app/view/waveconfig/waveconfig.tsx index cb9bfeff85..1bce94474c 100644 --- a/frontend/app/view/waveconfig/waveconfig.tsx +++ b/frontend/app/view/waveconfig/waveconfig.tsx @@ -41,11 +41,16 @@ const ConfigSidebar = memo(({ model }: ConfigSidebarProps) => {
handleFileSelect(file)} - className={`px-4 py-2 border-b border-border cursor-pointer transition-colors whitespace-nowrap overflow-hidden text-ellipsis ${ + className={`px-4 py-2 border-b border-border cursor-pointer transition-colors ${ selectedFile?.path === file.path ? "bg-accentbg text-primary" : "hover:bg-secondary/50" }`} > - {file.name} +
{file.name}
+ {file.description && ( +
+ {file.description} +
+ )}
))} {deprecatedConfigFiles.length > 0 && ( @@ -168,15 +173,16 @@ const WaveConfigView = memo(({ blockId, model }: ViewComponentProps {selectedFile.docsUrl && ( - - - + + + + + )}
{selectedFile.path} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 7aa02b3a3e..988fcf012f 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -24,12 +24,13 @@ declare global { "ai:model"?: string; "ai:thinkinglevel"?: string; "ai:endpoint"?: string; - "ai:apiversion"?: string; + "ai:azureapiversion"?: string; "ai:apitoken"?: string; "ai:apitokensecretname"?: string; "ai:azureresourcename"?: string; "ai:azuredeployment"?: string; "ai:capabilities"?: string[]; + "ai:switchcompat"?: string[]; "waveai:cloud"?: boolean; "waveai:premium"?: boolean; }; diff --git a/pkg/wconfig/defaultconfig/waveai.json b/pkg/wconfig/defaultconfig/waveai.json index bb07f7a340..c115211b73 100644 --- a/pkg/wconfig/defaultconfig/waveai.json +++ b/pkg/wconfig/defaultconfig/waveai.json @@ -8,7 +8,8 @@ "ai:apitype": "openai-responses", "ai:model": "gpt-5-mini", "ai:thinkinglevel": "low", - "ai:capabilities": ["tools", "images", "pdfs"] + "ai:capabilities": ["tools", "images", "pdfs"], + "ai:switchcompat": ["wavecloud"] }, "waveai@balanced": { "display:name": "Balanced", @@ -20,7 +21,8 @@ "ai:model": "gpt-5.1", "ai:thinkinglevel": "low", "ai:capabilities": ["tools", "images", "pdfs"], - "waveai:premium": true + "waveai:premium": true, + "ai:switchcompat": ["wavecloud"] }, "waveai@deep": { "display:name": "Deep", @@ -32,6 +34,7 @@ "ai:model": "gpt-5.1", "ai:thinkinglevel": "medium", "ai:capabilities": ["tools", "images", "pdfs"], - "waveai:premium": true + "waveai:premium": true, + "ai:switchcompat": ["wavecloud"] } } diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 1d6adf1eda..6d0da5dab5 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -278,6 +278,7 @@ type AIModeConfigType struct { AzureResourceName string `json:"ai:azureresourcename,omitempty"` AzureDeployment string `json:"ai:azuredeployment,omitempty"` Capabilities []string `json:"ai:capabilities,omitempty" jsonschema:"enum=pdfs,enum=images,enum=tools"` + SwitchCompat []string `json:"ai:switchcompat,omitempty"` WaveAICloud bool `json:"waveai:cloud,omitempty"` WaveAIPremium bool `json:"waveai:premium,omitempty"` } diff --git a/schema/waveai.json b/schema/waveai.json index b88d3b1bfb..1612cac88d 100644 --- a/schema/waveai.json +++ b/schema/waveai.json @@ -30,7 +30,7 @@ "ai:apitype": { "type": "string", "enum": [ - "anthropic-messages", + "google-gemini", "openai-responses", "openai-chat" ] @@ -49,7 +49,7 @@ "ai:endpoint": { "type": "string" }, - "ai:apiversion": { + "ai:azureapiversion": { "type": "string" }, "ai:apitoken": { @@ -75,6 +75,12 @@ }, "type": "array" }, + "ai:switchcompat": { + "items": { + "type": "string" + }, + "type": "array" + }, "waveai:cloud": { "type": "boolean" },