From 281566ee5e39233a94480db02b4653d7c23e77a1 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 18:15:59 -0800 Subject: [PATCH 01/10] get something for display:name even if it isn't set --- frontend/app/aipanel/ai-utils.ts | 22 +++++++++ frontend/app/aipanel/aimode.tsx | 51 +++++++++++---------- frontend/app/aipanel/aipanel-contextmenu.ts | 6 +-- frontend/types/gotypes.d.ts | 4 ++ package-lock.json | 4 +- 5 files changed, 58 insertions(+), 29 deletions(-) diff --git a/frontend/app/aipanel/ai-utils.ts b/frontend/app/aipanel/ai-utils.ts index fce9a7194d..dd725571d6 100644 --- a/frontend/app/aipanel/ai-utils.ts +++ b/frontend/app/aipanel/ai-utils.ts @@ -572,3 +572,25 @@ export const getFilteredAIModeConfigs = ( shouldShowCloudModes, }; }; + +/** + * Get the display name for an AI mode configuration. + * If display:name is set, use that. Otherwise, construct from model/provider. + * For azure-legacy, show "azureresourcename (azure)". + * For other providers, show "model (provider)". + */ +export function getModeDisplayName(config: AIModeConfigType): string { + if (config["display:name"]) { + return config["display:name"]; + } + + const provider = config["ai:provider"]; + const model = config["ai:model"]; + const azureResourceName = config["ai:azureresourcename"]; + + if (provider === "azure-legacy") { + return `${azureResourceName || "unknown"} (azure)`; + } + + return `${model || "unknown"} (${provider || "custom"})`; +} diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx index d8aa67ccac..566b105799 100644 --- a/frontend/app/aipanel/aimode.tsx +++ b/frontend/app/aipanel/aimode.tsx @@ -7,7 +7,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { cn, fireAndForget, makeIconClass } from "@/util/util"; import { useAtomValue } from "jotai"; import { memo, useRef, useState } from "react"; -import { getFilteredAIModeConfigs } from "./ai-utils"; +import { getFilteredAIModeConfigs, getModeDisplayName } from "./ai-utils"; import { WaveAIModel } from "./waveai-model"; interface AIModeMenuItemProps { @@ -34,13 +34,16 @@ const AIModeMenuItem = memo(({ config, isSelected, isDisabled, onClick, isFirst,
- {config["display:name"]} + {getModeDisplayName(config)} {isDisabled && " (premium)"} {isSelected && }
{config["display:description"] && ( -
+
{config["display:description"]}
)} @@ -64,11 +67,11 @@ function computeCompatibleSections( ): 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[] = []; @@ -82,12 +85,10 @@ function computeCompatibleSections( } else { allConfigs.forEach((config) => { if (config.mode === currentMode) return; - + const configSwitchCompat = config["ai:switchcompat"] || []; - const hasMatch = currentSwitchCompat.some((currentTag: string) => - configSwitchCompat.includes(currentTag) - ); - + const hasMatch = currentSwitchCompat.some((currentTag: string) => configSwitchCompat.includes(currentTag)); + if (hasMatch) { compatibleConfigs.push(config); } else { @@ -99,7 +100,7 @@ function computeCompatibleSections( 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 }); } @@ -109,14 +110,14 @@ function computeCompatibleSections( 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; } @@ -170,10 +171,9 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow setIsOpen(false); }; - const displayConfig = aiModeConfigs[currentMode] || { - "display:name": "? Unknown", - "display:icon": "question", - }; + const displayConfig = aiModeConfigs[currentMode]; + const displayName = displayConfig ? getModeDisplayName(displayConfig) : "Unknown"; + const displayIcon = displayConfig?.["display:icon"] || "sparkles"; const handleConfigureClick = () => { fireAndForget(async () => { @@ -200,12 +200,10 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow "group flex items-center gap-1.5 px-2 py-1 text-xs text-gray-300 hover:text-white rounded transition-colors cursor-pointer border border-gray-600/50", isOpen ? "bg-gray-700" : "bg-gray-800/50 hover:bg-gray-700" )} - title={`AI Mode: ${displayConfig["display:name"]}`} + title={`AI Mode: ${displayName}`} > - - - {displayConfig["display:name"]} - + + {displayName} @@ -216,13 +214,18 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow {sections.map((section, sectionIndex) => { const isFirstSection = sectionIndex === 0; const isLastSection = sectionIndex === sections.length - 1; - + return (
{!isFirstSection &&
} {showSectionHeaders && ( <> -
+
{section.sectionName}
{section.isIncompatible && ( diff --git a/frontend/app/aipanel/aipanel-contextmenu.ts b/frontend/app/aipanel/aipanel-contextmenu.ts index 2c4766f90e..72ba31aaf3 100644 --- a/frontend/app/aipanel/aipanel-contextmenu.ts +++ b/frontend/app/aipanel/aipanel-contextmenu.ts @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { getFilteredAIModeConfigs } from "@/app/aipanel/ai-utils"; +import { getFilteredAIModeConfigs, getModeDisplayName } from "@/app/aipanel/ai-utils"; import { waveAIHasSelection } from "@/app/aipanel/waveai-focus-utils"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { atoms, getSettingsKeyAtom, isDev } from "@/app/store/global"; @@ -68,7 +68,7 @@ export async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boo const isPremium = config["waveai:premium"] === true; const isEnabled = !isPremium || hasPremium; aiModeSubmenu.push({ - label: config["display:name"] || mode, + label: getModeDisplayName(config), type: "checkbox", checked: currentAIMode === mode, enabled: isEnabled, @@ -98,7 +98,7 @@ export async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boo const isPremium = config["waveai:premium"] === true; const isEnabled = !isPremium || hasPremium; aiModeSubmenu.push({ - label: config["display:name"] || mode, + label: getModeDisplayName(config), type: "checkbox", checked: currentAIMode === mode, enabled: isEnabled, diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 1c7a674cbb..867be52ea0 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1214,6 +1214,8 @@ declare global { "settings:customwidgets"?: number; "settings:customaipresets"?: number; "settings:customsettings"?: number; + "settings:customaimodes"?: number; + "settings:secretscount"?: number; "activity:activeminutes"?: number; "activity:fgminutes"?: number; "activity:openminutes"?: number; @@ -1296,6 +1298,8 @@ declare global { "settings:customwidgets"?: number; "settings:customaipresets"?: number; "settings:customsettings"?: number; + "settings:customaimodes"?: number; + "settings:secretscount"?: number; }; // waveobj.Tab diff --git a/package-lock.json b/package-lock.json index 0bddd41d75..f46240efdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.13.0-beta.0", + "version": "0.13.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.13.0-beta.0", + "version": "0.13.0-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ From fe5e5f58e315eda23683b73a150b27feaf657b16 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 18:21:51 -0800 Subject: [PATCH 02/10] some whitespace trimming for secrets --- pkg/aiusechat/usechat.go | 2 +- pkg/secretstore/secretstore.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 06364da256..175f3237c4 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -89,7 +89,7 @@ func getWaveAISettings(premium bool, builderMode bool, rtInfo waveobj.ObjRTInfo) if !exists || secret == "" { return nil, fmt.Errorf("secret %s not found or empty", config.APITokenSecretName) } - apiToken = secret + apiToken = strings.TrimSpace(secret) } var baseUrl string diff --git a/pkg/secretstore/secretstore.go b/pkg/secretstore/secretstore.go index 1f932a6edd..e7f11bea37 100644 --- a/pkg/secretstore/secretstore.go +++ b/pkg/secretstore/secretstore.go @@ -12,6 +12,7 @@ import ( "path/filepath" "regexp" "runtime" + "strings" "sync" "time" @@ -228,7 +229,7 @@ func SetSecret(name string, value string) error { lock.Lock() defer lock.Unlock() - secrets[name] = value + secrets[name] = strings.TrimRight(value, "\r\n") requestWrite() return nil } From fb6c00bd939ba85dff3957416cf6a9e19fccba7c Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 18:24:30 -0800 Subject: [PATCH 03/10] add configure modes to context menu, context menu in header... --- frontend/app/aipanel/aipanel-contextmenu.ts | 19 +++++++++++++++++++ frontend/app/aipanel/aipanelheader.tsx | 9 ++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/app/aipanel/aipanel-contextmenu.ts b/frontend/app/aipanel/aipanel-contextmenu.ts index 72ba31aaf3..ffa9336d8e 100644 --- a/frontend/app/aipanel/aipanel-contextmenu.ts +++ b/frontend/app/aipanel/aipanel-contextmenu.ts @@ -201,6 +201,25 @@ export async function handleWaveAIContextMenu(e: React.MouseEvent, showCopy: boo submenu: maxTokensSubmenu, }); + menu.push({ type: "separator" }); + + menu.push({ + label: "Configure Modes", + click: () => { + RpcApi.RecordTEventCommand( + TabRpcClient, + { + event: "action:other", + props: { + "action:type": "waveai:configuremodes:contextmenu", + }, + }, + { noresponse: true } + ); + model.openWaveAIConfig(); + }, + }); + if (model.canCloseWaveAIPanel()) { menu.push({ type: "separator" }); diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx index a2c4d586c2..7a54f7cb26 100644 --- a/frontend/app/aipanel/aipanelheader.tsx +++ b/frontend/app/aipanel/aipanelheader.tsx @@ -15,8 +15,15 @@ export const AIPanelHeader = memo(() => { handleWaveAIContextMenu(e, false); }; + const handleContextMenu = (e: React.MouseEvent) => { + handleWaveAIContextMenu(e, false); + }; + return ( -
+

Wave AI From 6c792844fe55c198b8566a91fec552160f9cfb37 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 18:26:19 -0800 Subject: [PATCH 04/10] chill out button colors and border --- frontend/app/aipanel/byokannouncement.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/app/aipanel/byokannouncement.tsx b/frontend/app/aipanel/byokannouncement.tsx index ce67c1bdcc..605f76df92 100644 --- a/frontend/app/aipanel/byokannouncement.tsx +++ b/frontend/app/aipanel/byokannouncement.tsx @@ -36,7 +36,7 @@ const BYOKAnnouncement = () => { }; return ( -
+
@@ -48,7 +48,7 @@ const BYOKAnnouncement = () => {
@@ -70,4 +70,4 @@ const BYOKAnnouncement = () => { BYOKAnnouncement.displayName = "BYOKAnnouncement"; -export { BYOKAnnouncement }; \ No newline at end of file +export { BYOKAnnouncement }; From fd99d39f73a951b4c855af2ffdcf34a131344b13 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 18:28:39 -0800 Subject: [PATCH 05/10] turn link blue instead of green --- frontend/app/aipanel/byokannouncement.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/aipanel/byokannouncement.tsx b/frontend/app/aipanel/byokannouncement.tsx index 605f76df92..f03513866b 100644 --- a/frontend/app/aipanel/byokannouncement.tsx +++ b/frontend/app/aipanel/byokannouncement.tsx @@ -57,7 +57,7 @@ const BYOKAnnouncement = () => { target="_blank" rel="noopener noreferrer" onClick={handleViewDocs} - className="text-secondary hover:text-primary text-sm cursor-pointer transition-colors flex items-center gap-1" + className="!text-blue-400 hover:!text-blue-300 hover:underline text-sm cursor-pointer transition-colors flex items-center gap-1" > View Docs From e39ec6919ea2f448c605b0134ed7aad0d5ea94ef Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 18:32:37 -0800 Subject: [PATCH 06/10] custom modes... doesn't show when you already have custom modes --- frontend/app/aipanel/aipanel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index cd4d8a745a..9e4ae21382 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -83,6 +83,8 @@ KeyCap.displayName = "KeyCap"; const AIWelcomeMessage = memo(() => { const modKey = isMacOS() ? "⌘" : "Alt"; + const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); + const hasCustomModes = fullConfig?.waveai ? Object.keys(fullConfig.waveai).some(key => !key.startsWith("waveai@")) : false; return (
@@ -155,7 +157,7 @@ const AIWelcomeMessage = memo(() => {
- + {!hasCustomModes && }
BETA: Free to use. Daily limits keep our costs in check.
From df0fa7dc06e4e0c8c081db0b04ac2797ec810f8c Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 20:53:55 -0800 Subject: [PATCH 07/10] a *lot* of plumbing to get "no tools support" thing to show up in the AI panel --- cmd/server/main-server.go | 2 ++ frontend/app/aipanel/aimode.tsx | 24 ++++++++++++++++++++ frontend/app/store/global.ts | 9 ++++++++ frontend/app/store/wshclientapi.ts | 5 +++++ frontend/types/custom.d.ts | 1 + frontend/types/gotypes.d.ts | 5 +++++ frontend/wave.ts | 4 ++++ pkg/aiusechat/usechat-mode.go | 36 ++++++++++++++++++++++++++++++ pkg/tsgen/tsgen.go | 1 + pkg/wconfig/settingsconfig.go | 4 ++++ pkg/wps/wpstypes.go | 1 + pkg/wshrpc/wshclient/wshclient.go | 6 +++++ pkg/wshrpc/wshrpctypes.go | 2 ++ pkg/wshrpc/wshserver/wshserver.go | 6 +++++ 14 files changed, 106 insertions(+) diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 748acaefbd..5259b60ffa 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -14,6 +14,7 @@ import ( "time" "github.com/joho/godotenv" + "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/authkey" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" @@ -526,6 +527,7 @@ func main() { sigutil.InstallShutdownSignalHandlers(doShutdown) sigutil.InstallSIGUSR1Handler() startConfigWatcher() + aiusechat.InitAIModeConfigWatcher() maybeStartPprofServer() go stdinReadWatch() go telemetryLoop() diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx index 566b105799..a38f920f50 100644 --- a/frontend/app/aipanel/aimode.tsx +++ b/frontend/app/aipanel/aimode.tsx @@ -1,6 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Tooltip } from "@/app/element/tooltip"; import { atoms, getSettingsKeyAtom } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -129,6 +130,8 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow const model = WaveAIModel.getInstance(); const aiMode = useAtomValue(model.currentAIMode); const aiModeConfigs = useAtomValue(model.aiModeConfigs); + const waveaiModeConfigs = useAtomValue(atoms.waveaiModeConfigAtom); + const widgetContextEnabled = useAtomValue(model.widgetAccessAtom); const rateLimitInfo = useAtomValue(atoms.waveAIRateLimitInfoAtom); const showCloudModes = useAtomValue(getSettingsKeyAtom("waveai:showcloudmodes")); const defaultMode = useAtomValue(getSettingsKeyAtom("waveai:defaultmode")) ?? "waveai@balanced"; @@ -174,6 +177,9 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow const displayConfig = aiModeConfigs[currentMode]; const displayName = displayConfig ? getModeDisplayName(displayConfig) : "Unknown"; const displayIcon = displayConfig?.["display:icon"] || "sparkles"; + const resolvedConfig = waveaiModeConfigs[currentMode]; + const hasToolsSupport = resolvedConfig && resolvedConfig["ai:capabilities"]?.includes("tools"); + const showNoToolsWarning = widgetContextEnabled && resolvedConfig && !hasToolsSupport; const handleConfigureClick = () => { fireAndForget(async () => { @@ -207,6 +213,24 @@ export const AIModeDropdown = memo(({ compatibilityMode = false }: AIModeDropdow + {showNoToolsWarning && ( + + Warning: This custom mode was configured without the "tools" capability in the + "ai:capabilities" array. Without tool support, Wave AI will not be able to interact with + widgets or files. +
+ } + placement="bottom" + > +
+ + No Tools Support +
+ + )} + {isOpen && ( <>
setIsOpen(false)} /> diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 3db4cc14af..0b6e35e751 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -107,6 +107,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); }); const fullConfigAtom = atom(null) as PrimitiveAtom; + const waveaiModeConfigAtom = atom(null) as PrimitiveAtom>; const settingsAtom = atom((get) => { return get(fullConfigAtom)?.settings ?? {}; }) as Atom; @@ -180,6 +181,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { waveWindow: windowDataAtom, workspace: workspaceAtom, fullConfigAtom, + waveaiModeConfigAtom, settingsAtom, hasCustomAIPresetsAtom, tabAtom, @@ -218,6 +220,13 @@ function initGlobalWaveEventSubs(initOpts: WaveInitOpts) { globalStore.set(atoms.fullConfigAtom, fullConfig); }, }, + { + eventType: "waveai:modeconfig", + handler: (event) => { + const modeConfigs = (event.data as AIModeConfigUpdate).configs; + globalStore.set(atoms.waveaiModeConfigAtom, modeConfigs); + }, + }, { eventType: "userinput", handler: (event) => { diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 0715eae699..dfb6ab8aec 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -352,6 +352,11 @@ class RpcApiType { return client.wshRpcCall("getwaveaichat", data, opts); } + // command "getwaveaimodeconfig" [call] + GetWaveAIModeConfigCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("getwaveaimodeconfig", null, opts); + } + // command "getwaveairatelimit" [call] GetWaveAIRateLimitCommand(client: WshClient, opts?: RpcOpts): Promise { return client.wshRpcCall("getwaveairatelimit", null, opts); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index bf1a9485a7..c63095dfe5 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -16,6 +16,7 @@ declare global { waveWindow: jotai.Atom; // driven from WOS workspace: jotai.Atom; // driven from WOS fullConfigAtom: jotai.PrimitiveAtom; // driven from WOS, settings -- updated via WebSocket + waveaiModeConfigAtom: jotai.PrimitiveAtom>; // resolved AI mode configs -- updated via WebSocket settingsAtom: jotai.Atom; // derrived from fullConfig hasCustomAIPresetsAtom: jotai.Atom; // derived from fullConfig tabAtom: jotai.Atom; // driven from WOS diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 867be52ea0..e481590bf7 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -35,6 +35,11 @@ declare global { "waveai:premium"?: boolean; }; + // wconfig.AIModeConfigUpdate + type AIModeConfigUpdate = { + configs: {[key: string]: AIModeConfigType}; + }; + // wshrpc.ActivityDisplayType type ActivityDisplayType = { width: number; diff --git a/frontend/wave.ts b/frontend/wave.ts index 448aa709aa..dd41769681 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -205,6 +205,8 @@ async function initWave(initOpts: WaveInitOpts) { const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); + const waveaiModeConfig = await RpcApi.GetWaveAIModeConfigCommand(TabRpcClient); + globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs); console.log("Wave First Render"); let firstRenderResolveFn: () => void = null; let firstRenderPromise = new Promise((resolve) => { @@ -283,6 +285,8 @@ async function initBuilder(initOpts: BuilderInitOpts) { const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); + const waveaiModeConfig = await RpcApi.GetWaveAIModeConfigCommand(TabRpcClient); + globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs); console.log("Tsunami Builder First Render"); let firstRenderResolveFn: () => void = null; diff --git a/pkg/aiusechat/usechat-mode.go b/pkg/aiusechat/usechat-mode.go index 5d629f6779..be2853d0df 100644 --- a/pkg/aiusechat/usechat-mode.go +++ b/pkg/aiusechat/usechat-mode.go @@ -5,12 +5,14 @@ package aiusechat import ( "fmt" + "log" "os" "regexp" "github.com/wavetermdev/waveterm/pkg/aiusechat/aiutil" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wps" ) var AzureResourceNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) @@ -236,3 +238,37 @@ func getAIModeConfig(aiMode string) (*wconfig.AIModeConfigType, error) { applyProviderDefaults(&config) return &config, nil } + +func InitAIModeConfigWatcher() { + watcher := wconfig.GetWatcher() + watcher.RegisterUpdateHandler(handleConfigUpdate) + log.Printf("AI mode config watcher initialized\n") +} + +func handleConfigUpdate(fullConfig wconfig.FullConfigType) { + resolvedConfigs := ComputeResolvedAIModeConfigs(fullConfig) + broadcastAIModeConfigs(resolvedConfigs) +} + +func ComputeResolvedAIModeConfigs(fullConfig wconfig.FullConfigType) map[string]wconfig.AIModeConfigType { + resolvedConfigs := make(map[string]wconfig.AIModeConfigType) + + for modeName, modeConfig := range fullConfig.WaveAIModes { + resolved := modeConfig + applyProviderDefaults(&resolved) + resolvedConfigs[modeName] = resolved + } + + return resolvedConfigs +} + +func broadcastAIModeConfigs(configs map[string]wconfig.AIModeConfigType) { + update := wconfig.AIModeConfigUpdate{ + Configs: configs, + } + + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_AIModeConfig, + Data: update, + }) +} diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index fae1422fbb..5c223b298c 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -53,6 +53,7 @@ var ExtraTypes = []any{ waveobj.MetaTSType{}, waveobj.ObjRTInfo{}, uctypes.RateLimitInfo{}, + wconfig.AIModeConfigUpdate{}, } // add extra type unions to generate here diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index bfb1327044..aa0441911b 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -284,6 +284,10 @@ type AIModeConfigType struct { WaveAIPremium bool `json:"waveai:premium,omitempty"` } +type AIModeConfigUpdate struct { + Configs map[string]AIModeConfigType `json:"configs"` +} + type FullConfigType struct { Settings SettingsType `json:"settings" merge:"meta"` MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index 076d964708..4f16295e64 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -21,6 +21,7 @@ const ( Event_WaveAIRateLimit = "waveai:ratelimit" Event_WaveAppAppGoUpdated = "waveapp:appgoupdated" Event_TsunamiUpdateMeta = "tsunami:updatemeta" + Event_AIModeConfig = "waveai:modeconfig" ) type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 10f2d17540..eeecc0bb31 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -428,6 +428,12 @@ func GetWaveAIChatCommand(w *wshutil.WshRpc, data wshrpc.CommandGetWaveAIChatDat return resp, err } +// command "getwaveaimodeconfig", wshserver.GetWaveAIModeConfigCommand +func GetWaveAIModeConfigCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wconfig.AIModeConfigUpdate, error) { + resp, err := sendRpcRequestCallHelper[wconfig.AIModeConfigUpdate](w, "getwaveaimodeconfig", nil, opts) + return resp, err +} + // command "getwaveairatelimit", wshserver.GetWaveAIRateLimitCommand func GetWaveAIRateLimitCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*uctypes.RateLimitInfo, error) { resp, err := sendRpcRequestCallHelper[*uctypes.RateLimitInfo](w, "getwaveairatelimit", nil, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 0ce53d257f..df07a00a18 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -99,6 +99,7 @@ const ( Command_SetConfig = "setconfig" Command_SetConnectionsConfig = "connectionsconfig" Command_GetFullConfig = "getfullconfig" + Command_GetWaveAIModeConfig = "getwaveaimodeconfig" Command_RemoteStreamFile = "remotestreamfile" Command_RemoteTarStream = "remotetarstream" Command_RemoteFileInfo = "remotefileinfo" @@ -245,6 +246,7 @@ type WshRpcInterface interface { SetConfigCommand(ctx context.Context, data MetaSettingsType) error SetConnectionsConfigCommand(ctx context.Context, data ConnConfigRequest) error GetFullConfigCommand(ctx context.Context) (wconfig.FullConfigType, error) + GetWaveAIModeConfigCommand(ctx context.Context) (wconfig.AIModeConfigUpdate, error) BlockInfoCommand(ctx context.Context, blockId string) (*BlockInfoData, error) BlocksListCommand(ctx context.Context, data BlocksListRequest) ([]BlocksListEntry, error) WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 6f6c2afc7a..41dd5bd4f5 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -592,6 +592,12 @@ func (ws *WshServer) GetFullConfigCommand(ctx context.Context) (wconfig.FullConf return watcher.GetFullConfig(), nil } +func (ws *WshServer) GetWaveAIModeConfigCommand(ctx context.Context) (wconfig.AIModeConfigUpdate, error) { + fullConfig := wconfig.GetWatcher().GetFullConfig() + resolvedConfigs := aiusechat.ComputeResolvedAIModeConfigs(fullConfig) + return wconfig.AIModeConfigUpdate{Configs: resolvedConfigs}, nil +} + func (ws *WshServer) ConnStatusCommand(ctx context.Context) ([]wshrpc.ConnStatus, error) { rtn := conncontroller.GetAllConnStatus() return rtn, nil From 23d65088e744fefb0e24ee557d4fbd680f80beaf Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 20:56:29 -0800 Subject: [PATCH 08/10] trim secret before testing empty --- pkg/aiusechat/usechat.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 175f3237c4..4fd437544b 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -86,10 +86,11 @@ func getWaveAISettings(premium bool, builderMode bool, rtInfo waveobj.ObjRTInfo) if err != nil { return nil, fmt.Errorf("failed to retrieve secret %s: %w", config.APITokenSecretName, err) } + secret = strings.TrimSpace(secret) if !exists || secret == "" { return nil, fmt.Errorf("secret %s not found or empty", config.APITokenSecretName) } - apiToken = strings.TrimSpace(secret) + apiToken = secret } var baseUrl string From b24115549cbd3587aa1ed0b7ec79447b42cce9bc Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 21:07:29 -0800 Subject: [PATCH 09/10] fix nits and a lot of "any" types --- frontend/app/aipanel/aimode.tsx | 16 ++++++++-------- frontend/app/aipanel/aipanel.tsx | 7 +++++-- frontend/app/aipanel/aipanelmessages.tsx | 3 ++- frontend/app/aipanel/byokannouncement.tsx | 2 +- frontend/types/custom.d.ts | 2 ++ 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx index a38f920f50..5ae1d8a385 100644 --- a/frontend/app/aipanel/aimode.tsx +++ b/frontend/app/aipanel/aimode.tsx @@ -12,7 +12,7 @@ import { getFilteredAIModeConfigs, getModeDisplayName } from "./ai-utils"; import { WaveAIModel } from "./waveai-model"; interface AIModeMenuItemProps { - config: any; + config: AIModeConfigWithMode; isSelected: boolean; isDisabled: boolean; onClick: () => void; @@ -56,15 +56,15 @@ AIModeMenuItem.displayName = "AIModeMenuItem"; interface ConfigSection { sectionName: string; - configs: any[]; + configs: AIModeConfigWithMode[]; isIncompatible?: boolean; } function computeCompatibleSections( currentMode: string, - aiModeConfigs: Record, - waveProviderConfigs: any[], - otherProviderConfigs: any[] + aiModeConfigs: Record, + waveProviderConfigs: AIModeConfigWithMode[], + otherProviderConfigs: AIModeConfigWithMode[] ): ConfigSection[] { const currentConfig = aiModeConfigs[currentMode]; const allConfigs = [...waveProviderConfigs, ...otherProviderConfigs]; @@ -74,8 +74,8 @@ function computeCompatibleSections( } const currentSwitchCompat = currentConfig["ai:switchcompat"] || []; - const compatibleConfigs: any[] = [currentConfig]; - const incompatibleConfigs: any[] = []; + const compatibleConfigs: AIModeConfigWithMode[] = [{ ...currentConfig, mode: currentMode }]; + const incompatibleConfigs: AIModeConfigWithMode[] = []; if (currentSwitchCompat.length === 0) { allConfigs.forEach((config) => { @@ -109,7 +109,7 @@ function computeCompatibleSections( return sections; } -function computeWaveCloudSections(waveProviderConfigs: any[], otherProviderConfigs: any[]): ConfigSection[] { +function computeWaveCloudSections(waveProviderConfigs: AIModeConfigWithMode[], otherProviderConfigs: AIModeConfigWithMode[]): ConfigSection[] { const sections: ConfigSection[] = []; if (waveProviderConfigs.length > 0) { diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 9e4ae21382..c386348b32 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -21,6 +21,7 @@ import { AIPanelHeader } from "./aipanelheader"; import { AIPanelInput } from "./aipanelinput"; import { AIPanelMessages } from "./aipanelmessages"; import { AIRateLimitStrip } from "./airatelimitstrip"; +import { WaveUIMessage } from "./aitypes"; import { BYOKAnnouncement } from "./byokannouncement"; import { TelemetryRequiredMessage } from "./telemetryrequired"; import { WaveAIModel } from "./waveai-model"; @@ -84,7 +85,9 @@ KeyCap.displayName = "KeyCap"; const AIWelcomeMessage = memo(() => { const modKey = isMacOS() ? "⌘" : "Alt"; const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); - const hasCustomModes = fullConfig?.waveai ? Object.keys(fullConfig.waveai).some(key => !key.startsWith("waveai@")) : false; + const hasCustomModes = fullConfig?.waveai + ? Object.keys(fullConfig.waveai).some((key) => !key.startsWith("waveai@")) + : false; return (
@@ -221,7 +224,7 @@ const AIPanelComponentInner = memo(() => { const telemetryEnabled = jotai.useAtomValue(getSettingsKeyAtom("telemetry:enabled")) ?? false; const isPanelVisible = jotai.useAtomValue(model.getPanelVisibleAtom()); - const { messages, sendMessage, status, setMessages, error, stop } = useChat({ + const { messages, sendMessage, status, setMessages, error, stop } = useChat({ transport: new DefaultChatTransport({ api: model.getUseChatEndpointUrl(), prepareSendMessagesRequest: (opts) => { diff --git a/frontend/app/aipanel/aipanelmessages.tsx b/frontend/app/aipanel/aipanelmessages.tsx index ac24d6145f..a0284153da 100644 --- a/frontend/app/aipanel/aipanelmessages.tsx +++ b/frontend/app/aipanel/aipanelmessages.tsx @@ -5,10 +5,11 @@ import { useAtomValue } from "jotai"; import { memo, useEffect, useRef } from "react"; import { AIMessage } from "./aimessage"; import { AIModeDropdown } from "./aimode"; +import { type WaveUIMessage } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; interface AIPanelMessagesProps { - messages: any[]; + messages: WaveUIMessage[]; status: string; onContextMenu?: (e: React.MouseEvent) => void; } diff --git a/frontend/app/aipanel/byokannouncement.tsx b/frontend/app/aipanel/byokannouncement.tsx index f03513866b..935cc4a3b0 100644 --- a/frontend/app/aipanel/byokannouncement.tsx +++ b/frontend/app/aipanel/byokannouncement.tsx @@ -57,7 +57,7 @@ const BYOKAnnouncement = () => { target="_blank" rel="noopener noreferrer" onClick={handleViewDocs} - className="!text-blue-400 hover:!text-blue-300 hover:underline text-sm cursor-pointer transition-colors flex items-center gap-1" + className="text-blue-400! hover:text-blue-300! hover:underline text-sm cursor-pointer transition-colors flex items-center gap-1" > View Docs diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index c63095dfe5..7f78df4304 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -497,6 +497,8 @@ declare global { size?: number; previewurl?: string; }; + + type AIModeConfigWithMode = { mode: string } & AIModeConfigType; } export {}; From 596e2086d735dbd860eb00184099c771192ef4d2 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 8 Dec 2025 21:40:37 -0800 Subject: [PATCH 10/10] use a "no tools" system prompt when we dont have tools access --- pkg/aiusechat/usechat-prompts.go | 37 ++++++++++++++++++++++++++++++++ pkg/aiusechat/usechat.go | 10 ++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/pkg/aiusechat/usechat-prompts.go b/pkg/aiusechat/usechat-prompts.go index 7aacc8f40a..2d479c5246 100644 --- a/pkg/aiusechat/usechat-prompts.go +++ b/pkg/aiusechat/usechat-prompts.go @@ -43,6 +43,43 @@ var SystemPromptText_OpenAI = strings.Join([]string{ `You have NO API access to widgets or Wave unless provided via an explicit tool.`, }, " ") +var SystemPromptText_NoTools = strings.Join([]string{ + `You are Wave AI, an assistant embedded in Wave Terminal (a terminal with graphical widgets).`, + `You appear as a pull-out panel on the left; widgets are on the right.`, + + // Capabilities & truthfulness + `Be truthful about your capabilities. You can answer questions, explain concepts, provide code examples, and help with technical problems, but you cannot directly access files, execute commands, or interact with the terminal. If you lack specific data or access, say so directly and suggest what the user could do to provide it.`, + + // Crisp behavior + `Be concise and direct. Prefer determinism over speculation. If a brief clarifying question eliminates guesswork, ask it.`, + + // Attached text files + `User-attached text files may appear inline as \ncontent\n.`, + `User-attached directories use the tag JSON DirInfo.`, + `If multiple attached files exist, treat each as a separate source file with its own file_name.`, + `When the user refers to these files, use their inline content directly for analysis and discussion.`, + + // Output & formatting + `When presenting commands or any runnable multi-line code, always use fenced Markdown code blocks.`, + `Use an appropriate language hint after the opening fence (e.g., "bash" for shell commands, "go" for Go, "json" for JSON).`, + `For shell commands, do NOT prefix lines with "$" or shell prompts. Use placeholders in ALL_CAPS (e.g., PROJECT_ID) and explain them once after the block if needed.`, + "Reserve inline code (single backticks) for short references like command names (`grep`, `less`), flags, env vars, file paths, or tiny snippets not meant to be executed.", + `You may use Markdown (lists, tables, bold/italics) to improve readability.`, + `Never comment on or justify your formatting choices; just follow these rules.`, + `When generating code or command blocks, try to keep lines under ~100 characters wide where practical (soft wrap; do not break tokens mid-word). Favor indentation and short variable names to stay compact, but correctness always takes priority.`, + + // Safety & limits + `If a request would execute dangerous or destructive actions, warn briefly and provide a safer alternative.`, + `If output is very long, prefer a brief summary plus a copy-ready fenced block or offer a follow-up chunking strategy.`, + + `You cannot directly write files, execute shell commands, run code in the terminal, or access remote files.`, + `When users ask for code or commands, provide ready-to-use examples they can copy and execute themselves.`, + `If they need file modifications, show the exact changes they should make.`, + + // Final reminder + `You have NO API access to widgets or Wave Terminal internals.`, +}, " ") + var SystemPromptText_StrictToolAddOn = `## Tool Call Rules (STRICT) When you decide a file write/edit tool call is needed: diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 4fd437544b..e6d7737ed0 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -47,14 +47,18 @@ var ( activeChats = ds.MakeSyncMap[bool]() // key is chatid ) -func getSystemPrompt(apiType string, model string, isBuilder bool) []string { +func getSystemPrompt(apiType string, model string, isBuilder bool, hasToolsCapability bool, widgetAccess bool) []string { if isBuilder { return []string{} } + useNoToolsPrompt := !hasToolsCapability || !widgetAccess basePrompt := SystemPromptText_OpenAI + if useNoToolsPrompt { + basePrompt = SystemPromptText_NoTools + } modelLower := strings.ToLower(model) needsStrictToolAddOn, _ := regexp.MatchString(`(?i)\b(mistral|o?llama|qwen|mixtral|yi|phi|deepseek)\b`, modelLower) - if needsStrictToolAddOn { + if needsStrictToolAddOn && !useNoToolsPrompt { return []string{basePrompt, SystemPromptText_StrictToolAddOn} } return []string{basePrompt} @@ -659,7 +663,7 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { BuilderId: req.BuilderId, BuilderAppId: req.BuilderAppId, } - chatOpts.SystemPrompt = getSystemPrompt(chatOpts.Config.APIType, chatOpts.Config.Model, chatOpts.BuilderId != "") + chatOpts.SystemPrompt = getSystemPrompt(chatOpts.Config.APIType, chatOpts.Config.Model, chatOpts.BuilderId != "", chatOpts.Config.HasCapability(uctypes.AICapabilityTools), chatOpts.WidgetAccess) if req.TabId != "" { chatOpts.TabStateGenerator = func() (string, []uctypes.ToolDefinition, string, error) {