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/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..5ae1d8a385 100644 --- a/frontend/app/aipanel/aimode.tsx +++ b/frontend/app/aipanel/aimode.tsx @@ -1,17 +1,18 @@ // 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"; 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 { - config: any; + config: AIModeConfigWithMode; isSelected: boolean; isDisabled: boolean; onClick: () => void; @@ -34,13 +35,16 @@ const AIModeMenuItem = memo(({ config, isSelected, isDisabled, onClick, isFirst,
- {config["display:name"]} + {getModeDisplayName(config)} {isDisabled && " (premium)"} {isSelected && }
{config["display:description"] && ( -
+
{config["display:description"]}
)} @@ -52,26 +56,26 @@ 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]; - + if (!currentConfig) { return [{ sectionName: "Incompatible Modes", configs: allConfigs, isIncompatible: true }]; } - + 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) => { @@ -82,12 +86,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 +101,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 }); } @@ -107,16 +109,16 @@ 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) { sections.push({ sectionName: "Wave AI Cloud", configs: waveProviderConfigs }); } if (otherProviderConfigs.length > 0) { sections.push({ sectionName: "Custom", configs: otherProviderConfigs }); } - + return sections; } @@ -128,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"; @@ -170,10 +174,12 @@ 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 resolvedConfig = waveaiModeConfigs[currentMode]; + const hasToolsSupport = resolvedConfig && resolvedConfig["ai:capabilities"]?.includes("tools"); + const showNoToolsWarning = widgetContextEnabled && resolvedConfig && !hasToolsSupport; const handleConfigureClick = () => { fireAndForget(async () => { @@ -200,15 +206,31 @@ 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} + {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)} /> @@ -216,13 +238,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..ffa9336d8e 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, @@ -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/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index cd4d8a745a..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"; @@ -83,6 +84,10 @@ 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 +160,7 @@ const AIWelcomeMessage = memo(() => {
- + {!hasCustomModes && }
BETA: Free to use. Daily limits keep our costs in check.
@@ -219,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/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 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 ce67c1bdcc..935cc4a3b0 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 = () => {
@@ -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 @@ -70,4 +70,4 @@ const BYOKAnnouncement = () => { BYOKAnnouncement.displayName = "BYOKAnnouncement"; -export { BYOKAnnouncement }; \ No newline at end of file +export { BYOKAnnouncement }; 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..7f78df4304 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 @@ -496,6 +497,8 @@ declare global { size?: number; previewurl?: string; }; + + type AIModeConfigWithMode = { mode: string } & AIModeConfigType; } export {}; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 1c7a674cbb..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; @@ -1214,6 +1219,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 +1303,8 @@ declare global { "settings:customwidgets"?: number; "settings:customaipresets"?: number; "settings:customsettings"?: number; + "settings:customaimodes"?: number; + "settings:secretscount"?: number; }; // waveobj.Tab 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/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": [ 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/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 06364da256..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} @@ -86,6 +90,7 @@ 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) } @@ -658,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) { 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 } 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