builder secrets, builder config/data tab hooked up (#2581)

builder secrets, builder config/data tab hooked up, and tsunami cors
config env var
This commit is contained in:
Mike Sawka 2025-11-21 10:36:51 -08:00 committed by GitHub
parent 62e8ade619
commit e0ca73ad53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 599 additions and 49 deletions

201
aiprompts/openai-request.md Normal file
View file

@ -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.

View file

@ -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")}
/>
<TabButton
label="Config/Data"
tabType="configdata"
isActive={activeTab === "configdata"}
isAppFocused={isAppFocused}
onClick={() => handleTabClick("configdata")}
/>
<TabButton
label="Files"
tabType="files"
@ -363,7 +371,12 @@ const BuilderAppPanel = memo(() => {
</div>
<div className="w-full h-full" style={{ display: activeTab === "secrets" ? "block" : "none" }}>
<ErrorBoundary>
<BuilderEnvTab />
<BuilderSecretTab />
</ErrorBoundary>
</div>
<div className="w-full h-full" style={{ display: activeTab === "configdata" ? "block" : "none" }}>
<ErrorBoundary>
<BuilderConfigDataTab />
</ErrorBoundary>
</div>
</div>

View file

@ -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),

View file

@ -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 (
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-6 max-w-[500px] text-center px-8">
<i className="fa fa-triangle-exclamation text-6xl text-warning" />
<div className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold text-primary">App Not Running</h2>
<p className="text-base text-secondary leading-relaxed">
The tsunami app must be running to view config and data. Please start the app from the Preview
tab first.
</p>
</div>
</div>
</div>
);
});
NotRunningView.displayName = "NotRunningView";
const ErrorView = memo(({ errorMsg }: { errorMsg: string }) => {
return (
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-6 max-w-2xl text-center px-8">
<i className="fa fa-circle-xmark text-6xl text-error" />
<div className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold text-error">Error Loading Data</h2>
<div className="text-left bg-panel border border-error/30 rounded-lg p-4">
<pre className="text-sm text-secondary whitespace-pre-wrap font-mono">{errorMsg}</pre>
</div>
</div>
</div>
</div>
);
});
ErrorView.displayName = "ErrorView";
const LoadingView = memo(() => {
return (
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-6">
<i className="fa fa-spinner fa-spin text-6xl text-secondary" />
<p className="text-base text-secondary">Loading data...</p>
</div>
</div>
);
});
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<ConfigDataState>({
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 <NotRunningView />;
}
if (state.isLoading) {
return <LoadingView />;
}
if (state.error) {
return <ErrorView errorMsg={state.error} />;
}
if (!state.config && !state.data) {
return <LoadingView />;
}
return (
<div className="w-full h-full flex flex-col bg-background">
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-border">
<h3 className="text-lg font-semibold text-primary">Config & Data</h3>
<button
onClick={handleRefresh}
className="px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer flex items-center gap-2"
>
<i className="fa fa-refresh" />
Refresh
</button>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-primary flex items-center gap-2">
<i className="fa fa-gear" />
Config
</h4>
<CopyButton title="Copy Config" onClick={handleCopyConfig} />
</div>
<div className="bg-panel border border-border rounded-lg p-4 overflow-auto">
<pre className="text-xs text-primary font-mono whitespace-pre">
{JSON.stringify(state.config, null, 2)}
</pre>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-primary flex items-center gap-2">
<i className="fa fa-database" />
Data
</h4>
<CopyButton title="Copy Data" onClick={handleCopyData} />
</div>
<div className="bg-panel border border-border rounded-lg p-4 overflow-auto">
<pre className="text-xs text-primary font-mono whitespace-pre">
{JSON.stringify(state.data, null, 2)}
</pre>
</div>
</div>
</div>
</div>
</div>
);
});
BuilderConfigDataTab.displayName = "BuilderConfigDataTab";
export { BuilderConfigDataTab };

View file

@ -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 (
<div className="flex items-center gap-4 py-2 border-b border-border">
<Tooltip content={!isMapped ? "Secret is Not Mapped" : "Secret Has a Valid Mapping"}>
<Tooltip content={!isMapped ? "Secret is Not Mapped" : isValid ? "Secret Has a Valid Mapping" : "Secret Binding is Invalid"}>
<div className="flex items-center">
{!isMapped && <AlertTriangle className="w-5 h-5 text-yellow-500" />}
{isMapped && isValid && <Check className="w-5 h-5 text-green-500" />}
{isInvalid && <AlertTriangle className="w-5 h-5 text-red-500" />}
{isValid && <Check className="w-5 h-5 text-green-500" />}
</div>
</Tooltip>
<div className="flex-1 flex items-center gap-2">
@ -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<string[]>([]);
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 (
<div className="w-full h-full flex items-center justify-center">
<div className="text-secondary text-center">
App manifest not available. Secrets will be shown once the app builds successfully.
</div>
</div>
);
}
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 (
<div className="w-full h-full flex flex-col p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-lg font-semibold">Secret Bindings</h2>
<button
className="px-3 py-1 text-sm font-medium rounded bg-accent/80 text-primary hover:bg-accent transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleSave}
disabled={!isDirty || isSaving}
>
{isSaving ? "Saving..." : "Save"}
</button>
</div>
<h2 className="text-lg font-semibold mb-2">Secret Bindings</h2>
<div className="mb-4 p-2 bg-blue-500/10 border border-blue-500/30 rounded text-sm text-secondary">
Map app secrets to Wave secret store names. Required secrets must be bound before the app can run
successfully.
successfully. Changes are saved automatically.
</div>
{!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 };

4
package-lock.json generated
View file

@ -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": [

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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())

View file

@ -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 (

View file

@ -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,

View file

@ -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 {

View file

@ -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)
}

View file

@ -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"`

View file

@ -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")

View file

@ -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