Merge branch 'main' into raw-apps-wmill-client-frontend

This commit is contained in:
Diego Imbert
2026-07-28 11:53:31 +02:00
committed by GitHub
14 changed files with 412 additions and 40 deletions
+19 -3
View File
@@ -50,8 +50,15 @@ const TWO_HUNDRED: &str = "200.html";
/// Check if the original path requires cross-origin isolation headers.
///
/// These headers are needed for SharedArrayBuffer and TypeScript workers
/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`).
/// CANONICAL COEP RATIONALE (the dev-server mirror in `frontend/vite.config.js`
/// and the navigation guards in `frontend/src/routes/(root)/(logged)/+layout.svelte`
/// point here): the headers are needed for SharedArrayBuffer and TypeScript
/// workers (raw app editor at `/apps_raw/edit|add`, in-browser bundler at
/// `/ui_builder/`). The raw app *viewer* (`/apps_raw/get/`) must NOT get them:
/// COEP `require-corp` blocks the viewed app's cross-origin subresources
/// (external images, embeds) that lack CORP — and since headers stick to the
/// document, apps would break on a page reload while working when reached via
/// client-side navigation.
///
/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep`
/// query param: a public (raw) app must set COEP to be embeddable as an iframe
@@ -62,7 +69,9 @@ const TWO_HUNDRED: &str = "200.html";
/// explicitly requests it.
#[cfg(feature = "static_frontend")]
fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool {
original_path.starts_with("/apps_raw/")
// no trailing slash on edit/add: matches the +layout.svelte guards
original_path.starts_with("/apps_raw/edit")
|| original_path.starts_with("/apps_raw/add")
|| original_path.starts_with("/ui_builder/")
|| ((original_path.starts_with("/public/") || original_path.starts_with("/a/"))
&& query_has_flag(query, "wm_coep"))
@@ -152,8 +161,15 @@ mod tests {
fn test_needs_cross_origin_isolation() {
// editor + bundler are always isolated, regardless of query
assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None));
assert!(needs_cross_origin_isolation("/apps_raw/add", None));
assert!(needs_cross_origin_isolation("/ui_builder/index.html", None));
// the raw app viewer must NOT be isolated
assert!(!needs_cross_origin_isolation(
"/apps_raw/get/u/foo/bar",
None
));
// public apps (and custom paths) are isolated only when they opt in via wm_coep
assert!(needs_cross_origin_isolation(
"/public/ws/secret",
@@ -197,7 +197,15 @@
return { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
},
setFlowJson: async ({ modules, schema, preprocessorModule, failureModule, groups, notes }) => {
setFlowJson: async ({
modules,
schema,
preprocessorModule,
failureModule,
groups,
notes,
settings
}) => {
try {
if (
modules !== undefined ||
@@ -205,7 +213,8 @@
preprocessorModule !== undefined ||
failureModule !== undefined ||
groups !== undefined ||
notes !== undefined
notes !== undefined ||
settings !== undefined
) {
// Take snapshot of current flowStore and set as beforeFlow
if (!diffManager?.hasPendingChanges) {
@@ -221,7 +230,8 @@
preprocessorModule,
failureModule,
groups,
notes
notes,
settings
})
// Refresh the state store to update UI
@@ -43,10 +43,13 @@ import { flowModuleSchema } from './openFlowZod.gen'
import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree'
import {
buildEditableFlowJson as buildEditableFlowJsonBase,
FLOW_VALUE_SETTINGS_KEYS,
pickFlowValueSettings,
validateEditableFlowJson,
validateFlowModules,
validateFlowSchema,
type EditableFlowJson
type EditableFlowJson,
type FlowValueSettings
} from './editableFlowJson'
import { FLOW_CHAT_SPECIAL_MODULES, getFlowPrompt } from '$system_prompts'
@@ -57,6 +60,9 @@ type FlowJsonUpdate = {
failureModule?: FlowModule | null
groups?: FlowGroup[] | null
notes?: FlowNote[] | null
/** Full state of the top-level FlowValue settings: when provided, keys
* absent from it are removed from the flow value. */
settings?: FlowValueSettings
}
function formatEmptyInlineScriptWarning({
@@ -563,7 +569,8 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
preprocessorModule: parsedFlow.preprocessor_module,
failureModule: parsedFlow.failure_module,
groups: parsedFlow.groups,
notes: parsedFlow.notes
notes: parsedFlow.notes,
settings: pickFlowValueSettings(parsedFlow)
})
const warning = formatEmptyInlineScriptWarning(updateResult)
@@ -854,7 +861,7 @@ export function prepareFlowSystemMessage(customPrompt?: string): ChatCompletionS
Use \`patch_flow_json\` for small, localized changes when you can target an exact snippet from the \`CURRENT FLOW JSON COMPACT\` block below.
Always copy the exact search text from the \`CURRENT FLOW JSON COMPACT\` block below.
The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\` keys.
The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\` keys, plus any top-level flow settings that are set (${FLOW_VALUE_SETTINGS_KEYS.join(', ')}). Settings can be added, edited, or removed with \`patch_flow_json\` as top-level keys — e.g. \`chat_input_enabled: true\` marks the flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow. \`set_flow_json\` never changes these settings.
**Parameters:**
- \`old_string\`: Exact JSON text to find
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import type { FlowModule, FlowValue } from '$lib/gen'
import {
applyEditableFlowJsonToFlow,
buildEditableFlowJson,
validateEditableFlowJson
} from './editableFlowJson'
import { createInlineScriptSession } from './inlineScriptsUtils'
vi.mock('../shared', () => ({
SPECIAL_MODULE_IDS: {
PREPROCESSOR: 'preprocessor',
FAILURE: 'failure'
}
}))
function makeRawScriptModule(id: string, content: string): FlowModule {
return {
id,
summary: id,
value: {
type: 'rawscript',
language: 'bun',
content,
input_transforms: {}
}
} as FlowModule
}
function makeFlowValue(extra: Record<string, unknown> = {}): FlowValue {
return {
modules: [makeRawScriptModule('step_a', 'code a')],
...extra
} as FlowValue
}
describe('flow settings in the compact editable view', () => {
it('round-trips top-level flow settings through build → validate → apply', () => {
const value = makeFlowValue({ chat_input_enabled: true, same_worker: true })
const session = createInlineScriptSession()
const editable = buildEditableFlowJson({ value }, session)
expect(editable.chat_input_enabled).toBe(true)
expect(editable.same_worker).toBe(true)
const revalidated = validateEditableFlowJson(JSON.parse(JSON.stringify(editable)))
const result = applyEditableFlowJsonToFlow(value, revalidated, session)
expect(result.chat_input_enabled).toBe(true)
expect(result.same_worker).toBe(true)
expect(result.modules[0]?.value).toMatchObject({ type: 'rawscript', content: 'code a' })
})
it('applies a patched-in setting and deletes a removed one', () => {
const value = makeFlowValue({ cache_ttl: 60 })
const session = createInlineScriptSession()
const editable = buildEditableFlowJson({ value }, session)
const patched = JSON.parse(JSON.stringify(editable))
patched.chat_input_enabled = true
delete patched.cache_ttl
const result = applyEditableFlowJsonToFlow(value, validateEditableFlowJson(patched), session)
expect(result.chat_input_enabled).toBe(true)
expect('cache_ttl' in result).toBe(false)
})
it('preserves original FlowValue fields outside the compact view', () => {
const value = makeFlowValue({ some_future_field: 'kept' })
const session = createInlineScriptSession()
const editable = buildEditableFlowJson({ value }, session)
expect('some_future_field' in editable).toBe(false)
const result = applyEditableFlowJsonToFlow(
value,
validateEditableFlowJson(JSON.parse(JSON.stringify(editable))),
session
)
expect((result as Record<string, unknown>).some_future_field).toBe('kept')
})
it('rejects unknown top-level keys instead of silently dropping them', () => {
expect(() =>
validateEditableFlowJson({
modules: [makeRawScriptModule('step_a', 'code a')],
chat_enabled: true
})
).toThrow(/Unknown top-level flow key\(s\): chat_enabled/)
})
it('rejects settings with the wrong type', () => {
expect(() =>
validateEditableFlowJson({
modules: [makeRawScriptModule('step_a', 'code a')],
chat_input_enabled: 'yes'
})
).toThrow(/chat_input_enabled/)
})
})
@@ -12,6 +12,18 @@ import {
type FlowNote
} from './helperUtils'
import { flowModuleSchema, flowModulesSchema } from './openFlowZod.gen'
import {
FLOW_VALUE_SETTINGS_KEYS,
flowValueSettingsSchema,
pickFlowValueSettings,
type FlowValueSettings
} from './flowValueSettings'
export {
FLOW_VALUE_SETTINGS_KEYS,
pickFlowValueSettings,
type FlowValueSettings
} from './flowValueSettings'
/**
* Compact, agent-friendly representation of a flow.
@@ -27,7 +39,7 @@ export type EditableFlowJson = {
failure_module: FlowModule | null
groups: FlowGroup[] | null
notes: FlowNote[] | null
}
} & FlowValueSettings
/** Optional input to the rich-error path of `validateEditableFlowJson`. */
type SchemaErrorContext = {
@@ -279,10 +291,19 @@ function validateOptionalFlowModule(rawModule: unknown, fieldName: string): Flow
return result.data
}
export const EDITABLE_FLOW_STRUCTURAL_KEYS = [
'modules',
'schema',
'preprocessor_module',
'failure_module',
'groups',
'notes'
] as const
/**
* Parse and validate a raw object as an `EditableFlowJson`. Validates module
* shape, schema shape, optional special modules (with their reserved ids),
* groups, and that no module ids collide.
* groups, top-level flow settings, and that no module ids collide.
*/
export function validateEditableFlowJson(
rawFlow: unknown,
@@ -293,6 +314,28 @@ export function validateEditableFlowJson(
}
const flow = rawFlow as Record<string, unknown>
// Reject unknown top-level keys: silently dropping them would make patch
// tools report success for edits that never land on the flow.
const allowedKeys = new Set<string>([
...EDITABLE_FLOW_STRUCTURAL_KEYS,
...FLOW_VALUE_SETTINGS_KEYS
])
const unknownKeys = Object.keys(flow).filter((key) => !allowedKeys.has(key))
if (unknownKeys.length > 0) {
throw new Error(
`Unknown top-level flow key(s): ${unknownKeys.join(', ')}. Allowed keys: ${[...allowedKeys].join(', ')}`
)
}
const settingsResult = flowValueSettingsSchema.safeParse(flow)
if (!settingsResult.success) {
const issue = settingsResult.error.issues[0]
const path = issue?.path?.join('.') ?? 'settings'
throw new Error(`Invalid flow setting ${path}: ${issue?.message ?? 'unknown error'}`)
}
const settings = pickFlowValueSettings(settingsResult.data)
const modules = validateFlowModules(flow.modules, ctx)
const schema = validateFlowSchema(flow.schema)
const preprocessorModule = validateOptionalFlowModule(
@@ -345,7 +388,8 @@ export function validateEditableFlowJson(
preprocessor_module: preprocessorModule,
failure_module: failureModule,
groups,
notes
notes,
...settings
}
}
@@ -401,7 +445,8 @@ export function buildEditableFlowJson(
preprocessor_module: preprocessorModule ?? null,
failure_module: failureModule ?? null,
groups: flow.value.groups ?? null,
notes: flow.value.notes ?? null
notes: flow.value.notes ?? null,
...pickFlowValueSettings(flow.value)
}
}
@@ -420,8 +465,12 @@ export function restoreSpecialRawscriptModule(
/**
* Inverse of `buildEditableFlowJson`. Replaces `inline_script.<moduleId>`
* placeholders in `editable.modules` and the special modules with the content
* stored in `session`. Other fields on the original FlowValue (`same_worker`,
* `concurrent_limit`, etc.) are preserved.
* stored in `session`.
*
* The compact view is the full state for the settings in
* `FLOW_VALUE_SETTINGS_KEYS`: a settings key absent from `editable` is removed
* from the result, so patches can unset them. Fields of the original FlowValue
* outside that list are preserved untouched.
*
* Pair with `buildEditableFlowJson` for round-trip patches: extract → patch
* the compact view → restore.
@@ -431,7 +480,7 @@ export function applyEditableFlowJsonToFlow(
editable: EditableFlowJson,
session: InlineScriptSession
): FlowValue {
return {
const result: FlowValue = {
...originalValue,
modules: session.restoreInlineScriptReferences(editable.modules),
preprocessor_module:
@@ -440,6 +489,14 @@ export function applyEditableFlowJsonToFlow(
groups: editable.groups ?? undefined,
notes: editable.notes ?? undefined
}
for (const key of FLOW_VALUE_SETTINGS_KEYS) {
if (editable[key] !== undefined) {
;(result as Record<string, unknown>)[key] = editable[key]
} else {
delete (result as Record<string, unknown>)[key]
}
}
return result
}
/**
@@ -0,0 +1,52 @@
import { z } from 'zod'
import type { FlowValue } from '$lib/gen'
/**
* Non-structural top-level FlowValue settings exposed in the compact view so
* they round-trip through patch/write tools instead of being silently dropped.
* Must stay in sync with the top-level scalar/config fields of `FlowValue`.
*/
export const flowValueSettingsSchema = z
.object({
same_worker: z.boolean(),
preserve_step_tags: z.boolean(),
concurrent_limit: z.number(),
concurrency_key: z.string(),
concurrency_time_window_s: z.number(),
debounce_delay_s: z.number(),
debounce_key: z.string(),
debounce_args_to_accumulate: z.array(z.string()),
max_total_debouncing_time: z.number(),
max_total_debounces_amount: z.number(),
skip_expr: z.string(),
cache_ttl: z.number(),
cache_ignore_s3_path: z.boolean(),
delete_after_secs: z.number(),
flow_env: z.record(z.string(), z.any()),
priority: z.number(),
early_return: z.string(),
chat_input_enabled: z.boolean()
})
.partial()
type FlowValueSettingsKey = keyof z.infer<typeof flowValueSettingsSchema> & keyof FlowValue
export const FLOW_VALUE_SETTINGS_KEYS = Object.keys(
flowValueSettingsSchema.shape
) as FlowValueSettingsKey[]
export type FlowValueSettings = Pick<FlowValue, FlowValueSettingsKey>
/**
* Extract the defined non-structural settings from a FlowValue (or an
* EditableFlowJson, which carries the same keys).
*/
export function pickFlowValueSettings(source: Record<string, unknown>): FlowValueSettings {
const settings: Record<string, unknown> = {}
for (const key of FLOW_VALUE_SETTINGS_KEYS) {
if (source[key] !== undefined) {
settings[key] = source[key]
}
}
return settings as FlowValueSettings
}
@@ -53,6 +53,39 @@ function makeAiAgentWithTool(agentId: string, toolId: string, toolContent: strin
}
describe('applyFlowJsonUpdate', () => {
it('leaves top-level flow settings untouched when settings is not provided', () => {
const flow = {
value: {
modules: [makeRawScriptModule('step_a', 'code a')],
chat_input_enabled: true,
same_worker: true
}
}
applyFlowJsonUpdate(flow as any, createInlineScriptSession(), {
modules: [makeRawScriptModule('step_b', 'code b')]
})
expect((flow.value as any).chat_input_enabled).toBe(true)
expect((flow.value as any).same_worker).toBe(true)
})
it('applies full-state settings: sets provided keys and removes absent ones', () => {
const flow = {
value: {
modules: [makeRawScriptModule('step_a', 'code a')],
chat_input_enabled: true
}
}
applyFlowJsonUpdate(flow as any, createInlineScriptSession(), {
settings: { same_worker: true }
})
expect((flow.value as any).same_worker).toBe(true)
expect('chat_input_enabled' in flow.value).toBe(false)
})
it('accepts new self-referenced inline scripts and initializes them as empty', () => {
const flow = {
value: {
@@ -8,6 +8,7 @@ import {
NoteColor
} from '$lib/components/graph/noteColors'
import type { InlineScriptSession } from './inlineScriptsUtils'
import { FLOW_VALUE_SETTINGS_KEYS, type FlowValueSettings } from './flowValueSettings'
/** Allowed note/group color names — matches the NoteColor palette the note and
* group editors use. The note renderer keys `NOTE_COLORS` by these exact names,
@@ -56,6 +57,9 @@ export interface FlowJsonUpdate {
failureModule?: FlowModule | null
groups?: FlowGroup[] | null
notes?: FlowNote[] | null
/** Full state of the top-level FlowValue settings: when provided, keys
* absent from it are removed from the flow value. */
settings?: FlowValueSettings
}
export interface FlowJsonUpdateResult {
@@ -269,7 +273,7 @@ export function validateFlowNotes(rawNotes: unknown, moduleIds?: Set<string>): F
export function applyFlowJsonUpdate(
flow: FlowLike,
inlineScriptSession: InlineScriptSession,
{ modules, schema, preprocessorModule, failureModule, groups, notes }: FlowJsonUpdate
{ modules, schema, preprocessorModule, failureModule, groups, notes, settings }: FlowJsonUpdate
): FlowJsonUpdateResult {
const emptyInlineScriptModuleIds = new Set<string>()
@@ -307,6 +311,16 @@ export function applyFlowJsonUpdate(
flow.value.notes = notes == null || notes.length === 0 ? undefined : notes
}
if (settings !== undefined) {
for (const key of FLOW_VALUE_SETTINGS_KEYS) {
if (settings[key] !== undefined) {
;(flow.value as Record<string, unknown>)[key] = settings[key]
} else {
delete (flow.value as Record<string, unknown>)[key]
}
}
}
return {
emptyInlineScriptModuleIds: Array.from(emptyInlineScriptModuleIds)
}
@@ -1941,7 +1941,7 @@ describe('global AI tools', () => {
path: 'f/flows/existing',
summary: 'deployed summary',
description: 'deployed description',
value: { modules: [] },
value: { modules: [], chat_input_enabled: true, same_worker: true },
schema: { properties: { deployed: { type: 'boolean' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
@@ -1961,7 +1961,11 @@ describe('global AI tools', () => {
path: 'f/flows/existing',
summary: 'new summary',
description: 'deployed description',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
value: {
modules: [{ id: 'step', value: { type: 'identity' } }],
chat_input_enabled: true,
same_worker: true
}
})
})
@@ -58,7 +58,10 @@ import { isChromiumBrowser } from '$lib/utils'
import {
applyEditableFlowJsonToFlow,
buildEditableFlowJson,
EDITABLE_FLOW_STRUCTURAL_KEYS,
type EditableFlowJson,
FLOW_VALUE_SETTINGS_KEYS,
pickFlowValueSettings,
finalizeUnresolvedInlineScripts,
restoreSpecialRawscriptModule,
validateEditableFlowJson
@@ -521,6 +524,7 @@ function appendEmptyInlineScriptWarning(result: string, editable: EditableFlowJs
function editableFlowToDraftValue(editable: EditableFlowJson): FlowDraftValue {
const value: FlowValue = {
...pickFlowValueSettings(editable),
modules: editable.modules,
preprocessor_module: editable.preprocessor_module ?? undefined,
failure_module: editable.failure_module ?? undefined,
@@ -1947,8 +1951,9 @@ function getFlowInstructions(): string {
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Never invent a folder.
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions.
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`.
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. When overwriting an existing flow, top-level flow settings (see below) are preserved from the current flow — use \`patch_flow_json\` to change them.
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, \`notes\`, and any top-level flow settings that are set.
- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with \`patch_flow_json\`: ${FLOW_VALUE_SETTINGS_KEYS.join(', ')}. For example, \`chat_input_enabled: true\` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
- Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it.
- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.
@@ -2878,7 +2883,8 @@ export const globalTools: Tool<{}>[] = [
path: parsed.path,
summary: parsed.summary,
description: parsed.description,
flow: editableFlowToDraftValue(resolved)
flow: editableFlowToDraftValue(resolved),
preserveBaseValueSettings: true
},
ctx
)
@@ -4169,8 +4175,14 @@ type FlowDraftArgs = {
description?: string
flow: FlowDraftValue
override?: boolean
/** Carry over the base value's non-structural fields (chat_input_enabled,
* same_worker, ...) into the new value. Set by write_flow, whose arguments
* cannot express them; patch_flow_json passes the full value state instead. */
preserveBaseValueSettings?: boolean
}
const FLOW_STRUCTURAL_VALUE_KEYS = new Set<string>(EDITABLE_FLOW_STRUCTURAL_KEYS)
const FLOW_SPEC: WriteSpec<Flow, FlowDraftArgs> = {
probe: (workspace, path) => FlowService.existsFlowByPath({ workspace, path }),
fetchDeployed: (workspace, path) => FlowService.getFlowByPath({ workspace, path }),
@@ -4179,6 +4191,16 @@ const FLOW_SPEC: WriteSpec<Flow, FlowDraftArgs> = {
if (args.flow.groups !== undefined && args.flow.groups !== null) {
value.groups = structuredClone(args.flow.groups)
}
if (args.preserveBaseValueSettings && base?.value) {
for (const [key, fieldValue] of Object.entries(base.value)) {
if (
!FLOW_STRUCTURAL_VALUE_KEYS.has(key) &&
(value as Record<string, unknown>)[key] === undefined
) {
;(value as Record<string, unknown>)[key] = structuredClone(fieldValue)
}
}
}
return base
? {
...structuredClone(base),
@@ -44,7 +44,7 @@
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import { isObjectTooBig, readFieldsRecursively } from '$lib/utils'
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
import type { AiAgent, ScriptLang } from '$lib/gen'
import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen'
import { deepEqual } from 'fast-equals'
import Toggle from '$lib/components/Toggle.svelte'
import { AI_AGENT_SCHEMA } from '../flowInfers'
@@ -599,24 +599,45 @@
false
)
} else if (aiAgentModules.length === 1) {
// Exactly one AI agent exists, configure it
// Exactly one AI agent exists: fill in defaults only for inputs the
// user hasn't configured, so re-enabling chat mode on an already
// configured agent doesn't clobber a custom user_message expression
// or a deliberate memory choice (e.g. off).
const aiAgent = aiAgentModules[0]
const value = aiAgent.value as AiAgent
// Set user_message to flow_input.user_message
value.input_transforms['user_message'] = {
type: 'javascript',
expr: 'flow_input.user_message'
// Degenerate shapes the input form can produce without deliberate
// configuration count as unconfigured: empty static value (undefined
// persists as null through JSON round-trips), blank JS expression
// (the JS toggle seeds a bare backtick pair), or an AI transform
// (meaningless for the chat input).
const isUnconfigured = (transform: InputTransform | undefined) =>
transform === undefined ||
(transform.type === 'static' && (transform.value == null || transform.value === '')) ||
(transform.type === 'javascript' && transform.expr.replaceAll('`', '').trim() === '') ||
transform.type === 'ai'
const applied: string[] = []
if (isUnconfigured(value.input_transforms['user_message'])) {
value.input_transforms['user_message'] = {
type: 'javascript',
expr: 'flow_input.user_message'
}
applied.push('user message input')
}
// Set messages_context_length to 10
value.input_transforms['memory'] = {
type: 'static',
value: { kind: 'auto', context_length: 10 }
if (isUnconfigured(value.input_transforms['memory'])) {
value.input_transforms['memory'] = {
type: 'static',
value: { kind: 'auto', context_length: 10 }
}
applied.push('context memory set to 10')
}
sendUserToast(
'Chat mode enabled. AI agent configured with user message input and context memory set to 10.',
applied.length > 0
? `Chat mode enabled. AI agent configured with ${applied.join(' and ')}.`
: 'Chat mode enabled. Existing AI agent configuration kept unchanged.',
false
)
}
@@ -90,8 +90,13 @@
// `wm_coep` (embed-in-cross-origin-isolated-page opt-in) must be propagated
// to the wrapper document: under a COEP `require-corp` embedder, a nested
// document is only allowed to load if it asserts COEP itself, so the
// backend adds the header when the flag is present.
const coep = new URLSearchParams(window.location.search).has('wm_coep') ? '?wm_coep=1' : ''
// backend adds the header when the flag is present. Also request it when
// this document is itself cross-origin isolated (e.g. the raw app editor)
// — the wrapper would otherwise be blocked outright, URL flag or not.
const coep =
new URLSearchParams(window.location.search).has('wm_coep') || window.crossOriginIsolated
? '?wm_coep=1'
: ''
return `/api/w/${workspace}/apps_u/get_data/v/${secret}.html${coep}`
})
@@ -415,8 +415,10 @@
// This ensures the cross-origin isolation headers are fetched from the server
// which are required for SharedArrayBuffer and TypeScript workers to work correctly
const toPath = navigation.to?.url.pathname
if (toPath && (toPath.startsWith('/apps_raw/add') || toPath.startsWith('/apps_raw/edit'))) {
const currentPath = navigation.from?.url.pathname
const currentPath = navigation.from?.url.pathname
const isEditorPath = (p: string | undefined) =>
!!p && (p.startsWith('/apps_raw/add') || p.startsWith('/apps_raw/edit'))
if (isEditorPath(toPath)) {
// Reload if we're not on an apps_raw path, or if we're on the raw app viewer
// (/apps_raw/get/): the viewer doesn't have cross-origin isolation headers, so
// we need a full reload to fetch them for the editor.
@@ -424,6 +426,15 @@
navigation.cancel()
window.location.href = navigation.to!.url.href
}
} else if (toPath && (isEditorPath(currentPath) || window.crossOriginIsolated)) {
// Reverse of the guard above: leaving the isolated editor document must
// also fully reload, or its COEP header sticks for the rest of the SPA
// session and blocks CORP-less cross-origin subresources (e.g. images in
// a viewed app — see needs_cross_origin_isolation in static_assets.rs).
// The path check covers plain-HTTP origins, where `crossOriginIsolated`
// stays false even with the headers applied.
navigation.cancel()
window.location.href = navigation.to!.url.href
}
})
+21 -3
View File
@@ -24,15 +24,33 @@ const remoteUrl =
const cookieDomain = process.env.ISOLATE_DEV_AUTH === '1' ? '' : 'localhost'
// Cross-origin isolation headers, scoped to mirror the production predicate —
// see `needs_cross_origin_isolation` in backend/windmill-api/src/static_assets.rs
// for which paths need them and why the raw app viewer must be excluded.
// `enforce: 'pre'` so these headers are set before SvelteKit's sirv static
// handler serves `static/` files and ends the response without calling next().
function needsCrossOriginIsolation(url) {
const [path, query = ''] = url.split('?')
return (
path.startsWith('/apps_raw/edit') ||
path.startsWith('/apps_raw/add') ||
path.startsWith('/ui_builder/') ||
((path.startsWith('/public/') || path.startsWith('/a/')) &&
new URLSearchParams(query).has('wm_coep'))
)
}
let plugin = {
name: 'configure-response-headers',
enforce: 'pre',
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
server.middlewares.use((req, res, next) => {
if (needsCrossOriginIsolation(req.url ?? '')) {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
}
// CORP on everything so dev assets stay loadable as subresources of
// isolated documents on other dev origins (e.g. 127.0.0.1 vs localhost).
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
next()
})