Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/server/main-server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -526,6 +527,7 @@ func main() {
sigutil.InstallShutdownSignalHandlers(doShutdown)
sigutil.InstallSIGUSR1Handler()
startConfigWatcher()
aiusechat.InitAIModeConfigWatcher()
maybeStartPprofServer()
go stdinReadWatch()
go telemetryLoop()
Expand Down
22 changes: 22 additions & 0 deletions frontend/app/aipanel/ai-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"})`;
}
91 changes: 59 additions & 32 deletions frontend/app/aipanel/aimode.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -34,13 +35,16 @@ const AIModeMenuItem = memo(({ config, isSelected, isDisabled, onClick, isFirst,
<div className="flex items-center gap-2 w-full">
<i className={makeIconClass(config["display:icon"] || "sparkles", false)}></i>
<span className={cn("text-sm", isSelected && "font-bold")}>
{config["display:name"]}
{getModeDisplayName(config)}
{isDisabled && " (premium)"}
</span>
{isSelected && <i className="fa fa-check ml-auto"></i>}
</div>
{config["display:description"] && (
<div className={cn("text-xs pl-5", isDisabled ? "text-gray-500" : "text-muted")} style={{ whiteSpace: "pre-line" }}>
<div
className={cn("text-xs pl-5", isDisabled ? "text-gray-500" : "text-muted")}
style={{ whiteSpace: "pre-line" }}
>
{config["display:description"]}
</div>
)}
Expand All @@ -52,26 +56,26 @@ AIModeMenuItem.displayName = "AIModeMenuItem";

interface ConfigSection {
sectionName: string;
configs: any[];
configs: AIModeConfigWithMode[];
isIncompatible?: boolean;
}

function computeCompatibleSections(
currentMode: string,
aiModeConfigs: Record<string, any>,
waveProviderConfigs: any[],
otherProviderConfigs: any[]
aiModeConfigs: Record<string, AIModeConfigType>,
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) => {
Expand All @@ -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 {
Expand All @@ -99,24 +101,24 @@ 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 });
}

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;
}

Expand All @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -200,29 +206,50 @@ 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}`}
>
<i className={cn(makeIconClass(displayConfig["display:icon"] || "sparkles", false), "text-[10px]")}></i>
<span className={`text-[11px]`}>
{displayConfig["display:name"]}
</span>
<i className={cn(makeIconClass(displayIcon, false), "text-[10px]")}></i>
<span className={`text-[11px]`}>{displayName}</span>
<i className="fa fa-chevron-down text-[8px]"></i>
</button>

{showNoToolsWarning && (
<Tooltip
content={
<div className="max-w-xs">
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.
</div>
}
placement="bottom"
>
<div className="flex items-center gap-1 text-[10px] text-yellow-600 mt-1 ml-1 cursor-default">
<i className="fa fa-triangle-exclamation"></i>
<span>No Tools Support</span>
</div>
</Tooltip>
)}

{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute top-full left-0 mt-1 bg-gray-800 border border-gray-600 rounded shadow-lg z-50 min-w-[280px]">
{sections.map((section, sectionIndex) => {
const isFirstSection = sectionIndex === 0;
const isLastSection = sectionIndex === sections.length - 1;

return (
<div key={section.sectionName}>
{!isFirstSection && <div className="border-t border-gray-600 my-2" />}
{showSectionHeaders && (
<>
<div className={cn("pb-1 text-center text-[10px] text-gray-400 uppercase tracking-wide", isFirstSection ? "pt-2" : "pt-0")}>
<div
className={cn(
"pb-1 text-center text-[10px] text-gray-400 uppercase tracking-wide",
isFirstSection ? "pt-2" : "pt-0"
)}
>
{section.sectionName}
</div>
{section.isIncompatible && (
Expand Down
25 changes: 22 additions & 3 deletions frontend/app/aipanel/aipanel-contextmenu.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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" });

Expand Down
9 changes: 7 additions & 2 deletions frontend/app/aipanel/aipanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="text-secondary py-8">
<div className="text-center">
Expand Down Expand Up @@ -155,7 +160,7 @@ const AIWelcomeMessage = memo(() => {
</div>
</div>
</div>
<BYOKAnnouncement />
{!hasCustomModes && <BYOKAnnouncement />}
<div className="mt-4 text-center text-[12px] text-muted">
BETA: Free to use. Daily limits keep our costs in check.
</div>
Expand Down Expand Up @@ -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<WaveUIMessage>({
transport: new DefaultChatTransport({
api: model.getUseChatEndpointUrl(),
prepareSendMessagesRequest: (opts) => {
Expand Down
9 changes: 8 additions & 1 deletion frontend/app/aipanel/aipanelheader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@ export const AIPanelHeader = memo(() => {
handleWaveAIContextMenu(e, false);
};

const handleContextMenu = (e: React.MouseEvent) => {
handleWaveAIContextMenu(e, false);
};

return (
<div className="py-2 pl-3 pr-1 @xs:p-2 @xs:pl-4 border-b border-gray-600 flex items-center justify-between min-w-0">
<div
className="py-2 pl-3 pr-1 @xs:p-2 @xs:pl-4 border-b border-gray-600 flex items-center justify-between min-w-0"
onContextMenu={handleContextMenu}
>
<h2 className="text-white text-sm @xs:text-lg font-semibold flex items-center gap-2 flex-shrink-0 whitespace-nowrap">
<i className="fa fa-sparkles text-accent"></i>
Wave AI
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/aipanel/aipanelmessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 4 additions & 4 deletions frontend/app/aipanel/byokannouncement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const BYOKAnnouncement = () => {
};

return (
<div className="bg-blue-900/20 border border-blue-500 rounded-lg p-4 mt-4">
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 mt-4">
<div className="flex items-start gap-3">
<i className="fa fa-key text-blue-400 text-lg mt-0.5"></i>
<div className="text-left flex-1">
Expand All @@ -48,7 +48,7 @@ const BYOKAnnouncement = () => {
<div className="flex items-center gap-3">
<button
onClick={handleOpenConfig}
className="bg-blue-500/80 hover:bg-blue-500 text-secondary hover:text-primary px-3 py-1.5 rounded-md text-sm font-medium cursor-pointer transition-colors"
className="border border-blue-400 text-blue-400 hover:bg-blue-500/10 hover:text-blue-300 px-3 py-1.5 rounded-md text-sm font-medium cursor-pointer transition-colors"
>
Configure AI Modes
</button>
Expand All @@ -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 <i className="fa fa-external-link text-xs"></i>
</a>
Expand All @@ -70,4 +70,4 @@ const BYOKAnnouncement = () => {

BYOKAnnouncement.displayName = "BYOKAnnouncement";

export { BYOKAnnouncement };
export { BYOKAnnouncement };
Loading
Loading