diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 748acaefb..5259b60ff 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 fce9a7194..dd725571d 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 d8aa67cca..5ae1d8a38 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 2c4766f90..ffa9336d8 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 cd4d8a745..c386348b3 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 a2c4d586c..7a54f7cb2 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 ac24d6145..a0284153d 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 ce67c1bdc..935cc4a3b 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 3db4cc14a..0b6e35e75 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 0715eae69..dfb6ab8ae 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 bf1a9485a..7f78df430 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 1c7a674cb..e481590bf 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 448aa709a..dd4176968 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 0bddd41d7..f46240efd 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 5d629f677..be2853d0d 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 7aacc8f40..2d479c524 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 06364da25..e6d7737ed 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 1f932a6ed..e7f11bea3 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 fae1422fb..5c223b298 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 bfb132704..aa0441911 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 076d96470..4f16295e6 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 10f2d1754..eeecc0bb3 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 0ce53d257..df07a00a1 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 6f6c2afc7..41dd5bd4f 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