diff --git a/aiprompts/openai-request.md b/aiprompts/openai-request.md new file mode 100644 index 0000000000..f67ac0847a --- /dev/null +++ b/aiprompts/openai-request.md @@ -0,0 +1,201 @@ +# OpenAI Request Input Field Structure (On-the-Wire Format) + +This document describes the actual JSON structure sent to the OpenAI API in the `input` field of [`OpenAIRequest`](../pkg/aiusechat/openai/openai-convertmessage.go:111). + +## Overview + +The `input` field is a JSON array containing one of three object types: + +1. **Messages** (user/assistant) - `OpenAIMessage` objects +2. **Function Calls** (tool invocations) - `OpenAIFunctionCallInput` objects +3. **Function Call Results** (tool outputs) - `OpenAIFunctionCallOutputInput` objects + +These are converted from [`OpenAIChatMessage`](../pkg/aiusechat/openai/openai-backend.go:46-52) internal format and cleaned before transmission ([see lines 485-494](../pkg/aiusechat/openai/openai-backend.go:485-494)). + +## 1. Message Objects (User/Assistant) + +User and assistant messages sent as [`OpenAIMessage`](../pkg/aiusechat/openai/openai-backend.go:54-57): + +```json +{ + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Hello, analyze this image" + }, + { + "type": "input_image", + "image_url": "data:image/png;base64,iVBORw0KG..." + } + ] +} +``` + +**Key Points:** +- `role`: Always `"user"` or `"assistant"` +- `content`: **Always an array** of content blocks (never a plain string) + +### Content Block Types + +#### Text Block +```json +{ + "type": "input_text", + "text": "message content here" +} +``` + +#### Image Block +```json +{ + "type": "input_image", + "image_url": "data:image/png;base64,..." +} +``` +- Can be a data URL or https:// URL +- `filename` field is **removed** during cleaning + +#### PDF File Block +```json +{ + "type": "input_file", + "file_data": "JVBERi0xLjQKJeLjz9M...", + "filename": "document.pdf" +} +``` +- `file_data`: Base64-encoded PDF content + +#### Function Call Block (in assistant messages) +```json +{ + "type": "function_call", + "call_id": "call_abc123", + "name": "search_files", + "arguments": {"query": "test"} +} +``` + +## 2. Function Call Objects (Tool Invocations) + +Tool calls from the model sent as [`OpenAIFunctionCallInput`](../pkg/aiusechat/openai/openai-backend.go:59-67): + +```json +{ + "type": "function_call", + "call_id": "call_abc123", + "name": "search_files", + "arguments": "{\"query\":\"test\",\"path\":\"./src\"}" +} +``` + +**Key Points:** +- `type`: Always `"function_call"` +- `call_id`: Unique identifier generated by model +- `name`: Function name to execute +- `arguments`: JSON-encoded string of parameters +- `status`: Optional (`"in_progress"`, `"completed"`, `"incomplete"`) +- Internal `toolusedata` field is **removed** during cleaning + +## 3. Function Call Output Objects (Tool Results) + +Tool execution results sent as [`OpenAIFunctionCallOutputInput`](../pkg/aiusechat/openai/openai-backend.go:69-75): + +```json +{ + "type": "function_call_output", + "call_id": "call_abc123", + "output": "Found 3 files matching query" +} +``` + +**Key Points:** +- `type`: Always `"function_call_output"` +- `call_id`: Must match the original function call's `call_id` +- `output`: Can be text, image array, or error object + +### Output Value Types + +#### Text Output +```json +{ + "type": "function_call_output", + "call_id": "call_abc123", + "output": "Result text here" +} +``` + +#### Image Output +```json +{ + "type": "function_call_output", + "call_id": "call_abc123", + "output": [ + { + "type": "input_image", + "image_url": "data:image/png;base64,..." + } + ] +} +``` + +#### Error Output +```json +{ + "type": "function_call_output", + "call_id": "call_abc123", + "output": "{\"ok\":\"false\",\"error\":\"File not found\"}" +} +``` +- Error output is a JSON-encoded string containing `ok` and `error` fields + +## Complete Example + +```json +{ + "model": "gpt-4o", + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "What files are in src/?" + } + ] + }, + { + "type": "function_call", + "call_id": "call_xyz789", + "name": "list_files", + "arguments": "{\"path\":\"src/\"}" + }, + { + "type": "function_call_output", + "call_id": "call_xyz789", + "output": "main.go\nutil.go\nconfig.go" + }, + { + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "The src/ directory contains 3 files: main.go, util.go, and config.go" + } + ] + } + ], + "stream": true, + "max_output_tokens": 4096 +} +``` + +## Cleaning Process + +Before transmission, internal fields are removed ([cleanup code](../pkg/aiusechat/openai/openai-backend.go:485-494)): + +- **Messages**: `previewurl` field removed, `filename` removed from `input_image` blocks +- **Function Calls**: `toolusedata` field removed +- **Function Outputs**: Sent as-is (no cleaning needed) + +This ensures the API receives only the fields it expects. \ No newline at end of file diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 5bdf0d720b..54b7622fd0 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -9,9 +9,10 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; +import { BuilderConfigDataTab } from "@/builder/tabs/builder-configdatatab"; import { BuilderFilesTab, DeleteFileModal, RenameFileModal } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; -import { BuilderEnvTab } from "@/builder/tabs/builder-secrettab"; +import { BuilderSecretTab } from "@/builder/tabs/builder-secrettab"; import { builderAppHasSelection } from "@/builder/utils/builder-focus-utils"; import { ErrorBoundary } from "@/element/errorboundary"; import { atoms } from "@/store/global"; @@ -310,6 +311,13 @@ const BuilderAppPanel = memo(() => { isAppFocused={isAppFocused} onClick={() => handleTabClick("code")} /> + handleTabClick("configdata")} + /> {
- + + +
+
+ +
diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts index 9e9e771d00..f05e932aa6 100644 --- a/frontend/builder/store/builder-apppanel-model.ts +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -10,7 +10,7 @@ import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; import { debounce } from "throttle-debounce"; -export type TabType = "preview" | "files" | "code" | "secrets"; +export type TabType = "preview" | "files" | "code" | "secrets" | "configdata"; export type EnvVar = { name: string; @@ -121,6 +121,16 @@ export class BuilderAppPanelModel { } } + updateSecretBindings(newBindings: { [key: string]: string }) { + const currentStatus = globalStore.get(this.builderStatusAtom); + if (currentStatus) { + globalStore.set(this.builderStatusAtom, { + ...currentStatus, + secretbindings: newBindings, + }); + } + } + async loadEnvVars(builderId: string) { try { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { @@ -215,7 +225,7 @@ export class BuilderAppPanelModel { async switchBuilderApp() { const builderId = globalStore.get(atoms.builderId); try { - await RpcApi.StopBuilderCommand(TabRpcClient, builderId); + await RpcApi.DeleteBuilderCommand(TabRpcClient, builderId); await new Promise((resolve) => setTimeout(resolve, 500)); await RpcApi.SetRTInfoCommand(TabRpcClient, { oref: WOS.makeORef("builder", builderId), diff --git a/frontend/builder/tabs/builder-configdatatab.tsx b/frontend/builder/tabs/builder-configdatatab.tsx new file mode 100644 index 0000000000..51a641dddd --- /dev/null +++ b/frontend/builder/tabs/builder-configdatatab.tsx @@ -0,0 +1,225 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; +import { CopyButton } from "@/element/copybutton"; +import { atoms } from "@/store/global"; +import { cn } from "@/util/util"; +import { useAtomValue } from "jotai"; +import { memo, useCallback, useEffect, useState } from "react"; + +const NotRunningView = memo(() => { + return ( +
+
+ +
+

App Not Running

+

+ The tsunami app must be running to view config and data. Please start the app from the Preview + tab first. +

+
+
+
+ ); +}); + +NotRunningView.displayName = "NotRunningView"; + +const ErrorView = memo(({ errorMsg }: { errorMsg: string }) => { + return ( +
+
+ +
+

Error Loading Data

+
+
{errorMsg}
+
+
+
+
+ ); +}); + +ErrorView.displayName = "ErrorView"; + +const LoadingView = memo(() => { + return ( +
+
+ +

Loading data...

+
+
+ ); +}); + +LoadingView.displayName = "LoadingView"; + +type ConfigDataState = { + config: any; + data: any; + error: string | null; + isLoading: boolean; +}; + +const BuilderConfigDataTab = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const builderStatus = useAtomValue(model.builderStatusAtom); + const builderId = useAtomValue(atoms.builderId); + const [state, setState] = useState({ + config: null, + data: null, + error: null, + isLoading: false, + }); + + const isRunning = builderStatus?.status === "running" && builderStatus?.port && builderStatus.port !== 0; + + const fetchData = useCallback(async () => { + if (!isRunning || !builderStatus?.port) { + return; + } + + setState((prev) => ({ ...prev, isLoading: true, error: null })); + + try { + const baseUrl = `http://localhost:${builderStatus.port}`; + + const [configResponse, dataResponse] = await Promise.all([ + fetch(`${baseUrl}/api/config`), + fetch(`${baseUrl}/api/data`), + ]); + + if (!configResponse.ok) { + throw new Error(`Failed to fetch config: ${configResponse.statusText}`); + } + if (!dataResponse.ok) { + throw new Error(`Failed to fetch data: ${dataResponse.statusText}`); + } + + const config = await configResponse.json(); + const data = await dataResponse.json(); + + setState({ + config, + data, + error: null, + isLoading: false, + }); + } catch (err) { + setState({ + config: null, + data: null, + error: err instanceof Error ? err.message : String(err), + isLoading: false, + }); + } + }, [isRunning, builderStatus?.port]); + + const handleRefresh = useCallback(async () => { + setState({ + config: null, + data: null, + error: null, + isLoading: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + await fetchData(); + }, [fetchData]); + + const handleCopyConfig = useCallback(() => { + if (state.config) { + navigator.clipboard.writeText(JSON.stringify(state.config, null, 2)); + } + }, [state.config]); + + const handleCopyData = useCallback(() => { + if (state.data) { + navigator.clipboard.writeText(JSON.stringify(state.data, null, 2)); + } + }, [state.data]); + + useEffect(() => { + if (isRunning) { + fetchData(); + } else { + setState({ + config: null, + data: null, + error: null, + isLoading: false, + }); + } + }, [isRunning, fetchData]); + + if (!isRunning) { + return ; + } + + if (state.isLoading) { + return ; + } + + if (state.error) { + return ; + } + + if (!state.config && !state.data) { + return ; + } + + return ( +
+
+

Config & Data

+ +
+
+
+
+
+

+ + Config +

+ +
+
+
+                                {JSON.stringify(state.config, null, 2)}
+                            
+
+
+
+
+

+ + Data +

+ +
+
+
+                                {JSON.stringify(state.data, null, 2)}
+                            
+
+
+
+
+
+ ); +}); + +BuilderConfigDataTab.displayName = "BuilderConfigDataTab"; + +export { BuilderConfigDataTab }; \ No newline at end of file diff --git a/frontend/builder/tabs/builder-secrettab.tsx b/frontend/builder/tabs/builder-secrettab.tsx index dbc2d215c0..5e3542f9b0 100644 --- a/frontend/builder/tabs/builder-secrettab.tsx +++ b/frontend/builder/tabs/builder-secrettab.tsx @@ -25,14 +25,16 @@ type SecretRowProps = { const SecretRow = memo(({ secretName, secretMeta, currentBinding, availableSecrets, onMapDefault, onSetAndMapDefault }: SecretRowProps) => { const isMapped = currentBinding.trim().length > 0; const isValid = isMapped && availableSecrets.includes(currentBinding); + const isInvalid = isMapped && !isValid; const hasMatchingSecret = availableSecrets.includes(secretName); return (
- +
{!isMapped && } - {isMapped && isValid && } + {isInvalid && } + {isValid && }
@@ -157,14 +159,11 @@ const SetSecretDialog = memo(({ secretName, onSetAndMap }: SetSecretDialogProps) SetSecretDialog.displayName = "SetSecretDialog"; -const BuilderEnvTab = memo(() => { +const BuilderSecretTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); const builderStatus = useAtomValue(model.builderStatusAtom); const error = useAtomValue(model.errorAtom); - const [localBindings, setLocalBindings] = useState<{ [key: string]: string }>({}); - const [isDirty, setIsDirty] = useState(false); - const [isSaving, setIsSaving] = useState(false); const [availableSecrets, setAvailableSecrets] = useState([]); const manifest = builderStatus?.manifest; @@ -183,10 +182,14 @@ const BuilderEnvTab = memo(() => { fetchSecrets(); }, []); - if (!localBindings || Object.keys(localBindings).length === 0) { - if (Object.keys(secretBindings).length > 0) { - setLocalBindings({ ...secretBindings }); - } + if (!builderStatus || !manifest) { + return ( +
+
+ App manifest not available. Secrets will be shown once the app builds successfully. +
+
+ ); } const sortedSecretEntries = Object.entries(secrets).sort(([nameA, metaA], [nameB, metaB]) => { @@ -195,34 +198,24 @@ const BuilderEnvTab = memo(() => { return nameA.localeCompare(nameB); }); - const handleBindingChange = (secretName: string, binding: string) => { - setLocalBindings((prev) => ({ ...prev, [secretName]: binding })); - setIsDirty(true); - }; - - const handleSave = async () => { - setIsSaving(true); + const handleMapDefault = async (secretName: string) => { + const newBindings = { ...secretBindings, [secretName]: secretName }; + try { const appId = globalStore.get(atoms.builderAppId); await RpcApi.WriteAppSecretBindingsCommand(TabRpcClient, { appid: appId, - bindings: localBindings, + bindings: newBindings, }); - setIsDirty(false); + model.updateSecretBindings(newBindings); globalStore.set(model.errorAtom, ""); + model.restartBuilder(); } catch (err) { console.error("Failed to save secret bindings:", err); globalStore.set(model.errorAtom, `Failed to save secret bindings: ${err.message || "Unknown error"}`); - } finally { - setIsSaving(false); } }; - const handleMapDefault = (secretName: string) => { - setLocalBindings((prev) => ({ ...prev, [secretName]: secretName })); - setIsDirty(true); - }; - const handleSetAndMapDefault = (secretName: string) => { modalsModel.pushModal("SetSecretDialog", { secretName, onSetAndMap: handleSetAndMap }); }; @@ -230,30 +223,35 @@ const BuilderEnvTab = memo(() => { const handleSetAndMap = async (secretName: string, secretValue: string) => { await RpcApi.SetSecretsCommand(TabRpcClient, { [secretName]: secretValue }); setAvailableSecrets((prev) => [...prev, secretName]); - setLocalBindings((prev) => ({ ...prev, [secretName]: secretName })); - setIsDirty(true); + + const newBindings = { ...secretBindings, [secretName]: secretName }; + + try { + const appId = globalStore.get(atoms.builderAppId); + await RpcApi.WriteAppSecretBindingsCommand(TabRpcClient, { + appid: appId, + bindings: newBindings, + }); + model.updateSecretBindings(newBindings); + globalStore.set(model.errorAtom, ""); + model.restartBuilder(); + } catch (err) { + console.error("Failed to save secret bindings:", err); + globalStore.set(model.errorAtom, `Failed to save secret bindings: ${err.message || "Unknown error"}`); + } }; const allRequiredBound = - sortedSecretEntries.filter(([_, meta]) => !meta.optional).every(([name]) => localBindings[name]?.trim()) || + sortedSecretEntries.filter(([_, meta]) => !meta.optional).every(([name]) => secretBindings[name]?.trim()) || false; return (
-
-

Secret Bindings

- -
+

Secret Bindings

Map app secrets to Wave secret store names. Required secrets must be bound before the app can run - successfully. + successfully. Changes are saved automatically.
{!allRequiredBound && ( @@ -276,7 +274,7 @@ const BuilderEnvTab = memo(() => { key={secretName} secretName={secretName} secretMeta={secretMeta} - currentBinding={localBindings[secretName] || ""} + currentBinding={secretBindings[secretName] || ""} availableSecrets={availableSecrets} onMapDefault={handleMapDefault} onSetAndMapDefault={handleSetAndMapDefault} @@ -289,6 +287,6 @@ const BuilderEnvTab = memo(() => { ); }); -BuilderEnvTab.displayName = "BuilderEnvTab"; +BuilderSecretTab.displayName = "BuilderSecretTab"; -export { BuilderEnvTab, SetSecretDialog }; +export { BuilderSecretTab, SetSecretDialog }; diff --git a/package-lock.json b/package-lock.json index 3a74848844..c0827c1651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.12.3", + "version": "0.12.4-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.12.3", + "version": "0.12.4-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/aiusechat/anthropic/anthropic-backend.go b/pkg/aiusechat/anthropic/anthropic-backend.go index c2eb3a519d..345d30bcdd 100644 --- a/pkg/aiusechat/anthropic/anthropic-backend.go +++ b/pkg/aiusechat/anthropic/anthropic-backend.go @@ -46,6 +46,10 @@ func (m *anthropicChatMessage) GetMessageId() string { return m.MessageId } +func (m *anthropicChatMessage) GetRole() string { + return m.Role +} + func (m *anthropicChatMessage) GetUsage() *uctypes.AIUsage { if m.Usage == nil { return nil diff --git a/pkg/aiusechat/chatstore/chatstore.go b/pkg/aiusechat/chatstore/chatstore.go index 514bedecdf..a5badcca9b 100644 --- a/pkg/aiusechat/chatstore/chatstore.go +++ b/pkg/aiusechat/chatstore/chatstore.go @@ -48,6 +48,24 @@ func (cs *ChatStore) Delete(chatId string) { delete(cs.chats, chatId) } +func (cs *ChatStore) CountUserMessages(chatId string) int { + cs.lock.Lock() + defer cs.lock.Unlock() + + chat := cs.chats[chatId] + if chat == nil { + return 0 + } + + count := 0 + for _, msg := range chat.NativeMessages { + if msg.GetRole() == "user" { + count++ + } + } + return count +} + func (cs *ChatStore) PostMessage(chatId string, aiOpts *uctypes.AIOptsType, message uctypes.GenAIMessage) error { cs.lock.Lock() defer cs.lock.Unlock() diff --git a/pkg/aiusechat/openai/openai-backend.go b/pkg/aiusechat/openai/openai-backend.go index 541c138b84..cced0dd06d 100644 --- a/pkg/aiusechat/openai/openai-backend.go +++ b/pkg/aiusechat/openai/openai-backend.go @@ -138,6 +138,13 @@ func (m *OpenAIChatMessage) GetMessageId() string { return m.MessageId } +func (m *OpenAIChatMessage) GetRole() string { + if m.Message != nil { + return m.Message.Role + } + return "" +} + func (m *OpenAIChatMessage) GetUsage() *uctypes.AIUsage { if m.Usage == nil { return nil diff --git a/pkg/aiusechat/openai/openai-convertmessage.go b/pkg/aiusechat/openai/openai-convertmessage.go index f0ac28eeb8..83cfc77700 100644 --- a/pkg/aiusechat/openai/openai-convertmessage.go +++ b/pkg/aiusechat/openai/openai-convertmessage.go @@ -292,6 +292,10 @@ func buildOpenAIHTTPRequest(ctx context.Context, inputs []any, chatOpts uctypes. if chatOpts.ClientId != "" { req.Header.Set("X-Wave-ClientId", chatOpts.ClientId) } + if chatOpts.ChatId != "" { + req.Header.Set("X-Wave-ChatId", chatOpts.ChatId) + } + req.Header.Set("X-Wave-Version", wavebase.WaveVersion) req.Header.Set("X-Wave-APIType", "openai") req.Header.Set("X-Wave-RequestType", chatOpts.GetWaveRequestType()) diff --git a/pkg/aiusechat/uctypes/usechat-types.go b/pkg/aiusechat/uctypes/usechat-types.go index 859b7218ce..fc825fbdc7 100644 --- a/pkg/aiusechat/uctypes/usechat-types.go +++ b/pkg/aiusechat/uctypes/usechat-types.go @@ -244,6 +244,8 @@ type AIUsage struct { } type AIMetrics struct { + ChatId string `json:"chatid"` + StepNum int `json:"stepnum"` Usage AIUsage `json:"usage"` RequestCount int `json:"requestcount"` ToolUseCount int `json:"toolusecount"` @@ -275,6 +277,7 @@ type AIFunctionCallInput struct { type GenAIMessage interface { GetMessageId() string GetUsage() *AIUsage + GetRole() string } const ( diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index d15872c66c..5263ad28f7 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -386,7 +386,10 @@ func RunAIChat(ctx context.Context, sseHandler *sse.SSEHandlerCh, backend UseCha } defer activeChats.Delete(chatOpts.ChatId) + stepNum := chatstore.DefaultChatStore.CountUserMessages(chatOpts.ChatId) metrics := &uctypes.AIMetrics{ + ChatId: chatOpts.ChatId, + StepNum: stepNum, Usage: uctypes.AIUsage{ APIType: chatOpts.Config.APIType, Model: chatOpts.Config.Model, @@ -572,6 +575,8 @@ func sendAIMetricsTelemetry(ctx context.Context, metrics *uctypes.AIMetrics) { event := telemetrydata.MakeTEvent("waveai:post", telemetrydata.TEventProps{ WaveAIAPIType: metrics.Usage.APIType, WaveAIModel: metrics.Usage.Model, + WaveAIChatId: metrics.ChatId, + WaveAIStepNum: metrics.StepNum, WaveAIInputTokens: metrics.Usage.InputTokens, WaveAIOutputTokens: metrics.Usage.OutputTokens, WaveAINativeWebSearchCount: metrics.Usage.NativeWebSearchCount, diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go index 5b775745b5..0452ae4661 100644 --- a/pkg/blockcontroller/tsunamicontroller.go +++ b/pkg/blockcontroller/tsunamicontroller.go @@ -292,6 +292,10 @@ func runTsunamiAppBinary(ctx context.Context, appBinPath string, appPath string, cmd := exec.Command(appBinPath) cmd.Env = append(os.Environ(), "TSUNAMI_CLOSEONSTDIN=1") + if wavebase.IsDevMode() { + cmd.Env = append(cmd.Env, "TSUNAMI_CORS="+tsunamiutil.DevModeCorsOrigins) + } + // Add TsunamiEnv variables if configured tsunamiEnv := blockMeta.GetMap(waveobj.MetaKey_TsunamiEnv) for key, value := range tsunamiEnv { diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 79a63474ba..5433528675 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -17,6 +17,7 @@ import ( "time" "github.com/wavetermdev/waveterm/pkg/panichandler" + "github.com/wavetermdev/waveterm/pkg/tsunamiutil" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/waveapputil" @@ -324,6 +325,10 @@ func (bc *BuilderController) runBuilderApp(ctx context.Context, appId string, ap cmd := exec.Command(appBinPath) cmd.Env = append(os.Environ(), "TSUNAMI_CLOSEONSTDIN=1") + if wavebase.IsDevMode() { + cmd.Env = append(cmd.Env, "TSUNAMI_CORS="+tsunamiutil.DevModeCorsOrigins) + } + for key, value := range builderEnv { cmd.Env = append(cmd.Env, key+"="+value) } diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index 282d5e48eb..79ec3d6941 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -127,6 +127,8 @@ type TEventProps struct { WaveAIAPIType string `json:"waveai:apitype,omitempty"` WaveAIModel string `json:"waveai:model,omitempty"` + WaveAIChatId string `json:"waveai:chatid,omitempty"` + WaveAIStepNum int `json:"waveai:stepnum,omitempty"` WaveAIInputTokens int `json:"waveai:inputtokens,omitempty"` WaveAIOutputTokens int `json:"waveai:outputtokens,omitempty"` WaveAINativeWebSearchCount int `json:"waveai:nativewebsearchcount,omitempty"` diff --git a/pkg/tsunamiutil/tsunamiutil.go b/pkg/tsunamiutil/tsunamiutil.go index 85657efb8c..02547f9055 100644 --- a/pkg/tsunamiutil/tsunamiutil.go +++ b/pkg/tsunamiutil/tsunamiutil.go @@ -11,6 +11,8 @@ import ( "github.com/wavetermdev/waveterm/pkg/wavebase" ) +const DevModeCorsOrigins = "http://localhost:5173,http://localhost:5174" + func GetTsunamiAppCachePath(scope string, appName string, osArch string) (string, error) { cachesDir := wavebase.GetWaveCachesDir() tsunamiCacheDir := filepath.Join(cachesDir, "tsunami-build-cache") diff --git a/tsunami/engine/serverhandlers.go b/tsunami/engine/serverhandlers.go index 43f4b47ebe..5c5325610c 100644 --- a/tsunami/engine/serverhandlers.go +++ b/tsunami/engine/serverhandlers.go @@ -50,6 +50,31 @@ func setNoCacheHeaders(w http.ResponseWriter) { w.Header().Set("Expires", "0") } +func setCORSHeaders(w http.ResponseWriter, r *http.Request) bool { + corsOriginsStr := os.Getenv("TSUNAMI_CORS") + if corsOriginsStr == "" { + return false + } + + origin := r.Header.Get("Origin") + if origin == "" { + return false + } + + allowedOrigins := strings.Split(corsOriginsStr, ",") + for _, allowedOrigin := range allowedOrigins { + allowedOrigin = strings.TrimSpace(allowedOrigin) + if allowedOrigin == origin { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Credentials", "true") + return true + } + } + return false +} + func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) { mux.HandleFunc("/api/render", h.handleRender) mux.HandleFunc("/api/updates", h.handleSSE) @@ -200,8 +225,14 @@ func (h *httpHandlers) handleData(w http.ResponseWriter, r *http.Request) { } }() + setCORSHeaders(w, r) setNoCacheHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -224,8 +255,14 @@ func (h *httpHandlers) handleConfig(w http.ResponseWriter, r *http.Request) { } }() + setCORSHeaders(w, r) setNoCacheHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + switch r.Method { case http.MethodGet: h.handleConfigGet(w, r) @@ -293,8 +330,14 @@ func (h *httpHandlers) handleSchemas(w http.ResponseWriter, r *http.Request) { } }() + setCORSHeaders(w, r) setNoCacheHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -506,8 +549,14 @@ func (h *httpHandlers) handleManifest(manifestFileBytes []byte) http.HandlerFunc } }() + setCORSHeaders(w, r) setNoCacheHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return