From 185ab29832269eddcb48da3db4d6566180266e2b Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 15 Sep 2025 22:40:02 -0700 Subject: [PATCH 001/125] some small tw migration --- frontend/app/app-bg.tsx | 2 +- frontend/app/app.scss | 25 +++++++------------------ frontend/app/app.tsx | 4 ++-- package-lock.json | 4 ++-- 4 files changed, 12 insertions(+), 23 deletions(-) diff --git a/frontend/app/app-bg.tsx b/frontend/app/app-bg.tsx index 718e3b82af..e2e60cb8ad 100644 --- a/frontend/app/app-bg.tsx +++ b/frontend/app/app-bg.tsx @@ -42,5 +42,5 @@ export function AppBackground() { useLayoutEffect(getAvgColor, [getAvgColor]); useResizeObserver(bgRef, getAvgColor); - return
; + return
; } diff --git a/frontend/app/app.scss b/frontend/app/app.scss index 6652c9ac81..396982ec57 100644 --- a/frontend/app/app.scss +++ b/frontend/app/app.scss @@ -18,6 +18,13 @@ body { transform: translateZ(0); } +#main { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + .is-transparent { background-color: transparent; } @@ -53,24 +60,6 @@ a.plain-link { font: var(--fixed-font); } -#main, -.mainapp { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - - .app-background { - pointer-events: none; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: var(--zindex-app-background); - } -} - .error-boundary { white-space: pre-wrap; color: var(--error-color); diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index dfe5679fc0..d67dc27f6f 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -279,7 +279,7 @@ const AppInner = () => { if (client == null || windowData == null) { return ( -
+
invalid configuration, client or window was not loaded
@@ -288,7 +288,7 @@ const AppInner = () => { return (
Date: Mon, 15 Sep 2025 23:11:38 -0700 Subject: [PATCH 002/125] togglable / resizeable wave ai panel, cmd-shift-a --- frontend/app/aipanel/aipanel.tsx | 41 ++++++++++++ frontend/app/store/keymodel.ts | 6 ++ .../app/workspace/workspace-layout-model.ts | 63 +++++++++++++++++++ frontend/app/workspace/workspace.tsx | 63 +++++++++++++++++-- package-lock.json | 11 ++++ package.json | 1 + 6 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 frontend/app/aipanel/aipanel.tsx create mode 100644 frontend/app/workspace/workspace-layout-model.ts diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx new file mode 100644 index 0000000000..aa6229993c --- /dev/null +++ b/frontend/app/aipanel/aipanel.tsx @@ -0,0 +1,41 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { memo } from "react"; + +interface AIPanelProps { + className?: string; + onClose?: () => void; +} + +const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { + return ( +
+
+

+ + Wave AI +

+ {onClose && ( + + )} +
+
+
+

Wave AI content goes here...

+

This is a placeholder for the AI assistant interface.

+
+
+
+ ); +}); + +AIPanelComponent.displayName = "AIPanel"; + +export { AIPanelComponent as AIPanel }; diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index b673925584..d51b2c26c5 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -29,6 +29,7 @@ import { CHORD_TIMEOUT } from "@/util/sharedconst"; import { fireAndForget } from "@/util/util"; import * as jotai from "jotai"; import { modalsModel } from "./modalmodel"; +import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; type KeyHandler = (event: WaveKeyboardEvent) => boolean; @@ -515,6 +516,11 @@ function registerGlobalKeys() { } return false; }); + globalKeyMap.set("Cmd:Shift:a", () => { + const currentVisible = workspaceLayoutModel.getAIPanelVisible(); + workspaceLayoutModel.setAIPanelVisible(!currentVisible); + return true; + }); const allKeys = Array.from(globalKeyMap.keys()); // special case keys, handled by web view allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o"); diff --git a/frontend/app/workspace/workspace-layout-model.ts b/frontend/app/workspace/workspace-layout-model.ts new file mode 100644 index 0000000000..778f6043b1 --- /dev/null +++ b/frontend/app/workspace/workspace-layout-model.ts @@ -0,0 +1,63 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { atom, PrimitiveAtom } from "jotai"; +import { globalStore } from "@/store/jotaiStore"; +import { isDev } from "@/store/global"; + +const AI_PANEL_DEFAULT_WIDTH = 300; +const AI_PANEL_MIN_WIDTH = 200; + +class WorkspaceLayoutModel { + aiPanelVisibleAtom: PrimitiveAtom; + aiPanelWidthAtom: PrimitiveAtom; + + constructor() { + this.aiPanelVisibleAtom = atom(isDev()); + this.aiPanelWidthAtom = atom(AI_PANEL_DEFAULT_WIDTH); + } + + getMaxAIPanelWidth(windowWidth: number): number { + return Math.floor(windowWidth * 0.5); + } + + getClampedAIPanelWidth(width: number, windowWidth: number): number { + const maxWidth = this.getMaxAIPanelWidth(windowWidth); + return Math.max(AI_PANEL_MIN_WIDTH, Math.min(width, maxWidth)); + } + + getAIPanelVisible(): boolean { + return globalStore.get(this.aiPanelVisibleAtom); + } + + setAIPanelVisible(visible: boolean): void { + if (!isDev() && visible) { + return; + } + globalStore.set(this.aiPanelVisibleAtom, visible); + } + + getAIPanelWidth(): number { + return globalStore.get(this.aiPanelWidthAtom); + } + + setAIPanelWidth(width: number): void { + globalStore.set(this.aiPanelWidthAtom, width); + } + + handleAIPanelResize(width: number, windowWidth: number): void { + if (!isDev()) { + return; + } + const clampedWidth = this.getClampedAIPanelWidth(width, windowWidth); + this.setAIPanelWidth(clampedWidth); + + if (!this.getAIPanelVisible()) { + this.setAIPanelVisible(true); + } + } +} + +const workspaceLayoutModel = new WorkspaceLayoutModel(); + +export { workspaceLayoutModel, WorkspaceLayoutModel, AI_PANEL_MIN_WIDTH, AI_PANEL_DEFAULT_WIDTH }; \ No newline at end of file diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index c4abd13ea0..e611a9750a 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -7,13 +7,42 @@ import { ModalsRenderer } from "@/app/modals/modalsrenderer"; import { TabBar } from "@/app/tab/tabbar"; import { TabContent } from "@/app/tab/tabcontent"; import { Widgets } from "@/app/workspace/widgets"; +import { workspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; +import { AIPanel } from "@/app/aipanel/aipanel"; import { atoms } from "@/store/global"; import { useAtomValue } from "jotai"; -import { memo } from "react"; +import { memo, useEffect, useState } from "react"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; const WorkspaceElem = memo(() => { const tabId = useAtomValue(atoms.staticTabId); const ws = useAtomValue(atoms.workspace); + const aiPanelVisible = useAtomValue(workspaceLayoutModel.aiPanelVisibleAtom); + const aiPanelWidth = useAtomValue(workspaceLayoutModel.aiPanelWidthAtom); + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + + useEffect(() => { + const handleResize = () => { + setWindowWidth(window.innerWidth); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const handlePanelResize = (sizes: number[]) => { + if (sizes.length >= 2 && aiPanelVisible) { + const aiPanelPixelWidth = (sizes[0] / 100) * windowWidth; + workspaceLayoutModel.handleAIPanelResize(aiPanelPixelWidth, windowWidth); + } + }; + + const handleCloseAIPanel = () => { + workspaceLayoutModel.setAIPanelVisible(false); + }; + + const aiPanelPercentage = aiPanelVisible ? Math.min((aiPanelWidth / windowWidth) * 100, 50) : 0; + const mainContentPercentage = aiPanelVisible ? 100 - aiPanelPercentage : 100; + return (
@@ -22,16 +51,38 @@ const WorkspaceElem = memo(() => { {tabId === "" ? ( No Active Tab ) : ( - <> - - - - + + {aiPanelVisible && ( + <> + + + + + + )} + +
+ + +
+
+
)} +
); }); +WorkspaceElem.displayName = "WorkspaceElem"; + export { WorkspaceElem as Workspace }; diff --git a/package-lock.json b/package-lock.json index 4cccc0f9a1..b66b4009ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "react-frame-component": "^5.2.7", "react-hook-form": "^7.62.0", "react-markdown": "^9.0.3", + "react-resizable-panels": "^3.0.6", "react-zoom-pan-pinch": "^3.7.0", "recharts": "^2.15.4", "rehype-highlight": "^7.0.2", @@ -27513,6 +27514,16 @@ "react": ">=18" } }, + "node_modules/react-resizable-panels": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", + "integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/react-router": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", diff --git a/package.json b/package.json index 2ed968e111..86f247f52d 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "react-frame-component": "^5.2.7", "react-hook-form": "^7.62.0", "react-markdown": "^9.0.3", + "react-resizable-panels": "^3.0.6", "react-zoom-pan-pinch": "^3.7.0", "recharts": "^2.15.4", "rehype-highlight": "^7.0.2", From 9606a4e3e2d86ed276d5591bb8076e4d71964301 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 09:36:58 -0700 Subject: [PATCH 003/125] waveai mode for usechat --- Taskfile.yml | 6 ++++++ pkg/waveai/usechat.go | 36 ++++++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 6dd064e10d..351d086ee7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -134,6 +134,12 @@ tasks: - docsite:build:embedded - build:backend + build:frontend:dev: + desc: Build the frontend in development mode. + cmd: npm run build:dev + deps: + - npm:install + build:backend: desc: Build the wavesrv and wsh components. cmds: diff --git a/pkg/waveai/usechat.go b/pkg/waveai/usechat.go index 3bfd1747c2..fec5cd712f 100644 --- a/pkg/waveai/usechat.go +++ b/pkg/waveai/usechat.go @@ -11,6 +11,7 @@ import ( "fmt" "log" "net/http" + "os" "strings" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -77,6 +78,20 @@ func (m *UseChatMessage) GetContent() string { type UseChatRequest struct { Messages []UseChatMessage `json:"messages"` Options *wconfig.AiSettingsType `json:"options,omitempty"` + WaveAI bool `json:"waveai,omitempty"` +} + +func getWaveAISettings() (*wshrpc.WaveAIOptsType, error) { + anthropicSecret := os.Getenv("WAVETERM_ANTHROPIC_SECRET") + if anthropicSecret == "" { + return nil, fmt.Errorf("no anthropic secret found") + } + return &wshrpc.WaveAIOptsType{ + APIToken: anthropicSecret, + Model: "claude-sonnet-4-20250514", + APIType: APIType_Anthropic, + MaxTokens: 10 * 1024, + }, nil } func resolveAIConfig(ctx context.Context, blockId, presetKey string, requestOptions *wconfig.AiSettingsType) (*wshrpc.WaveAIOptsType, error) { @@ -224,10 +239,23 @@ func HandleAIChat(w http.ResponseWriter, r *http.Request) { } // Resolve AI configuration - aiOpts, err := resolveAIConfig(r.Context(), blockId, presetKey, req.Options) - if err != nil { - http.Error(w, fmt.Sprintf("Configuration error: %v", err), http.StatusInternalServerError) - return + var aiOpts *wshrpc.WaveAIOptsType + var err error + + if req.WaveAI { + // Use WaveAI settings + aiOpts, err = getWaveAISettings() + if err != nil { + http.Error(w, fmt.Sprintf("WaveAI configuration error: %v", err), http.StatusInternalServerError) + return + } + } else { + // Use standard configuration resolution + aiOpts, err = resolveAIConfig(r.Context(), blockId, presetKey, req.Options) + if err != nil { + http.Error(w, fmt.Sprintf("Configuration error: %v", err), http.StatusInternalServerError) + return + } } // Validate configuration From 3f6bfb80a4b5d540b80516eecccf9451af046e21 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 10:47:47 -0700 Subject: [PATCH 004/125] add ai sdk --- package-lock.json | 148 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 + 2 files changed, 150 insertions(+) diff --git a/package-lock.json b/package-lock.json index b66b4009ca..dacbbc3d82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "tsunami/frontend" ], "dependencies": { + "@ai-sdk/react": "^2.0.44", "@floating-ui/react": "^0.27.16", "@monaco-editor/loader": "^1.5.0", "@monaco-editor/react": "^4.7.0", @@ -30,6 +31,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "ai": "^5.0.44", "base64-js": "^1.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -269,6 +271,75 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/gateway": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.23.tgz", + "integrity": "sha512-ynV7WxpRK2zWLGkdOtrU2hW22mBVkEYVS3iMg1+ZGmAYSgzCqzC74bfOJZ2GU1UdcrFWUsFI9qAYjsPkd+AebA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", + "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.9.tgz", + "integrity": "sha512-Pm571x5efqaI4hf9yW4KsVlDBDme8++UepZRnq+kqVBWWjgvGhQlzU8glaFq0YJEB9kkxZHbRRyVeHoV2sRYaQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, + "node_modules/@ai-sdk/react": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.44.tgz", + "integrity": "sha512-+a1ZjpJA8pRfuFImypMAjGkivlwdITfUxOXSa3B73CB0YnW2WYVNECX4nC6JD9mWIq/NMurllAXwszpMO8hVuw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "3.0.9", + "ai": "5.0.44", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.25.76 || ^4" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@algolia/abtesting": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.3.0.tgz", @@ -7316,6 +7387,15 @@ "node": ">=12" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -12051,6 +12131,24 @@ "node": ">=8" } }, + "node_modules/ai": { + "version": "5.0.44", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.44.tgz", + "integrity": "sha512-l/rdoM4LcRpsRBVvZQBwSU73oNoFGlWj+PcH86QRzxDGJgZqgGItWO0QcKjBNcLDmUjGN1VYd/8J0TAXHJleRQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "1.0.23", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.9", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -17145,6 +17243,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -20098,6 +20205,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -31018,6 +31131,19 @@ "node": ">=16" } }, + "node_modules/swr": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz", + "integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -31438,6 +31564,18 @@ "node": ">=12.22" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -34214,6 +34352,16 @@ "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==", "license": "MIT" }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 86f247f52d..0884d6620e 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "vitest": "^3.0.9" }, "dependencies": { + "@ai-sdk/react": "^2.0.44", "@floating-ui/react": "^0.27.16", "@monaco-editor/loader": "^1.5.0", "@monaco-editor/react": "^4.7.0", @@ -106,6 +107,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "ai": "^5.0.44", "base64-js": "^1.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", From 43026f0aab044c53af9a26b16c016d0e9f09f869 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 11:41:20 -0700 Subject: [PATCH 005/125] checkpoint --- frontend/app/aipanel/aipanel.tsx | 215 ++++++++++++++++++++++++++++++- frontend/app/app.scss | 4 + 2 files changed, 213 insertions(+), 6 deletions(-) diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index aa6229993c..28f41c1418 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -1,7 +1,14 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { memo } from "react"; +import { cn } from "@/util/util"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; + +interface Message { + id: string; + role: "user" | "assistant"; + content: string; +} interface AIPanelProps { className?: string; @@ -9,8 +16,137 @@ interface AIPanelProps { } const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const scrollToBottom = () => { + const container = messagesContainerRef.current; + if (container) { + container.scrollTop = container.scrollHeight; + } + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + setInput(e.target.value); + }, []); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || isLoading) return; + + const userMessage: Message = { + id: Date.now().toString(), + role: "user", + content: input.trim(), + }; + + const newMessages = [...messages, userMessage]; + setMessages(newMessages); + setInput(""); + setIsLoading(true); + + try { + const response = await fetch("/api/aichat?blockid=ai-panel", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + messages: newMessages.map((msg) => ({ + role: msg.role, + content: msg.content, + })), + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("No response body"); + } + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: "assistant", + content: "", + }; + + setMessages((prev) => [...prev, assistantMessage]); + + const decoder = new TextDecoder(); + let done = false; + + while (!done) { + const { value, done: streamDone } = await reader.read(); + done = streamDone; + + if (value) { + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6); + if (data === "[DONE]") { + done = true; + break; + } + try { + const parsed = JSON.parse(data); + if (parsed.choices?.[0]?.delta?.content) { + setMessages((prev) => { + const newMessages = [...prev]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage && lastMessage.role === "assistant") { + lastMessage.content += parsed.choices[0].delta.content; + } + return newMessages; + }); + } + } catch (parseError) { + console.error("Error parsing SSE data:", parseError); + } + } + } + } + } + } catch (error) { + console.error("Error sending message:", error); + setMessages((prev) => [ + ...prev, + { + id: (Date.now() + 1).toString(), + role: "assistant", + content: "Sorry, I encountered an error. Please try again.", + }, + ]); + } finally { + setIsLoading(false); + } + }, + [input, isLoading, messages] + ); + return ( -
+

@@ -26,10 +162,77 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { )}

-
-
-

Wave AI content goes here...

-

This is a placeholder for the AI assistant interface.

+ +
+
+ {messages.length === 0 ? ( +
+ +

Welcome to Wave AI

+

Start a conversation by typing a message below.

+
+ ) : ( + messages.map((message) => ( +
+
+
{message.content}
+
+
+ )) + )} + + {isLoading && ( +
+
+
+
+ + + +
+ AI is thinking... +
+
+
+ )} + +
+
+ +
+
+ + +
diff --git a/frontend/app/app.scss b/frontend/app/app.scss index 396982ec57..d8e047315e 100644 --- a/frontend/app/app.scss +++ b/frontend/app/app.scss @@ -4,6 +4,10 @@ @use "reset.scss"; @use "theme.scss"; +html { + overflow: hidden; +} + body { display: flex; flex-direction: row; From c6ee5a236f50b6459e8ce5e72aedad8802add2fa Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 12:26:31 -0700 Subject: [PATCH 006/125] fix endpoint --- frontend/app/aipanel/aipanel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 28f41c1418..5ab9819e41 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { cn } from "@/util/util"; +import { getWebServerEndpoint } from "@/util/endpoints"; import { memo, useCallback, useEffect, useRef, useState } from "react"; interface Message { @@ -54,7 +55,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { setIsLoading(true); try { - const response = await fetch("/api/aichat?blockid=ai-panel", { + const response = await fetch(`${getWebServerEndpoint()}/api/aichat?blockid=ai-panel`, { method: "POST", headers: { "Content-Type": "application/json", From c5afee50b687d8f308edf3fde52ae67af23b1ae2 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 12:29:27 -0700 Subject: [PATCH 007/125] move blockid to the non waveai case --- pkg/waveai/usechat.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pkg/waveai/usechat.go b/pkg/waveai/usechat.go index fec5cd712f..006808baed 100644 --- a/pkg/waveai/usechat.go +++ b/pkg/waveai/usechat.go @@ -222,15 +222,6 @@ func HandleAIChat(w http.ResponseWriter, r *http.Request) { return } - // Parse query parameters first - blockId := r.URL.Query().Get("blockid") - presetKey := r.URL.Query().Get("preset") - - if blockId == "" { - http.Error(w, "blockid query parameter is required", http.StatusBadRequest) - return - } - // Parse request body completely before sending any response var req UseChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -251,6 +242,14 @@ func HandleAIChat(w http.ResponseWriter, r *http.Request) { } } else { // Use standard configuration resolution + blockId := r.URL.Query().Get("blockid") + presetKey := r.URL.Query().Get("preset") + + if blockId == "" { + http.Error(w, "blockid query parameter is required", http.StatusBadRequest) + return + } + aiOpts, err = resolveAIConfig(r.Context(), blockId, presetKey, req.Options) if err != nil { http.Error(w, fmt.Sprintf("Configuration error: %v", err), http.StatusInternalServerError) From 1d05961e4f131d6d06c3a7baf5267d57a043c1b0 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 12:43:44 -0700 Subject: [PATCH 008/125] work on errors --- frontend/app/aipanel/aipanel.tsx | 36 +++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 5ab9819e41..2edf3791a3 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -1,8 +1,8 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { cn } from "@/util/util"; import { getWebServerEndpoint } from "@/util/endpoints"; +import { cn } from "@/util/util"; import { memo, useCallback, useEffect, useRef, useState } from "react"; interface Message { @@ -69,7 +69,24 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { }); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + let errorMessage = `HTTP ${response.status}`; + try { + const errorText = await response.text(); + if (errorText) { + // Truncate to max 200 chars and 2 lines + const truncated = errorText.substring(0, 200); + const lines = truncated.split('\n').slice(0, 2); + errorMessage = lines.join('\n'); + } + } catch (e) { + // Fall back to status text if we can't read the response body + if (response.statusText) { + errorMessage += ` ${response.statusText}`; + } + } + const error = new Error(errorMessage); + (error as any).status = response.status; + throw error; } const reader = response.body?.getReader(); @@ -124,12 +141,25 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { } } catch (error) { console.error("Error sending message:", error); + let errorMessage = "Sorry, I encountered an error. Please try again."; + + if (error instanceof Error) { + if (error.message.includes("Failed to fetch")) { + errorMessage = "Connection error. Check your internet connection and try again."; + } else { + // For dev tool, show the actual error message but truncated + const truncated = error.message.substring(0, 200); + const lines = truncated.split('\n').slice(0, 2); + errorMessage = lines.join('\n'); + } + } + setMessages((prev) => [ ...prev, { id: (Date.now() + 1).toString(), role: "assistant", - content: "Sorry, I encountered an error. Please try again.", + content: errorMessage, }, ]); } finally { From 056a1998d4ff7b6c296f97bda3586a9357f38715 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 13:17:14 -0700 Subject: [PATCH 009/125] rewriting for useChat --- frontend/app/aipanel/aipanel.tsx | 183 ++++++------------------------- pkg/waveai/usechat.go | 5 +- 2 files changed, 39 insertions(+), 149 deletions(-) diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 2edf3791a3..12753f8893 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -3,13 +3,9 @@ import { getWebServerEndpoint } from "@/util/endpoints"; import { cn } from "@/util/util"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; - -interface Message { - id: string; - role: "user" | "assistant"; - content: string; -} +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { memo, useEffect, useRef, useState } from "react"; interface AIPanelProps { className?: string; @@ -19,9 +15,16 @@ interface AIPanelProps { const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); - const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); + + const { messages, sendMessage, status } = useChat({ + transport: new DefaultChatTransport({ + api: `${getWebServerEndpoint()}/api/aichat?waveai=1`, + }), + onError: (error) => { + console.error("AI Chat error:", error); + }, + }); const scrollToBottom = () => { const container = messagesContainerRef.current; @@ -34,140 +37,24 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { scrollToBottom(); }, [messages]); - const handleInputChange = useCallback((e: React.ChangeEvent) => { - setInput(e.target.value); - }, []); - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!input.trim() || isLoading) return; - - const userMessage: Message = { - id: Date.now().toString(), - role: "user", - content: input.trim(), - }; - - const newMessages = [...messages, userMessage]; - setMessages(newMessages); - setInput(""); - setIsLoading(true); - - try { - const response = await fetch(`${getWebServerEndpoint()}/api/aichat?blockid=ai-panel`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - messages: newMessages.map((msg) => ({ - role: msg.role, - content: msg.content, - })), - }), - }); - - if (!response.ok) { - let errorMessage = `HTTP ${response.status}`; - try { - const errorText = await response.text(); - if (errorText) { - // Truncate to max 200 chars and 2 lines - const truncated = errorText.substring(0, 200); - const lines = truncated.split('\n').slice(0, 2); - errorMessage = lines.join('\n'); - } - } catch (e) { - // Fall back to status text if we can't read the response body - if (response.statusText) { - errorMessage += ` ${response.statusText}`; - } - } - const error = new Error(errorMessage); - (error as any).status = response.status; - throw error; - } - - const reader = response.body?.getReader(); - if (!reader) { - throw new Error("No response body"); - } - - const assistantMessage: Message = { - id: (Date.now() + 1).toString(), - role: "assistant", - content: "", - }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || status !== "ready") return; - setMessages((prev) => [...prev, assistantMessage]); - - const decoder = new TextDecoder(); - let done = false; - - while (!done) { - const { value, done: streamDone } = await reader.read(); - done = streamDone; - - if (value) { - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split("\n"); - - for (const line of lines) { - if (line.startsWith("data: ")) { - const data = line.slice(6); - if (data === "[DONE]") { - done = true; - break; - } - try { - const parsed = JSON.parse(data); - if (parsed.choices?.[0]?.delta?.content) { - setMessages((prev) => { - const newMessages = [...prev]; - const lastMessage = newMessages[newMessages.length - 1]; - if (lastMessage && lastMessage.role === "assistant") { - lastMessage.content += parsed.choices[0].delta.content; - } - return newMessages; - }); - } - } catch (parseError) { - console.error("Error parsing SSE data:", parseError); - } - } - } - } - } - } catch (error) { - console.error("Error sending message:", error); - let errorMessage = "Sorry, I encountered an error. Please try again."; - - if (error instanceof Error) { - if (error.message.includes("Failed to fetch")) { - errorMessage = "Connection error. Check your internet connection and try again."; - } else { - // For dev tool, show the actual error message but truncated - const truncated = error.message.substring(0, 200); - const lines = truncated.split('\n').slice(0, 2); - errorMessage = lines.join('\n'); - } - } + sendMessage({ text: input.trim() }); + setInput(""); + }; - setMessages((prev) => [ - ...prev, - { - id: (Date.now() + 1).toString(), - role: "assistant", - content: errorMessage, - }, - ]); - } finally { - setIsLoading(false); - } - }, - [input, isLoading, messages] - ); + const getMessageContent = (message: any) => { + if (message.content) return message.content; + if (message.parts) { + return message.parts + .filter((part: any) => part.type === "text") + .map((part: any) => part.text) + .join(""); + } + return ""; + }; return (
{ message.role === "user" ? "bg-accent text-white" : "bg-gray-700 text-gray-100" )} > -
{message.content}
+
{getMessageContent(message)}
)) )} - {isLoading && ( + {status === "streaming" && (
@@ -242,22 +129,22 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
setInput(e.target.value)} placeholder="Ask Wave AI anything..." className="flex-1 bg-gray-700 text-white px-4 py-2 rounded-lg border border-gray-600 focus:border-accent focus:outline-none" - disabled={isLoading} + disabled={status !== "ready"} />
)) diff --git a/package-lock.json b/package-lock.json index dacbbc3d82..d148681a26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "rxjs": "^7.8.2", "shell-quote": "^1.8.3", "sprintf-js": "^1.1.3", + "streamdown": "^1.3.0", "tailwind-merge": "^3.3.1", "throttle-debounce": "^5.0.2", "tinycolor2": "^1.6.0", @@ -8462,6 +8463,73 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.12.2.tgz", + "integrity": "sha512-L1Safnhra3tX/oJK5kYHaWmLEBJi1irASwewzY3taX5ibyXyMkkSDZlq01qigjryOBwrXSdFgTiZ3ryzSNeu7Q==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.12.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.12.2.tgz", + "integrity": "sha512-Nm3/azSsaVS7hk6EwtHEnTythjQfwvrO5tKqMlaH9TwG1P+PNaR8M0EAKZ+GaH2DFwvcr4iSfTveyxMIvXEHMw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.12.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.3" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.12.2.tgz", + "integrity": "sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.12.2", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.12.2.tgz", + "integrity": "sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.12.2" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.12.2.tgz", + "integrity": "sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.12.2" + } + }, + "node_modules/@shikijs/types": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.12.2.tgz", + "integrity": "sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@shuding/opentype.js": { "version": "1.4.0-beta.0", "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", @@ -10805,6 +10873,12 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "license": "MIT" + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -18488,6 +18562,16 @@ "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", "license": "MIT" }, + "node_modules/harden-react-markdown": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/harden-react-markdown/-/harden-react-markdown-1.0.5.tgz", + "integrity": "sha512-uN+PdsmySN4gdczqM0DXzltS4dELSO4U/p/QVLiiypyZMBR1CaewgQTI7ZxArFazBoCk7lGRVvYsyxos0VHGNg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-markdown": ">=9.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -18561,6 +18645,55 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -18688,6 +18821,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -20916,6 +21072,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.542.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", + "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -21427,6 +21592,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -22277,6 +22461,81 @@ ], "license": "MIT" }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-math/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-math/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -24465,6 +24724,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz", + "integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -27989,6 +28265,30 @@ "node": ">=4" } }, + "node_modules/regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", + "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexpu-core": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.3.1.tgz", @@ -28080,6 +28380,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-raw": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", @@ -28896,6 +29215,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", @@ -30191,6 +30526,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shiki": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.12.2.tgz", + "integrity": "sha512-uIrKI+f9IPz1zDT+GMz+0RjzKJiijVr6WDWm9Pe3NNY6QigKCfifCEv9v9R2mDASKKjzjQ2QpFLcxaR3iHSnMA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.12.2", + "@shikijs/engine-javascript": "3.12.2", + "@shikijs/engine-oniguruma": "3.12.2", + "@shikijs/langs": "3.12.2", + "@shikijs/themes": "3.12.2", + "@shikijs/types": "3.12.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -30779,6 +31130,69 @@ "memoizerific": "^1.11.3" } }, + "node_modules/streamdown": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/streamdown/-/streamdown-1.3.0.tgz", + "integrity": "sha512-vFZdoWKUeagzKwGGOcEqkV1fcgXOJOQqrNBor5/hbaAE/e/ULxZoIHHJJd5KEuaSddCM9KuYtIuZi3WSttXTEA==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1", + "harden-react-markdown": "^1.0.5", + "katex": "^0.16.22", + "lucide-react": "^0.542.0", + "marked": "^16.2.1", + "mermaid": "^11.11.0", + "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "shiki": "^3.12.2", + "tailwind-merge": "^3.3.1" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/streamdown/node_modules/marked": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz", + "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/streamdown/node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/streamx": { "version": "2.22.1", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", @@ -32461,6 +32875,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", diff --git a/package.json b/package.json index 0884d6620e..da9795dd27 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "rxjs": "^7.8.2", "shell-quote": "^1.8.3", "sprintf-js": "^1.1.3", + "streamdown": "^1.3.0", "tailwind-merge": "^3.3.1", "throttle-debounce": "^5.0.2", "tinycolor2": "^1.6.0", From 18b36907b257660eac1ca5ea535d05f45b0f6ef3 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 18:59:19 -0700 Subject: [PATCH 011/125] widget access toggle --- frontend/app/aipanel/aimessage.tsx | 61 ++++++++++ frontend/app/aipanel/aipanel.tsx | 144 ++--------------------- frontend/app/aipanel/aipanelheader.tsx | 59 ++++++++++ frontend/app/aipanel/aipanelinput.tsx | 46 ++++++++ frontend/app/aipanel/aipanelmessages.tsx | 80 +++++++++++++ 5 files changed, 254 insertions(+), 136 deletions(-) create mode 100644 frontend/app/aipanel/aimessage.tsx create mode 100644 frontend/app/aipanel/aipanelheader.tsx create mode 100644 frontend/app/aipanel/aipanelinput.tsx create mode 100644 frontend/app/aipanel/aipanelmessages.tsx diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx new file mode 100644 index 0000000000..7e5e6a1fe3 --- /dev/null +++ b/frontend/app/aipanel/aimessage.tsx @@ -0,0 +1,61 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo } from "react"; +import { Streamdown } from "streamdown"; + +interface AIMessageProps { + message: any; + isStreaming: boolean; +} + +export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { + const getMessageContent = (message: any) => { + if (message.content) return message.content; + if (message.parts) { + return message.parts + .filter((part: any) => part.type === "text") + .map((part: any) => part.text) + .join(""); + } + return ""; + }; + + return ( +
+
+ {message.role === "user" ? ( +
{getMessageContent(message)}
+ ) : ( + + {getMessageContent(message)} + + )} +
+
+ ); +}); + +AIMessage.displayName = "AIMessage"; diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 3fba13c7f8..b4878bc6bc 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -5,8 +5,10 @@ import { getWebServerEndpoint } from "@/util/endpoints"; import { cn } from "@/util/util"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; -import { memo, useEffect, useRef, useState } from "react"; -import { Streamdown } from "streamdown"; +import { memo, useState } from "react"; +import { AIPanelHeader } from "./aipanelheader"; +import { AIPanelInput } from "./aipanelinput"; +import { AIPanelMessages } from "./aipanelmessages"; interface AIPanelProps { className?: string; @@ -14,8 +16,6 @@ interface AIPanelProps { } const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); const [input, setInput] = useState(""); const { messages, sendMessage, status } = useChat({ @@ -27,17 +27,6 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { }, }); - const scrollToBottom = () => { - const container = messagesContainerRef.current; - if (container) { - container.scrollTop = container.scrollHeight; - } - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); - const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!input.trim() || status !== "ready") return; @@ -46,137 +35,20 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { setInput(""); }; - const getMessageContent = (message: any) => { - if (message.content) return message.content; - if (message.parts) { - return message.parts - .filter((part: any) => part.type === "text") - .map((part: any) => part.text) - .join(""); - } - return ""; - }; - return (
-
-

- - Wave AI -

- {onClose && ( - - )} -
+
-
- {messages.length === 0 ? ( -
- -

Welcome to Wave AI

-

Start a conversation by typing a message below.

-
- ) : ( - messages.map((message) => ( -
-
- {message.role === "user" ? ( -
- {getMessageContent(message)} -
- ) : ( - - {getMessageContent(message)} - - )} -
-
- )) - )} - - {status === "streaming" && ( -
-
-
-
- - - -
- AI is thinking... -
-
-
- )} - -
-
- -
- - setInput(e.target.value)} - placeholder="Ask Wave AI anything..." - className="flex-1 bg-gray-700 text-white px-4 py-2 rounded-lg border border-gray-600 focus:border-accent focus:outline-none" - disabled={status !== "ready"} - /> - - -
+ +
); diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx new file mode 100644 index 0000000000..d173514c4f --- /dev/null +++ b/frontend/app/aipanel/aipanelheader.tsx @@ -0,0 +1,59 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { memo, useState } from "react"; + +interface AIPanelHeaderProps { + onClose?: () => void; +} + +export const AIPanelHeader = memo(({ onClose }: AIPanelHeaderProps) => { + const [widgetAccess, setWidgetAccess] = useState(true); + + return ( +
+

+ + Wave AI +

+ +
+
+ Widget Access + +
+ + {onClose && ( + + )} +
+
+ ); +}); + +AIPanelHeader.displayName = "AIPanelHeader"; diff --git a/frontend/app/aipanel/aipanelinput.tsx b/frontend/app/aipanel/aipanelinput.tsx new file mode 100644 index 0000000000..d1299b8b7d --- /dev/null +++ b/frontend/app/aipanel/aipanelinput.tsx @@ -0,0 +1,46 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { memo } from "react"; + +interface AIPanelInputProps { + input: string; + setInput: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + status: string; +} + +export const AIPanelInput = memo(({ input, setInput, onSubmit, status }: AIPanelInputProps) => { + return ( +
+
+ setInput(e.target.value)} + placeholder="Ask Wave AI anything..." + className="flex-1 bg-gray-700 text-white px-4 py-2 rounded-lg border border-gray-600 focus:border-accent focus:outline-none" + disabled={status !== "ready"} + /> + +
+
+ ); +}); + +AIPanelInput.displayName = "AIPanelInput"; \ No newline at end of file diff --git a/frontend/app/aipanel/aipanelmessages.tsx b/frontend/app/aipanel/aipanelmessages.tsx new file mode 100644 index 0000000000..e0b4c16c0d --- /dev/null +++ b/frontend/app/aipanel/aipanelmessages.tsx @@ -0,0 +1,80 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { memo, useRef, useEffect } from "react"; +import { AIMessage } from "./aimessage"; + +interface AIPanelMessagesProps { + messages: any[]; + status: string; +} + +export const AIPanelMessages = memo(({ messages, status }: AIPanelMessagesProps) => { + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + + const scrollToBottom = () => { + const container = messagesContainerRef.current; + if (container) { + container.scrollTop = container.scrollHeight; + } + }; + + const hasMessageContent = (message: any) => { + if (message.content) return message.content.length > 0; + if (message.parts) { + return message.parts.some((part: any) => part.type === "text" && part.text && part.text.length > 0); + } + return false; + }; + + const shouldShowThinking = () => { + if (status !== "streaming") return false; + if (messages.length === 0) return true; + const lastMessage = messages[messages.length - 1]; + return lastMessage.role === "assistant" && !hasMessageContent(lastMessage); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + return ( +
+ {messages.length === 0 ? ( +
+ +

Welcome to Wave AI

+

Start a conversation by typing a message below.

+
+ ) : ( + messages.map((message) => ( + + )) + )} + + {shouldShowThinking() && ( +
+
+
+
+ + + +
+ AI is thinking... +
+
+
+ )} + +
+
+ ); +}); + +AIPanelMessages.displayName = "AIPanelMessages"; \ No newline at end of file From fe82c3a579331587e78b63a513570aa63fe26cf3 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 19:08:15 -0700 Subject: [PATCH 012/125] model, some tab atom funcs --- frontend/app/aipanel/aipanel.tsx | 7 ++++-- frontend/app/aipanel/aipanelheader.tsx | 9 ++++--- frontend/app/aipanel/waveai-model.tsx | 12 +++++++++ frontend/app/store/global.ts | 35 ++++++++++++++++++++++++-- 4 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 frontend/app/aipanel/waveai-model.tsx diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index b4878bc6bc..7b312ba3b6 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -5,10 +5,11 @@ import { getWebServerEndpoint } from "@/util/endpoints"; import { cn } from "@/util/util"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; -import { memo, useState } from "react"; +import { memo, useRef, useState } from "react"; import { AIPanelHeader } from "./aipanelheader"; import { AIPanelInput } from "./aipanelinput"; import { AIPanelMessages } from "./aipanelmessages"; +import { WaveAIModel } from "./waveai-model"; interface AIPanelProps { className?: string; @@ -17,6 +18,8 @@ interface AIPanelProps { const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { const [input, setInput] = useState(""); + const modelRef = useRef(new WaveAIModel()); + const model = modelRef.current; const { messages, sendMessage, status } = useChat({ transport: new DefaultChatTransport({ @@ -44,7 +47,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { borderBottomRightRadius: "var(--block-border-radius)", }} > - +
diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx index d173514c4f..ccd169264c 100644 --- a/frontend/app/aipanel/aipanelheader.tsx +++ b/frontend/app/aipanel/aipanelheader.tsx @@ -1,14 +1,17 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { memo, useState } from "react"; +import { useAtom } from "jotai"; +import { memo } from "react"; +import { WaveAIModel } from "./waveai-model"; interface AIPanelHeaderProps { onClose?: () => void; + model: WaveAIModel; } -export const AIPanelHeader = memo(({ onClose }: AIPanelHeaderProps) => { - const [widgetAccess, setWidgetAccess] = useState(true); +export const AIPanelHeader = memo(({ onClose, model }: AIPanelHeaderProps) => { + const [widgetAccess, setWidgetAccess] = useAtom(model.widgetAccess); return (
diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx new file mode 100644 index 0000000000..04f8b922c6 --- /dev/null +++ b/frontend/app/aipanel/waveai-model.tsx @@ -0,0 +1,12 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as jotai from "jotai"; + +export class WaveAIModel { + widgetAccess: jotai.PrimitiveAtom = jotai.atom(true); + + constructor() { + // Model initialization + } +} \ No newline at end of file diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 5db71d62a5..f2b6876a84 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -31,6 +31,8 @@ let globalEnvironment: "electron" | "renderer"; const blockComponentModelMap = new Map(); const Counters = new Map(); const ConnStatusMapAtom = atom(new Map>()); +const blockAtomCache = new Map>>(); +const tabAtomCache = new Map>>(); type GlobalInitOptions = { tabId: string; @@ -249,6 +251,26 @@ function useBlockMetaKeyAtom(blockId: string, key: T): return useAtomValue(getBlockMetaKeyAtom(blockId, key)); } +function getTabMetaKeyAtom(tabId: string, key: T): Atom { + const tabCache = getSingleTabAtomCache(tabId); + const metaAtomName = "#meta-" + key; + let metaAtom = tabCache.get(metaAtomName); + if (metaAtom != null) { + return metaAtom; + } + metaAtom = atom((get) => { + let tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); + let tabData = get(tabAtom); + return tabData?.meta?.[key]; + }); + tabCache.set(metaAtomName, metaAtom); + return metaAtom; +} + +function useTabMetaKeyAtom(tabId: string, key: T): MetaType[T] { + return useAtomValue(getTabMetaKeyAtom(tabId, key)); +} + function getConnConfigKeyAtom(connName: string, key: T): Atom { let connCache = getSingleConnAtomCache(connName); const keyAtomName = "#conn-" + key; @@ -335,8 +357,6 @@ function getSettingsPrefixAtom(prefix: string): Atom { return settingsPrefixAtom; } -const blockAtomCache = new Map>>(); - function getSingleBlockAtomCache(blockId: string): Map> { let blockCache = blockAtomCache.get(blockId); if (blockCache == null) { @@ -355,6 +375,15 @@ function getSingleConnAtomCache(connName: string): Map> { return blockCache; } +function getSingleTabAtomCache(tabId: string): Map> { + let tabCache = tabAtomCache.get(tabId); + if (tabCache == null) { + tabCache = new Map>(); + tabAtomCache.set(tabId, tabCache); + } + return tabCache; +} + function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): Atom { const blockCache = getSingleBlockAtomCache(blockId); let atom = blockCache.get(name); @@ -779,6 +808,7 @@ export { getOverrideConfigAtom, getSettingsKeyAtom, getSettingsPrefixAtom, + getTabMetaKeyAtom, getUserName, globalStore, initGlobal, @@ -806,5 +836,6 @@ export { useBlockMetaKeyAtom, useOverrideConfigAtom, useSettingsKeyAtom, + useTabMetaKeyAtom, WOS, }; From 88b8d5fb8cfcb9a881ad466048d779f1acc8ef78 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 16 Sep 2025 19:42:28 -0700 Subject: [PATCH 013/125] breakpoints --- aiprompts/tailwind-container-queries.md | 38 +++++++++++++++++++ frontend/app/aipanel/aipanelheader.tsx | 13 ++++--- .../app/workspace/workspace-layout-model.ts | 10 ++--- frontend/tailwindsetup.css | 9 ++++- 4 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 aiprompts/tailwind-container-queries.md diff --git a/aiprompts/tailwind-container-queries.md b/aiprompts/tailwind-container-queries.md new file mode 100644 index 0000000000..007cc080cf --- /dev/null +++ b/aiprompts/tailwind-container-queries.md @@ -0,0 +1,38 @@ +### Tailwind v4 Container Queries (Quick Overview) + +- **Viewport breakpoints**: `sm:`, `md:`, `lg:`, etc. → respond to **screen size**. +- **Container queries**: `@sm:`, `@md:`, etc. → respond to **parent element size**. + +#### Enable + +No plugin needed in **v4** (built-in). +In v3: install `@tailwindcss/container-queries`. + +#### Usage + +```html + +``` + +- `@container` marks the parent. +- `@sm:` / `@md:` refer to **container width**, not viewport. + +#### Notes + +- Based on native CSS container queries (well supported in modern browsers). +- Breakpoints for container queries reuse Tailwind’s `sm`, `md`, `lg`, etc. scales. +- Safe for modern webapps; no IE/legacy support. + +we have special breakpoints set up for panels: + + --container-xs: 300px; + --container-xxs: 200px; + --container-tiny: 120px; + +since often sm, md, and lg are too big for panels. + +so to use you'd do: + +@xs:ml-4 diff --git a/frontend/app/aipanel/aipanelheader.tsx b/frontend/app/aipanel/aipanelheader.tsx index ccd169264c..70ed4434f7 100644 --- a/frontend/app/aipanel/aipanelheader.tsx +++ b/frontend/app/aipanel/aipanelheader.tsx @@ -14,15 +14,16 @@ export const AIPanelHeader = memo(({ onClose, model }: AIPanelHeaderProps) => { const [widgetAccess, setWidgetAccess] = useAtom(model.widgetAccess); return ( -
-

+
+

Wave AI

-
-
- Widget Access +
+
+ Context + Widget Context + +
+ {file.previewUrl ? ( +
+ {file.name} +
+ ) : ( +
+ +
+ )} + +
+ {file.name} +
+
{formatFileSize(file.size)}
+
+
+ ))} +
+
+ ); +}); + +AIDroppedFiles.displayName = "AIDroppedFiles"; diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index ee90e78d62..43809417fb 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -6,6 +6,8 @@ import { cn } from "@/util/util"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { memo, useRef, useState } from "react"; +import { isAcceptableFile } from "./ai-utils"; +import { AIDroppedFiles } from "./aidroppedfiles"; import { AIPanelHeader } from "./aipanelheader"; import { AIPanelInput } from "./aipanelinput"; import { AIPanelMessages } from "./aipanelmessages"; @@ -18,6 +20,7 @@ interface AIPanelProps { const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { const [input, setInput] = useState(""); + const [isDragOver, setIsDragOver] = useState(false); const modelRef = useRef(new WaveAIModel()); const model = modelRef.current; const chatIdRef = useRef(crypto.randomUUID()); @@ -51,19 +54,82 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { setInput(""); }; + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Only set drag over to false if we're actually leaving the drop zone + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { + setIsDragOver(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = Array.from(e.dataTransfer.files); + const acceptableFiles = files.filter(isAcceptableFile); + + acceptableFiles.forEach(file => { + model.addFile(file); + }); + + if (acceptableFiles.length < files.length) { + console.warn(`${files.length - acceptableFiles.length} files were rejected due to unsupported file types`); + } + }; + return (
+ {isDragOver && ( +
+
+ +
Drop files here
+
Images, PDFs, and text/code files supported
+
+
+ )}
+
diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 04f8b922c6..453b9480ed 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -2,11 +2,68 @@ // SPDX-License-Identifier: Apache-2.0 import * as jotai from "jotai"; +import { globalStore } from "@/app/store/jotaiStore"; + +export interface DroppedFile { + id: string; + file: File; + name: string; + type: string; + size: number; + previewUrl?: string; +} export class WaveAIModel { widgetAccess: jotai.PrimitiveAtom = jotai.atom(true); + droppedFiles: jotai.PrimitiveAtom = jotai.atom([]); constructor() { // Model initialization } + + addFile(file: File): DroppedFile { + const droppedFile: DroppedFile = { + id: crypto.randomUUID(), + file, + name: file.name, + type: file.type, + size: file.size, + }; + + // Create preview URL for images + if (file.type.startsWith('image/')) { + droppedFile.previewUrl = URL.createObjectURL(file); + } + + const currentFiles = globalStore.get(this.droppedFiles); + globalStore.set(this.droppedFiles, [...currentFiles, droppedFile]); + + return droppedFile; + } + + removeFile(fileId: string) { + const currentFiles = globalStore.get(this.droppedFiles); + const fileToRemove = currentFiles.find(f => f.id === fileId); + + // Cleanup preview URL if it exists + if (fileToRemove?.previewUrl) { + URL.revokeObjectURL(fileToRemove.previewUrl); + } + + const updatedFiles = currentFiles.filter(f => f.id !== fileId); + globalStore.set(this.droppedFiles, updatedFiles); + } + + clearFiles() { + const currentFiles = globalStore.get(this.droppedFiles); + + // Cleanup all preview URLs + currentFiles.forEach(file => { + if (file.previewUrl) { + URL.revokeObjectURL(file.previewUrl); + } + }); + + globalStore.set(this.droppedFiles, []); + } } \ No newline at end of file From 43f3c132022d174878acf2a42a9ef5fe385ea050 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 21 Sep 2025 10:06:21 -0700 Subject: [PATCH 043/125] expandable input --- frontend/app/aipanel/aipanel.tsx | 8 +++---- frontend/app/aipanel/aipanelinput.tsx | 31 +++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 43809417fb..4c8f290f19 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -71,12 +71,12 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - + // Only set drag over to false if we're actually leaving the drop zone const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const x = e.clientX; const y = e.clientY; - + if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { setIsDragOver(false); } @@ -90,7 +90,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { const files = Array.from(e.dataTransfer.files); const acceptableFiles = files.filter(isAcceptableFile); - acceptableFiles.forEach(file => { + acceptableFiles.forEach((file) => { model.addFile(file); }); @@ -117,7 +117,7 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => { onDrop={handleDrop} > {isDragOver && ( -
+
Drop files here
diff --git a/frontend/app/aipanel/aipanelinput.tsx b/frontend/app/aipanel/aipanelinput.tsx index 6e13f593b4..73a4f14f1c 100644 --- a/frontend/app/aipanel/aipanelinput.tsx +++ b/frontend/app/aipanel/aipanelinput.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { cn } from "@/util/util"; -import { memo } from "react"; +import { memo, useRef, useEffect } from "react"; interface AIPanelInputProps { input: string; @@ -12,15 +12,38 @@ interface AIPanelInputProps { } export const AIPanelInput = memo(({ input, setInput, onSubmit, status }: AIPanelInputProps) => { + const textareaRef = useRef(null); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onSubmit(e as any); + } + }; + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + textarea.style.height = "auto"; + const scrollHeight = textarea.scrollHeight; + const maxHeight = 6 * 24; // 6 lines * approximate line height + textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; + }, [input]); + return (
- - +