mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[eric] App Builder fixes: preview loads (auth-token threaded into iframe URL + sub-resource paths), agent keeps running when you switch tabs
(session+workspace persisted on the app), Settings default model/thinking now flow into App + Skill Builder drafts
This commit is contained in:
@@ -0,0 +1 @@
|
||||
backend/mcp-bundles/** linguist-vendored
|
||||
@@ -27,6 +27,11 @@ class Output(BaseModel):
|
||||
permission: str = "ask"
|
||||
auto_run_config: Optional[AutoRunConfig] = None
|
||||
thumbnail: Optional[str] = None
|
||||
# Linkage so reopening the App Builder reattaches to the in-progress session
|
||||
# and reuses the same on-disk workspace folder instead of seeding a fresh one
|
||||
# (which would orphan the running agent + lose chat history on every navigate).
|
||||
session_id: Optional[str] = None
|
||||
workspace_id: Optional[str] = None
|
||||
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
@@ -71,6 +76,8 @@ class OutputCreate(BaseModel):
|
||||
files: dict[str, str] = Field(default_factory=dict)
|
||||
auto_run_config: Optional[dict[str, Any]] = None
|
||||
thumbnail: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
workspace_id: Optional[str] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@@ -101,6 +108,8 @@ class OutputUpdate(BaseModel):
|
||||
permission: Optional[str] = None
|
||||
auto_run_config: Optional[dict[str, Any]] = None
|
||||
thumbnail: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
workspace_id: Optional[str] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import mimetypes
|
||||
import base64
|
||||
@@ -7,6 +8,7 @@ from datetime import datetime
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from backend.auth import get_auth_token
|
||||
from jsonschema import validate as schema_validate, ValidationError as SchemaValidationError
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.outputs.models import (
|
||||
@@ -79,6 +81,53 @@ def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str =
|
||||
return f"{injection}\n{html}"
|
||||
|
||||
|
||||
# URL schemes / prefixes that must NOT have ?token= appended. These are either
|
||||
# external (CDNs, mailto) or non-network references that the auth middleware
|
||||
# never sees. Anything else is treated as a same-origin relative URL pointing
|
||||
# at our /api/outputs/.../serve/ subtree, which DOES need the token.
|
||||
_ABSOLUTE_URL_PREFIXES = (
|
||||
"http://", "https://", "//", "data:", "blob:",
|
||||
"mailto:", "tel:", "javascript:", "about:", "#",
|
||||
)
|
||||
|
||||
_HREF_SRC_ATTR_RE = re.compile(
|
||||
r"""(\s(?:href|src))\s*=\s*(["'])([^"']+)\2""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _inject_token_into_relative_urls(html: str, token: str) -> str:
|
||||
"""Append `?token=<t>` to every relative href/src in the served HTML.
|
||||
|
||||
Browsers strip the parent iframe URL's query string before resolving
|
||||
relative `<link href="styles.css">` / `<script src="x.js">`, so without
|
||||
this rewrite the sub-resource fetch lands at the auth middleware with no
|
||||
credentials and gets a 401. Idempotent: skips URLs that already carry a
|
||||
`token=` param. Skips absolute URLs (CDN, data:, etc.) — see prefix list.
|
||||
"""
|
||||
if not token:
|
||||
return html
|
||||
|
||||
def _patch(match: re.Match) -> str:
|
||||
attr, quote, url = match.group(1), match.group(2), match.group(3)
|
||||
lowered = url.lower().lstrip()
|
||||
if lowered.startswith(_ABSOLUTE_URL_PREFIXES):
|
||||
return match.group(0)
|
||||
if "token=" in url:
|
||||
return match.group(0)
|
||||
# Split off any hash fragment so `?token=` lands in the query, not in
|
||||
# the fragment: `page.html?v=1#sec` → `page.html?v=1&token=X#sec`.
|
||||
hash_idx = url.find("#")
|
||||
if hash_idx >= 0:
|
||||
base, frag = url[:hash_idx], url[hash_idx:]
|
||||
else:
|
||||
base, frag = url, ""
|
||||
sep = "&" if "?" in base else "?"
|
||||
return f'{attr}={quote}{base}{sep}token={token}{frag}{quote}'
|
||||
|
||||
return _HREF_SRC_ATTR_RE.sub(_patch, html)
|
||||
|
||||
|
||||
def _decode_data_param(d: str) -> tuple[str, str]:
|
||||
"""Decode the base64-encoded _d query param into (input_json, result_json)."""
|
||||
try:
|
||||
@@ -170,6 +219,10 @@ async def serve_workspace_file(workspace_id: str, filepath: str, _d: str = ""):
|
||||
if filepath == "index.html":
|
||||
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
|
||||
content = _inject_data_into_html(content, input_json, result_json)
|
||||
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the
|
||||
# parent's ?token= query string, so rewrite the HTML to put the token
|
||||
# back on every relative URL — otherwise sub-resources 401.
|
||||
content = _inject_token_into_relative_urls(content, get_auth_token())
|
||||
|
||||
mime, _ = mimetypes.guess_type(filepath)
|
||||
return Response(content=content, media_type=mime or "text/plain")
|
||||
@@ -186,6 +239,7 @@ async def serve_output_file(output_id: str, filepath: str, _d: str = ""):
|
||||
if filepath == "index.html":
|
||||
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
|
||||
content = _inject_data_into_html(content, input_json, result_json)
|
||||
content = _inject_token_into_relative_urls(content, get_auth_token())
|
||||
|
||||
mime, _ = mimetypes.guess_type(filepath)
|
||||
return Response(content=content, media_type=mime or "text/plain")
|
||||
@@ -216,7 +270,10 @@ async def read_workspace(workspace_id: str):
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
return {"files": files, "meta": meta}
|
||||
# Include `path` so the frontend can rehydrate without re-calling /seed.
|
||||
# /seed unconditionally overwrites, which would clobber any in-progress edits
|
||||
# the agent made since the last save.
|
||||
return {"files": files, "meta": meta, "path": os.path.abspath(folder)}
|
||||
|
||||
|
||||
@outputs.router.post("/workspace/seed")
|
||||
|
||||
+4
-1
@@ -107,7 +107,10 @@ async def _auth_middleware(request: Request, call_next):
|
||||
# (CLI path — CLI sends x-api-key with our token as value).
|
||||
headers = dict(request.headers)
|
||||
x_api_key = headers.get("x-api-key") or headers.get("X-API-Key")
|
||||
auth_ok = request_matches_token(headers)
|
||||
# Accept `?token=<token>` query param too. Required for browser-driven
|
||||
# GETs that can't set headers — notably the App Builder iframe loading
|
||||
# /api/outputs/.../serve/index.html via <iframe src="...">.
|
||||
auth_ok = request_matches_token(headers, query_params=dict(request.query_params))
|
||||
if not auth_ok and x_api_key:
|
||||
import secrets as _s
|
||||
from backend.auth import get_auth_token as _gt
|
||||
|
||||
@@ -103,10 +103,40 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
|
||||
[workspacePath],
|
||||
);
|
||||
|
||||
// Honor Settings → default_model + default_thinking_level. createDraftSession's
|
||||
// hardcoded 'sonnet' / undefined-thinking would otherwise win and force every
|
||||
// Skill Builder draft onto Sonnet + Auto thinking.
|
||||
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
|
||||
const defaultThinkingLevel = useAppSelector((s) => s.settings.data.default_thinking_level);
|
||||
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
|
||||
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
|
||||
const modelsLoaded = useAppSelector((s) => s.models.loaded);
|
||||
|
||||
const initSession = useCallback(async () => {
|
||||
const wsId = `skill-ws-${Date.now().toString(36)}`;
|
||||
setStableWorkspaceId(wsId);
|
||||
|
||||
// Resolve provider from the model registry (mirrors ChatInput.tsx provider map).
|
||||
const PROVIDER_MAP: Record<string, string> = {
|
||||
anthropic: 'anthropic',
|
||||
'openswarm pro': 'anthropic',
|
||||
openai: 'openai',
|
||||
google: 'gemini',
|
||||
xai: 'openrouter',
|
||||
meta: 'openrouter',
|
||||
deepseek: 'openrouter',
|
||||
mistral: 'openrouter',
|
||||
qwen: 'openrouter',
|
||||
cohere: 'openrouter',
|
||||
};
|
||||
let resolvedProvider: string | undefined;
|
||||
for (const [prov, models] of Object.entries(modelsByProvider)) {
|
||||
if (models.some((m: any) => m.value === defaultModel)) {
|
||||
resolvedProvider = PROVIDER_MAP[prov.toLowerCase()] || prov.toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${SKILLS_WORKSPACE_API}/workspace/seed`, {
|
||||
method: 'POST',
|
||||
@@ -119,19 +149,30 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
|
||||
mode: 'skill-builder',
|
||||
setActive: false,
|
||||
targetDirectory: data.path,
|
||||
model: defaultModel || undefined,
|
||||
provider: resolvedProvider,
|
||||
thinkingLevel: defaultThinkingLevel || undefined,
|
||||
}));
|
||||
setInitialDraftId(action.payload.draftId);
|
||||
} catch {
|
||||
const action = dispatch(createDraftSession({ mode: 'skill-builder', setActive: false }));
|
||||
const action = dispatch(createDraftSession({
|
||||
mode: 'skill-builder',
|
||||
setActive: false,
|
||||
model: defaultModel || undefined,
|
||||
provider: resolvedProvider,
|
||||
thinkingLevel: defaultThinkingLevel || undefined,
|
||||
}));
|
||||
setInitialDraftId(action.payload.draftId);
|
||||
}
|
||||
}, [dispatch]);
|
||||
}, [dispatch, defaultModel, defaultThinkingLevel, modelsByProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draftCreated.current) return;
|
||||
// Wait for settings + model registry so we don't snapshot stale 'sonnet'.
|
||||
if (!settingsLoaded || !modelsLoaded) return;
|
||||
draftCreated.current = true;
|
||||
initSession();
|
||||
}, [initSession]);
|
||||
}, [initSession, settingsLoaded, modelsLoaded]);
|
||||
|
||||
// Poll workspace for updates
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -27,7 +27,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { createDraftSession, removeDraftSession, AgentMessage } from '@/shared/state/agentsSlice';
|
||||
import { createDraftSession, removeDraftSession, fetchSession, AgentMessage } from '@/shared/state/agentsSlice';
|
||||
import { createOutput, updateOutput, Output, executeOutput, OutputExecuteResult, autoRunOutput, autoRunAgentOutput, cleanupAutoRunAgent, AutoRunConfig, SERVE_BASE } from '@/shared/state/outputsSlice';
|
||||
import { createSessionWs } from '@/shared/ws/WebSocketManager';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
@@ -559,14 +559,73 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
|
||||
const [initialDraftId, setInitialDraftId] = useState<string | null>(null);
|
||||
const [workspacePath, setWorkspacePath] = useState<string | null>(null);
|
||||
const [stableWorkspaceId] = useState(() => `ws-${Date.now().toString(36)}`);
|
||||
// Reuse the workspace_id stored on the Output if present so we don't seed a
|
||||
// fresh folder every time the editor remounts (which would orphan the agent's
|
||||
// in-progress edits and lose chat continuity). Only mint a new id for first-
|
||||
// time outputs that don't yet have one persisted.
|
||||
const [stableWorkspaceId] = useState(() => output?.workspace_id || `ws-${Date.now().toString(36)}`);
|
||||
const draftCreated = useRef(false);
|
||||
|
||||
// Honor the user's Settings → default_model + default_thinking_level.
|
||||
// Without this, createDraftSession's hardcoded 'sonnet' / undefined-thinking
|
||||
// fallbacks win and App Builder always opens on Sonnet + Auto thinking
|
||||
// regardless of what the user picked in Settings.
|
||||
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
|
||||
const defaultThinkingLevel = useAppSelector((s) => s.settings.data.default_thinking_level);
|
||||
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
|
||||
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
|
||||
const modelsLoaded = useAppSelector((s) => s.models.loaded);
|
||||
|
||||
useEffect(() => {
|
||||
if (draftCreated.current) return;
|
||||
// Wait for settings + model registry before seeding the draft, otherwise
|
||||
// we'd snapshot the Redux initial 'sonnet' default and ignore the user's pick.
|
||||
if (!settingsLoaded || !modelsLoaded) return;
|
||||
draftCreated.current = true;
|
||||
|
||||
// Resolve provider from the model registry. Group names mirror the
|
||||
// provider map in ChatInput.tsx (Anthropic / OpenSwarm Pro → 'anthropic',
|
||||
// Google → 'gemini', xAI/Meta/etc → 'openrouter').
|
||||
const PROVIDER_MAP: Record<string, string> = {
|
||||
anthropic: 'anthropic',
|
||||
'openswarm pro': 'anthropic',
|
||||
openai: 'openai',
|
||||
google: 'gemini',
|
||||
xai: 'openrouter',
|
||||
meta: 'openrouter',
|
||||
deepseek: 'openrouter',
|
||||
mistral: 'openrouter',
|
||||
qwen: 'openrouter',
|
||||
cohere: 'openrouter',
|
||||
};
|
||||
let resolvedProvider: string | undefined;
|
||||
for (const [prov, models] of Object.entries(modelsByProvider)) {
|
||||
if (models.some((m: any) => m.value === defaultModel)) {
|
||||
resolvedProvider = PROVIDER_MAP[prov.toLowerCase()] || prov.toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Reattach branch: this Output already has a session + workspace from a
|
||||
// prior visit. Skip seeding (would clobber any in-progress edits the
|
||||
// agent made) and skip createDraftSession (would orphan the live session).
|
||||
// Just resolve the workspace path and tell AgentChat which session to bind to.
|
||||
if (output?.session_id && output?.workspace_id) {
|
||||
try {
|
||||
const res = await fetch(`${WORKSPACE_API}/${output.workspace_id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.path) setWorkspacePath(data.path);
|
||||
}
|
||||
} catch { /* path is best-effort; chat still works without it */ }
|
||||
// Pull the latest session state from the backend so the chat catches up
|
||||
// on anything the agent did while the user was on another tab.
|
||||
dispatch(fetchSession(output.session_id));
|
||||
setInitialDraftId(output.session_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const seedBody: Record<string, any> = { workspace_id: stableWorkspaceId };
|
||||
if (output) {
|
||||
const seedFiles: Record<string, string> = { ...output.files };
|
||||
@@ -588,14 +647,23 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
mode: 'view-builder',
|
||||
setActive: false,
|
||||
targetDirectory: data.path,
|
||||
model: defaultModel || undefined,
|
||||
provider: resolvedProvider,
|
||||
thinkingLevel: defaultThinkingLevel || undefined,
|
||||
}));
|
||||
setInitialDraftId(action.payload.draftId);
|
||||
} catch {
|
||||
const action = dispatch(createDraftSession({ mode: 'view-builder', setActive: false }));
|
||||
const action = dispatch(createDraftSession({
|
||||
mode: 'view-builder',
|
||||
setActive: false,
|
||||
model: defaultModel || undefined,
|
||||
provider: resolvedProvider,
|
||||
thinkingLevel: defaultThinkingLevel || undefined,
|
||||
}));
|
||||
setInitialDraftId(action.payload.draftId);
|
||||
}
|
||||
})();
|
||||
}, [dispatch, output, stableWorkspaceId]);
|
||||
}, [dispatch, output, stableWorkspaceId, settingsLoaded, modelsLoaded, defaultModel, defaultThinkingLevel, modelsByProvider]);
|
||||
|
||||
const effectiveSessionId = useAppSelector((state) => {
|
||||
if (!initialDraftId) return null;
|
||||
@@ -671,14 +739,46 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
|
||||
prevAgentActive.current = isAgentActive;
|
||||
}, [isAgentActive, workspaceId, pollWorkspace]);
|
||||
|
||||
// Hold the latest session status in a ref so the unmount cleanup can read it
|
||||
// at teardown time (the cleanup closure would otherwise capture a stale value
|
||||
// from when the effect first ran).
|
||||
const sessionStatusRef = useRef<string | null>(null);
|
||||
sessionStatusRef.current = agentStatus;
|
||||
const isLaunchedRef = useRef(false);
|
||||
isLaunchedRef.current = isLaunched;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (initialDraftId) {
|
||||
// Only garbage-collect drafts the user abandoned without launching. Once
|
||||
// a session is launched, the agent runs on the backend independent of
|
||||
// the frontend — leave the Redux entry alive so navigating away doesn't
|
||||
// wipe in-progress work or chat history.
|
||||
if (initialDraftId && sessionStatusRef.current === 'draft' && !isLaunchedRef.current) {
|
||||
dispatch(removeDraftSession(initialDraftId));
|
||||
}
|
||||
};
|
||||
}, [initialDraftId, dispatch]);
|
||||
|
||||
// Persist session_id + workspace_id onto the saved Output the moment the
|
||||
// session goes from draft to launched. Without this, reopening the App later
|
||||
// would have no way to find its in-progress session and would seed a fresh one.
|
||||
// Use `createdId` (state) not `createdIdRef.current` so the effect re-fires
|
||||
// after autosave creates the Output for a brand-new app. `output` prop is a
|
||||
// parent snapshot that doesn't refresh, so we dedup via a ref.
|
||||
const persistedLinkageRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const eid = output?.id ?? createdId;
|
||||
if (!eid || !effectiveSessionId || !isLaunched) return;
|
||||
const fingerprint = `${eid}:${effectiveSessionId}:${stableWorkspaceId}`;
|
||||
if (persistedLinkageRef.current === fingerprint) return;
|
||||
persistedLinkageRef.current = fingerprint;
|
||||
dispatch(updateOutput({
|
||||
id: eid,
|
||||
session_id: effectiveSessionId,
|
||||
workspace_id: stableWorkspaceId,
|
||||
}));
|
||||
}, [effectiveSessionId, isLaunched, output?.id, createdId, stableWorkspaceId, dispatch]);
|
||||
|
||||
const schemaText = files['schema.json'] ?? '{"type":"object","properties":{},"required":[]}';
|
||||
|
||||
const parsedSchema = useMemo(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useMemo, forwardRef, useImperativeHandle, use
|
||||
import Box from '@mui/material/Box';
|
||||
import { useElementSelection } from '@/app/components/ElementSelectionContext';
|
||||
import { useIframeElementSelector } from './useIframeElementSelector';
|
||||
import { getAuthToken, ensureAuthToken } from '@/shared/config';
|
||||
|
||||
export interface ViewPreviewHandle {
|
||||
reload: () => void;
|
||||
@@ -54,6 +55,19 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const ctx = useElementSelection();
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
// Track auth token in state so the iframe URL is rebuilt the moment the
|
||||
// token IPC roundtrip resolves. Without this, the first render runs while
|
||||
// _authTokenCache is still '' and the iframe loads a tokenless URL → 401
|
||||
// → the JSON error renders inside the preview pane.
|
||||
const [authToken, setAuthToken] = useState(() => getAuthToken());
|
||||
useEffect(() => {
|
||||
if (authToken) return;
|
||||
let cancelled = false;
|
||||
ensureAuthToken().then((tok) => {
|
||||
if (!cancelled && tok) setAuthToken(tok);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ctx && iframeRef.current) {
|
||||
@@ -65,10 +79,13 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
|
||||
|
||||
const iframeSrc = useMemo(() => {
|
||||
if (!serveUrl) return undefined;
|
||||
// Don't ship a tokenless URL — the backend auth middleware would 401 and
|
||||
// the iframe would render the JSON error. Wait for the token to load.
|
||||
if (!authToken) return undefined;
|
||||
const dataParam = encodeDataParam(inputData, backendResult);
|
||||
const sep = serveUrl.includes('?') ? '&' : '?';
|
||||
return `${serveUrl}${sep}_d=${encodeURIComponent(dataParam)}&_v=${reloadKey}`;
|
||||
}, [serveUrl, inputData, backendResult, reloadKey]);
|
||||
return `${serveUrl}${sep}_d=${encodeURIComponent(dataParam)}&_v=${reloadKey}&token=${encodeURIComponent(authToken)}`;
|
||||
}, [serveUrl, inputData, backendResult, reloadKey, authToken]);
|
||||
|
||||
const srcdoc = useMemo(() => {
|
||||
if (serveUrl || !frontendCode) return undefined;
|
||||
|
||||
@@ -436,14 +436,14 @@ const agentsSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
createDraftSession: {
|
||||
reducer(state, action: PayloadAction<{ draftId: string; mode: string; setActive: boolean; targetDirectory?: string }>) {
|
||||
const { draftId, mode, setActive, targetDirectory } = action.payload;
|
||||
reducer(state, action: PayloadAction<{ draftId: string; mode: string; setActive: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto' }>) {
|
||||
const { draftId, mode, setActive, targetDirectory, model, provider, thinkingLevel } = action.payload;
|
||||
state.sessions[draftId] = {
|
||||
id: draftId,
|
||||
name: 'New chat',
|
||||
status: 'draft',
|
||||
provider: 'anthropic',
|
||||
model: 'sonnet',
|
||||
provider: provider || 'anthropic',
|
||||
model: model || 'sonnet',
|
||||
mode,
|
||||
worktree_path: null,
|
||||
branch_name: null,
|
||||
@@ -461,6 +461,7 @@ const agentsSlice = createSlice({
|
||||
streamingMessage: null,
|
||||
target_directory: targetDirectory || null,
|
||||
tool_group_meta: {},
|
||||
thinking_level: thinkingLevel,
|
||||
};
|
||||
if (setActive) {
|
||||
state.activeSessionId = draftId;
|
||||
@@ -469,13 +470,16 @@ const agentsSlice = createSlice({
|
||||
}
|
||||
}
|
||||
},
|
||||
prepare(opts?: { mode?: string; setActive?: boolean; targetDirectory?: string }) {
|
||||
prepare(opts?: { mode?: string; setActive?: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto' }) {
|
||||
return {
|
||||
payload: {
|
||||
draftId: `draft-${Date.now().toString(36)}`,
|
||||
mode: opts?.mode || 'agent',
|
||||
setActive: opts?.setActive !== false,
|
||||
targetDirectory: opts?.targetDirectory,
|
||||
model: opts?.model,
|
||||
provider: opts?.provider,
|
||||
thinkingLevel: opts?.thinkingLevel,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface Output {
|
||||
permission: string;
|
||||
auto_run_config?: AutoRunConfig | null;
|
||||
thumbnail?: string | null;
|
||||
// Linkage so reopening App Builder reattaches to the in-progress session
|
||||
// and reuses the on-disk workspace folder instead of seeding a fresh one.
|
||||
session_id?: string | null;
|
||||
workspace_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user