mirror of
https://github.com/wavetermdev/waveterm
synced 2026-04-21 14:37:16 +00:00
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:
parent
62e8ade619
commit
e0ca73ad53
17 changed files with 599 additions and 49 deletions
201
aiprompts/openai-request.md
Normal file
201
aiprompts/openai-request.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
225
frontend/builder/tabs/builder-configdatatab.tsx
Normal file
225
frontend/builder/tabs/builder-configdatatab.tsx
Normal 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 };
|
||||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue