Compare commits

...
Author SHA1 Message Date
hugocasaandClaude Opus 5 c2beda70df docs: state where an agent trail may be passed on the header prop
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:07:37 +02:00
hugocasaandClaude Opus 5 6649f1c520 fix: show the agent breadcrumb on nested mcp and web search tools
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:05:41 +02:00
hugocasaandClaude Opus 5 967a7b1a8a docs: say what a deeper agent tool breaks in the picker comment
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:52:16 +02:00
hugocasaandClaude Opus 5 e20cd87ef9 feat: edit a nested AI agent's own tools from the flow editor
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:42:08 +02:00
hugocasaandClaude Opus 5 c297ed0052 feat: managed memory with an inherited or custom memory id per step (#11118)
* feat: split ai agent memory into agent policy, run memory id and step history

* fix: scope string memory ids to workspace and flow, keep nested tool history inputs

* chore: update sqlx cache for the flow context query

* docs: describe memory id scoping as collision-free rather than isolated

* chore: regenerate openflow json after merging main

* fix: offer no memory id for legacy manual memory, document linked history inputs

* fix: seed provided messages from legacy manual memory and hide its note once set

* fix: bypass memory when a provided messages expression evaluates to null

* fix: require a user message when provided messages are empty

* chore: keep the empty messages comment within the line width

* docs: name the history inputs wherever linked steps list their flow-local inputs

* docs: keep the memory storage path on one line

* feat: managed memory with an inherited or custom memory id per step

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: list a custom memory id in the test run form and name where an inherited one comes from

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep memory id out of the add-field menu and drop the memory id telemetry

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep legacy auto memory without an id working after an untouched redeploy

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: rename step messages to previous_messages and address review

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: rewrap comments and docs lines lengthened by the previous_messages rename

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: read agent memory as either a legacy shape or the current one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: name the memory setting in ignored-input notes and keep conversions honest

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep a legacy memory count unset on open and read a cleared count as off

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: address review on cleared test history and zero-count memory

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: drop flow-local keys from a linked agent resource before interpolating it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a linked resource's own inputs as fallbacks and note ignored history on image runs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: restore the linked agent draft tests and log ignored history on every image run

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: resolve the one-of variant from the value when the selected one leaves the list

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: treat zero-count managed memory as off when enabling chat mode and shorten comments

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: stop requiring user_message in the openflow agent contract when previous messages are the prompt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:55:43 +02:00
GuilhemandClaude Fable 5.1 4eab995cf7 feat: tell test flow conversations from deployed ones and rename a chat (#11179)
* feat: mark test flow conversations apart from deployed ones and allow renaming a chat

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep the conversation kind across refreshes and reject NUL titles

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: ignore conversation lists for a kind no longer selected

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: start a fresh conversation listing when the kind changes on a later page

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse sending into a conversation of the other kind

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse cross-kind conversation continuations on the server

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the conversation filter unavailable while an answer runs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 11:41:49 +02:00
hugocasaandClaude Opus 5 381d4470ef fix: disable a schedule whose cron has no run left instead of panicking (#11195)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:40:00 +02:00
AlexRV12andClaude Opus 5 e954d33613 feat(ai-chat): add list_workers and list_data_metrics global tools (#11143)
* feat(ai-chat): add list_workers and list_data_metrics global tools

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-evals): keep the generated eval workspace id valid for the backend

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): trim the worker truncation note and added comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* fix(ai-chat): only hedge an empty worker list when workers can be hidden

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* fix(ai-chat): show the empty-worker message in the tool card

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* fix(ai-chat): stop advertising workers in the api catalog tool

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* test(ai-evals): move the workspace id guard beside its function

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* fix(ai-chat): keep the empty metric note true for every filter shape

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* fix(ai-chat): resolve the caller role before judging an empty worker list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* fix(ai-evals): collapse doubled hyphens in the generated workspace id

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* docs(ai-chat): describe what the ducklake tools and the worker hedge do

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* fix(ai-chat): name the missing lake when a metric table filter has none

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* fix(ai-chat): strip the ducklake scheme from the table retry hint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

* test(ai-evals): add a global case for the declared measure path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gi6UeLKJB9UaSWtjDCVaMq

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:29:37 +02:00
AlexRV12andClaude Opus 5 5bb37ca338 feat(ai-chat): merge get_job_logs and get_flow_run_details into get_run (#11172)
* feat(ai-chat): merge get_job_logs and get_flow_run_details into get_run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1arUSfbbVQSq2DsjamxKF

* fix(ai-chat): tell an unreadable log apart from an empty one in get_run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1arUSfbbVQSq2DsjamxKF

* fix(ai-chat): keep get_run's step tree optional when the read resolves undefined

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1arUSfbbVQSq2DsjamxKF

* fix(ai-chat): match each WINDMILL_TOO_BIG marker to the field it elides

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1arUSfbbVQSq2DsjamxKF

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:00:41 +02:00
GuilhemandClaude Fable 5.1 189793c2e4 feat: flow chat model picker on a shared model-settings component (#11187)
* refactor: render the session chat model menu from a shared ChatModelSettings config

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat: pick the flow chat's model and thinking from the provider fields the flow exposes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: name only the thinking level the flow run will send on the model button

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let the flow chat take a typed model id and keep a shared thinking input editable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: promote a flow input to the model button only where its control can edit it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop any reasoning token the chosen model rejects before a flow chat run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 09:59:05 +02:00
GuilhemandClaude Fable 5.1 68f2248018 feat: keep flow inputs and seed the agent when chat mode is enabled (#11177)
* feat: keep flow inputs and seed the agent when chat mode is enabled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: leave a linked agent's memory and streaming to the agent when enabling chat mode

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 09:29:05 +02:00
Guilhem 23c24a9688 feat: collect flow conversations and agent memory once their last message goes (#11178)
* feat: collect flow conversations and agent memory when their last message goes

* fix: lock the conversation lookup so a new turn orders against its cleanup

* fix: let concurrent turns recreate a collected conversation without conflicting
2026-09-17 09:27:12 +02:00
GuilhemandClaude Fable 5.1 a9ec0aec3a feat: render the flow chat through the shared session chat components (#11175)
* feat: render the flow chat through the shared session chat components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: settle the flow chat queue on the turn's outcome and guard IME enter

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep copilot free-tier banners and deploy-locked sends out of the flow chat

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:45:01 +02:00
hugocasaandClaude Opus 5 9d348f84c7 fix: skip expiry notifications for app embed and SDK tokens (#11169)
* fix: skip expiry notifications for app embed and SDK tokens

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: share app token label prefixes between mint sites and the check

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: skip expiry alerts for impersonation and test-connection tokens

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:28:53 +02:00
hugocasaandClaude Opus 5 02e47de8b4 feat: allow git sync auto-pull, promotion and PRs on Pro licenses (#11173)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:28:40 +02:00
Guilhem 64dffe6106 feat: show flow step detail inside the graph tab on narrow detail layouts (#11168)
* feat: show flow step detail as a page inside the graph tab on narrow detail layouts

* fix: pad the detail step tab, wrapped header row and raise the tabbed layout breakpoint

* fix: keep the step header pinned and switch to triggers on every trigger node tap
2026-09-16 16:12:21 +02:00
GuilhemandClaude Fable 5.1 3d08197182 feat: badge chat-input flows on the home list (#11164)
* feat: badge chat-input flows on the home list

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a malformed draft value from aborting the runnables list

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: only a JSON boolean marks a draft flow as chat-enabled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 14:46:49 +02:00
164 changed files with 8843 additions and 1794 deletions
+3 -2
View File
@@ -31,8 +31,9 @@ Open-source platform for internal tools, workflows, API integrations, background
`cargo run`; a normal build cannot start one at all.
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
scope, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`.
Read before designing anything that creates users, tokens or sessions.
scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and
that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates
users, tokens or sessions.
- **Product telemetry**: `docs/feature-telemetry.md` — when to instrument a new feature with
`feature_usage`, and the four-step recipe. An unregistered `(feature, kind)` pair is dropped
silently, so frontend-only instrumentation records nothing.
+19
View File
@@ -37,6 +37,25 @@ _Avoid_: argument field, param
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
_Avoid_: JS field, code input
### Flow chat
**Conversation**:
One thread of messages against one chat-enabled flow, with its own agent memory. A flow has
many; the chat shows one at a time.
_Avoid_: thread, session (that names an AI session, a different thing), chat (that names the surface)
**Turn**:
One question and the answer to it: the run the question started, the handle that stops it,
and the rows it is writing. At most one per conversation, and the chat is held for its whole
length — from the moment the question takes the chat, before it has a job, until it is ended.
_Avoid_: request, exchange, message round
**Transcript**:
The rows a conversation's chat holds. Not the conversation: it is the newest page plus
whatever older pages the reader has scrolled back through, so a question it cannot answer
from what it holds is one to ask the server rather than to guess at.
_Avoid_: history, messages (too easily read as "all of them")
### Permissions
**Member**:
+1 -12
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'
import type { BackendValidationSettings } from '../../core/backendValidation'
import { buildWorkspaceId } from './workspaceId'
interface CompletedJobResultMaybe {
completed: boolean
@@ -24,7 +24,6 @@ export interface CompletedPreviewJob {
const tokenCache = new Map<string, Promise<string>>()
const sharedWorkspaceQueue = new Map<string, Promise<void>>()
const managedSharedWorkspacePrefixes = ['f/evals/']
const DEFAULT_WORKSPACE_PREFIX = 'ai-evals'
export class BackendPreviewClient {
constructor(private readonly settings: BackendValidationSettings) {}
@@ -441,16 +440,6 @@ async function withSharedWorkspaceLock<T>(workspaceId: string, body: () => Promi
}
}
function buildWorkspaceId(caseId: string, attempt: number): string {
const caseSlug = caseId
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 30)
const suffix = randomUUID().slice(0, 8)
return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || 'case'}-a${attempt}-${suffix}`
}
function extractFolderName(path: string): string | null {
if (!path.startsWith('f/')) {
return null
+59 -3
View File
@@ -11,6 +11,7 @@ import type {
Script
} from '../../../frontend/src/lib/gen'
import type {
DataMetric,
DataTableTables,
DataTableTableSchema,
EndpointTool,
@@ -92,7 +93,7 @@ export interface BenchmarkWorkspaceResource {
}
export interface BenchmarkWorkspaceJob {
/** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */
/** Stable id so a case prompt can reference a specific run (e.g. for get_run). */
id?: string
jobKind?: CompletedJob['job_kind']
scriptPath?: string
@@ -100,6 +101,8 @@ export interface BenchmarkWorkspaceJob {
label?: string
success?: boolean
logs?: string
args?: Record<string, unknown>
result?: unknown
}
export interface BenchmarkWorkspaceRunnables {
@@ -110,6 +113,9 @@ export interface BenchmarkWorkspaceRunnables {
aiProviders?: BenchmarkWorkspaceAiProvider[]
resources?: BenchmarkWorkspaceResource[]
datatables?: BenchmarkDatatableSeed[]
/** DuckLake catalog names, as `list_ducklakes` reports them. */
ducklakes?: string[]
dataMetrics?: DataMetric[]
jobs?: BenchmarkWorkspaceJob[]
}
@@ -156,7 +162,7 @@ export function registerBenchmarkWorkspaceRunnables(
...runnables,
datatables: runnables.datatables ? structuredClone(runnables.datatables) : undefined
})
// Seed any fixture jobs so list_runs / get_job_logs have data to return.
// Seed any fixture jobs so list_runs / get_run have data to return.
for (const seed of runnables.jobs ?? []) {
createBenchmarkCompletedJob({
workspace,
@@ -166,7 +172,9 @@ export function registerBenchmarkWorkspaceRunnables(
scriptPath: seed.scriptPath,
createdBy: seed.createdBy,
label: seed.label,
logs: seed.logs
logs: seed.logs,
args: seed.args,
result: seed.result
})
}
}
@@ -481,6 +489,33 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string {
return job.logs ?? ''
}
/**
* Mirror `JobService.getFlowAllResults`, which get_run calls for the execution
* tree. Fixture jobs are single runs with no steps, so only the root entry.
*/
export function getBenchmarkFlowAllResults(workspace: string, jobId: string) {
const job = getBenchmarkCompletedJob(workspace, jobId)
if (!job) {
throw new Error(`Job "${jobId}" not found in benchmark workspace`)
}
return {
entries: [
{
job_id: jobId,
label: 'Flow',
kind: job.job_kind ?? 'script',
depth: 0,
sibling_index: 1,
sibling_count: 1,
status: job.success ? 'success' : 'failure',
success: job.success
}
],
truncated: false,
scope_filtered: false
}
}
// ============= Drafts (per-user, DB-backed in production) =============
/**
@@ -642,6 +677,27 @@ export function listBenchmarkDatatables(workspace: string): DataTableTables[] |
}))
}
// ============= DuckLake catalogs and declared metrics =============
/** Seeded DuckLake names, or `null` for a non-benchmark workspace. */
export function listBenchmarkDucklakes(workspace: string): string[] | null {
const runnables = benchmarkWorkspaceRunnables.get(workspace)
return runnables ? (runnables.ducklakes ?? []) : null
}
/**
* Seeded metric declarations, or `null` for a non-benchmark workspace.
*
* The `table` / `path_prefix` filters are ignored: which rows a filter selects is
* `canonical_table_path`'s business and is pinned by `ducklakeTools.test.ts`.
* Re-deriving it here would give the eval its own copy of that spec to drift from,
* and the case this serves measures whether the model reaches for the tool at all.
*/
export function listBenchmarkDataMetrics(workspace: string): DataMetric[] | null {
const runnables = benchmarkWorkspaceRunnables.get(workspace)
return runnables ? (runnables.dataMetrics ?? []) : null
}
export function getBenchmarkDatatableSchema(input: {
workspace: string
datatableName: string
@@ -62,6 +62,7 @@ vi.mock('$lib/gen', async () => {
getBenchmarkDatatableSchema,
getBenchmarkDraftForUser,
getBenchmarkFlowByPath,
getBenchmarkFlowAllResults,
getBenchmarkJobLogs,
getBenchmarkOwnDraft,
getBenchmarkScriptByHash,
@@ -75,7 +76,9 @@ vi.mock('$lib/gen', async () => {
listBenchmarkPlainResources,
listBenchmarkApps,
listBenchmarkDatatables,
listBenchmarkDataMetrics,
listBenchmarkDrafts,
listBenchmarkDucklakes,
listBenchmarkFlows,
listBenchmarkJobs,
listBenchmarkScripts,
@@ -325,7 +328,11 @@ vi.mock('$lib/gen', async () => {
getJobLogs: async (data: { workspace: string; id: string }) =>
hasBenchmarkWorkspace(data.workspace)
? getBenchmarkJobLogs(data.workspace, data.id)
: actual.JobService.getJobLogs(data)
: actual.JobService.getJobLogs(data),
getFlowAllResults: async (data: { workspace: string; id: string }) =>
hasBenchmarkWorkspace(data.workspace)
? getBenchmarkFlowAllResults(data.workspace, data.id)
: actual.JobService.getFlowAllResults(data)
}),
WorkspaceService: wrapService(actual.WorkspaceService, {
getCopilotInfo: async (data: { workspace: string }) =>
@@ -336,6 +343,10 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkDatatables(data.workspace) ?? [])
: actual.WorkspaceService.listDataTableTables(data),
listDucklakes: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkDucklakes(data.workspace) ?? [])
: actual.WorkspaceService.listDucklakes(data),
getDataTableTableSchema: async (data: {
workspace: string
datatableName: string
@@ -351,6 +362,12 @@ vi.mock('$lib/gen', async () => {
})
: actual.WorkspaceService.getDataTableTableSchema(data)
}),
DataMetricService: wrapService(actual.DataMetricService, {
listDataMetrics: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? { metrics: listBenchmarkDataMetrics(data.workspace) ?? [] }
: actual.DataMetricService.listDataMetrics(data)
}),
ScheduleService: wrapService(actual.ScheduleService, {
existsSchedule: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data),
+1 -12
View File
@@ -1,9 +1,8 @@
import { randomUUID } from "node:crypto";
import type { WindmillBackendSettings } from "../../core/windmillBackendSettings";
import { buildWorkspaceId } from "./workspaceId";
const tokenCache = new Map<string, Promise<string>>();
const sharedWorkspaceQueue = new Map<string, Promise<void>>();
const DEFAULT_WORKSPACE_PREFIX = "ai-evals";
export class WindmillBackendClient {
constructor(private readonly settings: WindmillBackendSettings) {}
@@ -179,16 +178,6 @@ async function withSharedWorkspaceLock<T>(
}
}
function buildWorkspaceId(caseId: string, attempt: number): string {
const caseSlug = caseId
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
const suffix = randomUUID().slice(0, 8);
return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || "case"}-a${attempt}-${suffix}`;
}
async function expectOk(response: Response, context: string): Promise<void> {
if (response.ok) {
return;
@@ -0,0 +1,21 @@
import { describe, expect, it } from "bun:test";
import { buildWorkspaceId } from "./workspaceId";
describe("buildWorkspaceId", () => {
// `workspace.proper_id` rejects `--`, which a case id can carry itself and
// which truncating a slug on a hyphen produces once the suffix adds its own.
// One id per shape: cut landing on a hyphen, cut landing mid-word, no cut, and
// a doubled hyphen no cut ever reaches.
it("stays within the id length cap and the proper_id format", () => {
for (const caseId of [
"global-test6-secret-variable-draft",
"global-test23-datatable-query-select",
"short",
"global--test-foo",
]) {
const id = buildWorkspaceId(caseId, 1);
expect(id.length).toBeLessThanOrEqual(50);
expect(id).toMatch(/^\w+(-\w+)*$/);
}
});
});
+22
View File
@@ -0,0 +1,22 @@
import { randomUUID } from "node:crypto";
const DEFAULT_WORKSPACE_PREFIX = "ai-evals";
// A workspace id must be at most 50 characters AND match `^\w+(-\w+)*$`
// (`workspace.proper_id`), so the case slug yields to the random suffix that
// makes the id unique, and no hyphen may end up doubled — neither one already in
// the case id nor one a truncation leaves for the suffix to follow.
const MAX_WORKSPACE_ID_LENGTH = 50;
export function buildWorkspaceId(caseId: string, attempt: number): string {
const caseSlug = caseId
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "");
const suffix = `-a${attempt}-${randomUUID().slice(0, 8)}`;
const head = `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || "case"}`;
return `${head
.slice(0, MAX_WORKSPACE_ID_LENGTH - suffix.length)
.replace(/-+$/, "")}${suffix}`;
}
+62 -14
View File
@@ -889,13 +889,13 @@
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- get_job_logs
- get_run
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
- write_script
toolCallArgs:
- tool: get_job_logs
- tool: get_run
field: id
stringIncludesAnyOf:
- 01920000-0000-7000-8000-0000000000f1
@@ -906,6 +906,34 @@
- fetches the logs for the requested job id
- explains the failure from the returned logs (connection refused to the upstream API)
- id: global-run-args-and-result
prompt: |-
What was the run 01920000-0000-7000-8000-0000000000f2 called with, and what did it return?
initial: ai_evals/fixtures/frontend/global/initial/jobs_seed.json
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- get_run
forbiddenToolsUsed:
- test_run_script
- run_script
- deploy_workspace_item
toolCallArgs:
- tool: get_run
field: id
stringIncludesAnyOf:
- 01920000-0000-7000-8000-0000000000f2
# Read-only, so no draft for the global judge to score — validated on tool use
# and the deterministic argument check, like the neighbouring run cases.
skipJudge: true
judgeChecklist:
- reports the arguments the run was called with (region emea, 12 recipients)
- reports what the run returned (12 sent, 3 skipped)
- does not start a new run to find out
# --- Page navigation (open_page) ---
# The assistant should take the user to a Windmill page (Runs/Schedules) with the
# right filters via open_page, rather than describing where to click or dumping the
@@ -1891,10 +1919,11 @@
- when the lookup fails, tells the user instead of inventing table names
- does not write scripts or resources to answer a read-only question
# --- API catalog (search_api_endpoints / call_api_get / call_api_endpoint) ---
# The harness serves the catalog and the executed calls itself (mock
# listMcpTools + benchmark fetch handlers in adapters/frontend), so these cases
# do not require an mcp-enabled eval backend.
# --- Dedicated tools preferred over the API catalog ---
# The harness serves worker/queue reads itself (benchmark fetch handlers in
# adapters/frontend), so these cases do not require an mcp-enabled eval backend.
# The stale `api-catalog` in the id below is kept so results stay comparable
# across benchmark runs.
- id: global-test30-api-catalog-workers
prompt: |-
@@ -1906,23 +1935,42 @@
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- search_api_endpoints
- call_api_get
- list_workers
forbiddenToolsUsed:
- call_api_get
- call_api_endpoint
- write_script
- deploy_workspace_item
toolCallArgs:
- tool: call_api_get
field: name
stringIncludesAnyOf:
- listWorkers
# Read-only workspace inspection produces no draft; validate via tool use.
skipJudge: true
judgeChecklist:
- discovers the workers endpoint through the API catalog instead of guessing or fabricating
- reads worker state through list_workers instead of guessing or fabricating
- reports worker status from the returned data
- id: global-test37-ducklake-declared-measure
prompt: |-
We track orders in the main ducklake. Write me a duckdb script that reports total
revenue by month. Keep it as a draft, don't deploy it.
initial: ai_evals/fixtures/frontend/global/initial/ducklake_orders_metrics.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
toolExpect:
requiredToolsUsed:
- list_data_metrics
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
# The judge runs: the point is not that the tool was called but that the number it
# describes is the declared one. `revenue` excludes test rows, so an aggregate that
# reproduces it without the filter is plausible, runnable and wrong.
judgeChecklist:
- totals revenue with the declared sum over the amount column rather than an invented aggregate over a guessed column
- excludes test orders from the total, as the declared revenue measure does
- groups by month using the declared order_month expression over order_date
- does not introduce column names absent from the declarations
- id: global-test31-draft-test-run-not-deployed
prompt: |-
Update `f/evals/global/format_greeting` so the provided name is uppercased in the greeting, then run it with name "ada" to check it works.
@@ -0,0 +1,36 @@
{
"workspace": {
"ducklakes": ["main"],
"dataMetrics": [
{
"script_path": "f/analytics/orders_pipeline",
"table_path": "main/main.orders",
"kind": "measure",
"name": "revenue",
"expr": "sum(amount)",
"filter": "not is_test"
},
{
"script_path": "f/analytics/orders_pipeline",
"table_path": "main/main.orders",
"kind": "measure",
"name": "order_count",
"expr": "count(*)"
},
{
"script_path": "f/analytics/orders_pipeline",
"table_path": "main/main.orders",
"kind": "dimension",
"name": "order_month",
"expr": "date_trunc('month', order_date)"
},
{
"script_path": "f/analytics/orders_pipeline",
"table_path": "main/main.orders",
"kind": "dimension",
"name": "region",
"expr": "region"
}
]
}
}
@@ -15,6 +15,8 @@
"jobKind": "script",
"createdBy": "bob",
"success": true,
"args": { "region": "emea", "dry_run": false, "recipients": 12 },
"result": { "sent": 12, "skipped": 3, "digest_url": "https://reports.example.com/d/2026-06-09" },
"logs": "Generating daily digest...\nDigest emailed to 12 recipients\nDone in 1.2s"
},
{
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation c\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": []
},
"hash": "07a005f0f9e80a156cd2a5a0ae39a1fabeaa167818206a25abfe31d5582f942a"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": []
},
"hash": "462d2b2822b185a6f51fafcfa957cb3b31ee6b69abae79a214dddba0dee4425c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2",
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_test",
"type_info": "Bool"
}
],
"parameters": {
@@ -52,8 +57,9 @@
true,
false,
false,
false,
false
]
},
"hash": "c383cc023714b361d10c10e8fef1fc148ab1da942951ee9ffdddaecee76a6be9"
"hash": "48c8522a4fed219c5011f4ba63c81cfe028a8b2a32bd790840cef65c452a8c31"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM flow_conversation WHERE id = ANY($1) AND workspace_id = $2 ORDER BY id FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
false
]
},
"hash": "4f52bf546579f26a1d22c239b8b0054b753cfbeb5dad1e8120fd5e8a672d50ef"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE flow_conversation SET title = $1, updated_at = updated_at\n WHERE id = $2 AND workspace_id = $3\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "5b9c9eb64051f291fed4be9bc0b0cc0aef2e7bde732899976eddac36a2da7658"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)\n RETURNING m.conversation_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "conversation_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": [
false
]
},
"hash": "69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
"query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (id) DO NOTHING\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test",
"describe": {
"columns": [
{
@@ -37,6 +37,11 @@
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_test",
"type_info": "Bool"
}
],
"parameters": {
@@ -45,7 +50,8 @@
"Varchar",
"Varchar",
"Varchar",
"Varchar"
"Varchar",
"Bool"
]
},
"nullable": [
@@ -55,8 +61,9 @@
true,
false,
false,
false,
false
]
},
"hash": "6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe"
"hash": "6d259b8cce5da5fecefe4ce322789b6b2cc43b51f2056c677d58f39c31fb26cb"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_agent_memory a\n USING flow_conversation c\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND a.conversation_id = c.id\n AND a.workspace_id = c.workspace_id\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": []
},
"hash": "89ea81b765550cf665e30533efc9672f8c98d2579fc72c07752361cc5fd683dc"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n j.args as \"args: Json<HashMap<String, Box<RawValue>>>\",\n js.flow_status as \"flow_status: Json<windmill_common::flow_status::FlowStatus>\"\n FROM v2_job_status js\n INNER JOIN v2_job j ON j.id = js.id\n WHERE js.id = $1\n ",
"query": "\n SELECT\n j.args as \"args: Json<HashMap<String, Box<RawValue>>>\",\n js.flow_status as \"flow_status: Json<windmill_common::flow_status::FlowStatus>\",\n j.runnable_path\n FROM v2_job_status js\n INNER JOIN v2_job j ON j.id = js.id\n WHERE js.id = $1\n ",
"describe": {
"columns": [
{
@@ -12,6 +12,11 @@
"ordinal": 1,
"name": "flow_status: Json<windmill_common::flow_status::FlowStatus>",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "runnable_path",
"type_info": "Varchar"
}
],
"parameters": {
@@ -20,9 +25,10 @@
]
},
"nullable": [
true,
true,
true
]
},
"hash": "dd89d652154748d6d7e625e31778f6885d0ee62d29a4b8894a4b459dd215a103"
"hash": "9008f9abb70a9a07e38acb20bea6a710d0efd77dac4aedeb88d72240e816530b"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation c\n WHERE c.id = ANY($1)\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "90b910da8d00a7c7bcf29c167e38e44eb1c0062a8241dc8fe0ed3dd95b65f89a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "conversation_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
false
]
},
"hash": "967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_agent_memory a\n USING flow_conversation c\n WHERE c.id = ANY($1)\n AND a.conversation_id = c.id\n AND a.workspace_id = c.workspace_id\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "a1b23f3e62c6433d95cdac58215741a51ca0ce66bf1c674097705cf2e1b72eff"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation_message WHERE job_id = ANY($1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "bfdd60b42e32bd81e2d20b327462893147b4e5ff078531de36147d908132d636"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n ))\n RETURNING token_prefix",
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n ))\n RETURNING token_prefix",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "31ed2fb85c0c726e3cf6392be2a73c62bae004b2842a4828c6807232570f83a1"
"hash": "d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65"
}
@@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2\n FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "flow_path",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "title",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "is_test",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
false
]
},
"hash": "dd84f9dfb238d18cb74f9e43228345427131bf8008eba6920021d6a449791534"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM flow_conversation WHERE id = ANY($1) ORDER BY id FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
false
]
},
"hash": "ec295b3890a0018475ec0a3774c7a30d71a5689efe72daf58bd1e8f6cf90c410"
}
+1
View File
@@ -15665,6 +15665,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yml",
"sha1",
"sha2 0.10.9",
"size",
"spki",
@@ -0,0 +1 @@
ALTER TABLE flow_conversation DROP COLUMN is_test;
@@ -0,0 +1,26 @@
-- A chat run from the flow editor's test panel is stored exactly like one from the
-- deployed flow, so the two were indistinguishable once written. Marking them lets the
-- lists tell a trial apart from a real conversation.
ALTER TABLE flow_conversation ADD COLUMN is_test BOOLEAN NOT NULL DEFAULT false;
-- Existing rows: a conversation whose messages came from a flowpreview run was a test.
-- Derived once here because the job is purged on retention, after which the origin of an
-- old conversation is unknowable.
--
-- Walked to the root job rather than matched directly: an existing message row never holds
-- the flow job itself. The rows point at the step that produced them — the AI agent's job
-- for an answer, the tool's own job for a tool call — whose kind is never 'flowpreview'.
--
-- `root_job` first, matching `get_root_job_id` (windmill-worker/src/common.rs): only it
-- reaches the top of the run. `flow_innermost_root_job` stops at the closest flow scope by
-- design, so an agent inside a subflow would land on that subflow's 'flow' row and the
-- conversation would read as deployed.
UPDATE flow_conversation c
SET is_test = true
WHERE EXISTS (
SELECT 1 FROM flow_conversation_message m
JOIN v2_job j ON j.id = m.job_id
JOIN v2_job root
ON root.id = coalesce(j.root_job, j.flow_innermost_root_job, j.parent_job, j.id)
WHERE m.conversation_id = c.id AND root.kind = 'flowpreview'
);
+3 -15
View File
@@ -4712,17 +4712,11 @@ const GIT_AUTO_PULL_LOCK_ID: i64 = 737_483_921;
/// Poll every git-sync repository with auto-pull enabled and enqueue a pull when
/// the tracked branch has new commits (repo → Windmill direction).
///
/// Runs on a single replica at a time (advisory lock) and only on
/// Enterprise-licensed instances. Detection is `git ls-remote`; GitHub-App
/// repositories are skipped here and sync via webhooks instead (phase 2).
/// Runs on a single replica at a time (advisory lock). Detection is
/// `git ls-remote`; GitHub-App repositories are skipped here and sync via
/// webhooks instead (phase 2).
#[cfg(feature = "private")]
pub async fn poll_git_auto_pull(db: &Pool<Postgres>) {
use windmill_common::ee_oss::{get_license_plan, LicensePlan};
if !matches!(get_license_plan().await, LicensePlan::Enterprise) {
return;
}
let mut lock_conn = match db.acquire().await {
Ok(c) => c,
Err(e) => {
@@ -4792,12 +4786,6 @@ const GIT_CREDENTIAL_LOCK_ID: i64 = 737_483_923;
/// sync down on its expiry date.
#[cfg(all(feature = "enterprise", feature = "private"))]
async fn maintain_git_credentials(db: &Pool<Postgres>) {
use windmill_common::ee_oss::{get_license_plan, LicensePlan};
if !matches!(get_license_plan().await, LicensePlan::Enterprise) {
return;
}
// Transaction-scoped advisory lock, as for the schedule reconcile above: a
// session lock on a pooled connection would ride back into the pool still
// held if the sweep died before unlocking, and wedge the pass on every
+1 -1
View File
@@ -99,7 +99,7 @@ email_trigger: path(char), local_part(char), workspaced_local_part(bool), script
favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind)
flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[])
FK: (workspace_id) -> workspace(id)
flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char)
flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char), is_test(bool)
FK: (workspace_id) -> workspace(id)
flow_conversation_message: id(uuid), conversation_id(uuid), message_type(message_type), content(text), job_id(uuid), created_at(ts), created_seq(int8), step_name(char), success(bool)
FK: (conversation_id) -> flow_conversation(id) | (job_id) -> v2_job(id)
+248 -1
View File
@@ -37,7 +37,7 @@ async fn seed_side_rows(db: &Pool<Postgres>, ws: &str, job_id: Uuid) -> anyhow::
.bind(ws)
.execute(db)
.await?;
// created_seq is assigned by a trigger; inserting a value is rejected.
// created_seq is an identity column; supplying a value is rejected.
sqlx::query(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
VALUES ($1, 'assistant', 'hi', $2)",
@@ -121,6 +121,185 @@ async fn test_delete_jobs_removes_side_rows(db: Pool<Postgres>) -> anyhow::Resul
Ok(())
}
/// (conversation rows, agent-memory rows) for one conversation.
async fn conversation_and_memory_counts(
db: &Pool<Postgres>,
conversation_id: Uuid,
) -> anyhow::Result<(i64, i64)> {
Ok((
count(
db,
"SELECT count(*) FROM flow_conversation WHERE id = $1",
conversation_id,
)
.await?,
count(
db,
"SELECT count(*) FROM ai_agent_memory WHERE conversation_id = $1",
conversation_id,
)
.await?,
))
}
/// A conversation outlives the jobs behind its messages until the last one goes: only then
/// are the row and the agent's memory for it left with nothing, and only then are they
/// deleted. Both halves matter — the surviving half is what a single data-modifying CTE
/// would break, since its emptiness check would read the snapshot from before the delete.
#[sqlx::test(fixtures("base"))]
async fn test_delete_jobs_removes_a_conversation_once_its_last_message_goes(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let first_job = Uuid::new_v4();
let second_job = Uuid::new_v4();
insert_job(&db, WS, first_job).await?;
insert_job(&db, WS, second_job).await?;
let conv_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by)
VALUES ($1, $2, 'f/flow', 'test-user')",
)
.bind(conv_id)
.bind(WS)
.execute(&db)
.await?;
for job_id in [first_job, second_job] {
sqlx::query(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
VALUES ($1, 'assistant', 'hi', $2)",
)
.bind(conv_id)
.bind(job_id)
.execute(&db)
.await?;
}
sqlx::query(
"INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages)
VALUES ($1, $2, 'a', '[]'::jsonb)",
)
.bind(WS)
.bind(conv_id)
.execute(&db)
.await?;
let mut conn = db.acquire().await?;
windmill_common::jobs::delete_jobs(&mut conn, &[first_job]).await?;
drop(conn);
assert_eq!(
conversation_and_memory_counts(&db, conv_id).await?,
(1, 1),
"a conversation with a message left must survive, memory included"
);
let mut conn = db.acquire().await?;
windmill_common::jobs::delete_jobs(&mut conn, &[second_job]).await?;
drop(conn);
assert_eq!(
conversation_and_memory_counts(&db, conv_id).await?,
(0, 0),
"the last message going should take the conversation and its memory"
);
Ok(())
}
/// Turns that start while retention is collecting their conversation must land, not fail:
/// the conversation lookup locks the row, so each turn waits for the collector's commit,
/// finds the conversation gone, and creates it again — the first insert wins and the other
/// reads its row. Without the lock a turn's message insert is what waits, on the parent
/// row's key lock, and fails its FK check afterwards.
#[sqlx::test(fixtures("base"))]
async fn test_new_turns_wait_for_conversation_cleanup_and_recreate(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let old_job = Uuid::new_v4();
let new_jobs = [Uuid::new_v4(), Uuid::new_v4()];
insert_job(&db, WS, old_job).await?;
for job in new_jobs {
insert_job(&db, WS, job).await?;
}
let conv_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by)
VALUES ($1, $2, 'f/flow', 'test-user')",
)
.bind(conv_id)
.bind(WS)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
VALUES ($1, 'user', 'hi', $2)",
)
.bind(conv_id)
.bind(old_job)
.execute(&db)
.await?;
// The collector holds the conversation row locked and deleted, uncommitted.
let mut cleanup = db.begin().await?;
windmill_common::jobs::delete_jobs(&mut *cleanup, &[old_job]).await?;
let turns: Vec<_> = new_jobs
.into_iter()
.map(|new_job| {
let db = db.clone();
tokio::spawn(async move {
let mut tx = db.begin().await?;
windmill_common::flow_conversations::get_or_create_conversation_with_id(
&mut tx,
WS,
"f/flow",
"test-user",
"hi again",
conv_id,
false,
)
.await?;
windmill_common::flow_conversations::add_message_to_conversation_tx(
&mut tx,
conv_id,
Some(new_job),
"hi again",
windmill_common::flow_conversations::MessageType::User,
None,
true,
)
.await?;
tx.commit().await?;
anyhow::Ok(())
})
})
.collect();
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
cleanup.commit().await?;
for turn in turns {
turn.await??;
}
assert_eq!(
conversation_and_memory_counts(&db, conv_id).await?.0,
1,
"the turns must have created the conversation again, once"
);
assert_eq!(
count(
&db,
"SELECT count(*) FROM flow_conversation_message WHERE conversation_id = $1",
conv_id,
)
.await?,
2,
"both turns' messages should be there"
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_clear_schedule_removes_side_rows(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -192,6 +371,74 @@ async fn test_workspace_delete_removes_side_rows(db: Pool<Postgres>) -> anyhow::
Ok(())
}
/// The purge endpoint carries its own copy of the emptied-conversation rule, so it gets the
/// same guard: the conversation and its memory go with the last message, and not before.
#[sqlx::test(fixtures("base"))]
async fn test_jobs_export_delete_removes_a_conversation_once_its_last_message_goes(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let first_job = Uuid::new_v4();
let second_job = Uuid::new_v4();
insert_job(&db, WS, first_job).await?;
insert_job(&db, WS, second_job).await?;
let conv_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by)
VALUES ($1, $2, 'f/flow', 'test-user')",
)
.bind(conv_id)
.bind(WS)
.execute(&db)
.await?;
for job_id in [first_job, second_job] {
sqlx::query(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
VALUES ($1, 'assistant', 'hi', $2)",
)
.bind(conv_id)
.bind(job_id)
.execute(&db)
.await?;
}
sqlx::query(
"INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages)
VALUES ($1, $2, 'a', '[]'::jsonb)",
)
.bind(WS)
.bind(conv_id)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let purge = |job_id: Uuid| async move {
reqwest::Client::new()
.post(format!("http://localhost:{port}/api/w/{WS}/jobs/delete"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&[job_id])
.send()
.await
};
assert!(purge(first_job).await?.status().is_success());
assert_eq!(
conversation_and_memory_counts(&db, conv_id).await?,
(1, 1),
"a conversation with a message left must survive the purge endpoint too"
);
assert!(purge(second_job).await?.status().is_success());
assert_eq!(
conversation_and_memory_counts(&db, conv_id).await?,
(0, 0),
"the last message going should take the conversation and its memory"
);
Ok(())
}
/// The `/jobs/delete` purge endpoint must scope every side-table delete to the path
/// workspace. A `test-workspace` admin passing a job id from another workspace must not be
/// able to delete that workspace's job or side rows (the side tables no longer cascade, so
+57 -7
View File
@@ -78,17 +78,50 @@ impl Default for OutputType {
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Memory {
Off,
Auto {
#[serde(default)]
Window {
#[serde(default, deserialize_with = "deserialize_null_as_zero")]
context_length: usize,
#[serde(default)]
},
/// Written before `window`. Its `memory_id` stays a fallback behind the run's memory id.
Auto {
#[serde(default, deserialize_with = "deserialize_null_as_zero")]
context_length: usize,
#[serde(default, deserialize_with = "deserialize_blank_as_none")]
memory_id: Option<Uuid>,
},
/// Written before a step had history inputs of its own, and read on its own where it remains.
Manual {
messages: Vec<OpenAIMessage>,
},
}
// An editor form can leave `""` in a legacy baked id it never filled; it means no id rather than
// failing every run of the step.
fn deserialize_blank_as_none<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Uuid>, D::Error> {
match <Option<String> as serde::Deserialize>::deserialize(deserializer)? {
Some(id) if !id.trim().is_empty() => Uuid::parse_str(id.trim())
.map(Some)
.map_err(serde::de::Error::custom),
_ => Ok(None),
}
}
// A count the editor's number field was cleared of is stored as `null`, which `default` does not
// cover; it reads as 0, memory off, rather than failing every run of the step.
fn deserialize_null_as_zero<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<usize, D::Error> {
<Option<usize> as serde::Deserialize>::deserialize(deserializer).map(Option::unwrap_or_default)
}
fn deserialize_present<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<serde_json::Value>, D::Error> {
<serde_json::Value as serde::Deserialize>::deserialize(deserializer).map(Some)
}
#[derive(Deserialize)]
struct AIAgentArgsRaw {
provider: ProviderWithResource,
@@ -103,6 +136,12 @@ struct AIAgentArgsRaw {
streaming: Option<bool>,
max_iterations: Option<usize>,
memory: Option<Memory>,
// A null must stay distinguishable from an absent key: a step whose own memory id evaluates to
// nothing runs stateless instead of falling back to the run's memory id.
#[serde(default, deserialize_with = "deserialize_present")]
memory_id: Option<serde_json::Value>,
#[serde(default)]
previous_messages: Option<Vec<OpenAIMessage>>,
enabled_tools: Option<Vec<String>>,
// Legacy field for backward compatibility
messages_context_length: Option<usize>,
@@ -124,6 +163,10 @@ pub struct AIAgentArgs {
pub streaming: Option<bool>,
pub max_iterations: Option<usize>,
pub memory: Option<Memory>,
/// Memory id set on the step, overriding the run's. Empty when its expression produced none.
pub memory_id: Option<String>,
/// History supplied by the flow, replayed without reading or writing memory.
pub previous_messages: Option<Vec<OpenAIMessage>>,
/// Which of the agent's tools this run may call; `narrow_roster` holds what the names are and
/// what `None` means.
pub enabled_tools: Option<Vec<String>>,
@@ -139,12 +182,17 @@ impl From<AIAgentArgsRaw> for AIAgentArgs {
});
// Backward compatibility: if context_length is 0, use off mode
let memory = memory.map(|memory| {
if let Memory::Auto { context_length: 0, .. } = memory {
let memory = memory.map(|memory| match memory {
Memory::Auto { context_length: 0, .. } | Memory::Window { context_length: 0 } => {
Memory::Off
} else {
memory
}
memory => memory,
});
let memory_id = raw.memory_id.map(|value| match value {
serde_json::Value::Null => String::new(),
serde_json::Value::String(s) => s.trim().to_string(),
value => value.to_string(),
});
AIAgentArgs {
@@ -159,6 +207,8 @@ impl From<AIAgentArgsRaw> for AIAgentArgs {
streaming: raw.streaming,
max_iterations: raw.max_iterations,
memory,
memory_id,
previous_messages: raw.previous_messages,
enabled_tools: raw.enabled_tools,
credentials_check: raw.credentials_check.unwrap_or(false),
}
+3
View File
@@ -1141,6 +1141,9 @@ impl NewToken {
/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally
/// skip it, since their scopes derive from the action being authorized, not the
/// caller's token).
///
/// A token the system mints for itself with an `expiration` needs a label reserved in
/// `windmill_common::auth::is_user_token`, or its expiry alerts its owner (docs/auth-surface.md).
pub async fn create_token_internal(
tx: &mut sqlx::PgConnection,
db: &DB,
@@ -1,6 +1,6 @@
use axum::{
extract::{Path, Query},
routing::{delete, get},
routing::{delete, get, post},
Extension, Json, Router,
};
use chrono::{DateTime, Utc};
@@ -15,13 +15,14 @@ use windmill_common::{
db::{UserDB, DB},
error::{JsonResult, Result},
flow_conversations::MessageType,
utils::{not_found_if_none, paginate, Pagination},
utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination},
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_conversations))
.route("/delete/{conversation_id}", delete(delete_conversation))
.route("/update/{conversation_id}", post(update_conversation))
.route("/{conversation_id}/messages", get(list_messages))
}
@@ -38,9 +39,22 @@ pub struct FlowConversationMessage {
pub success: bool,
}
/// Which conversations a listing holds. A test chat was started from the editor's test
/// panel; a deployed one from the flow itself.
#[derive(Deserialize, Default, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum ConversationKind {
Test,
/// The default: a deployed flow's chat should not surface someone's trial runs.
#[default]
Deployed,
All,
}
#[derive(Deserialize)]
pub struct ListConversationsQuery {
pub flow_path: Option<String>,
pub kind: Option<ConversationKind>,
}
#[derive(Deserialize)]
@@ -67,6 +81,7 @@ async fn list_conversations(
"created_at",
"updated_at",
"created_by",
"is_test",
])
.and_where_eq("workspace_id", "?".bind(&w_id));
@@ -74,6 +89,16 @@ async fn list_conversations(
sqlb.and_where_eq("flow_path", "?".bind(flow_path));
}
match query.kind.unwrap_or_default() {
ConversationKind::Test => {
sqlb.and_where_eq("is_test", "true");
}
ConversationKind::Deployed => {
sqlb.and_where_eq("is_test", "false");
}
ConversationKind::All => {}
}
sqlb.order_by("updated_at", true)
.limit(per_page as i64)
.offset(offset as i64);
@@ -101,7 +126,7 @@ async fn delete_conversation(
// Verify the conversation exists and belongs to the user
let conversation = sqlx::query_as!(
FlowConversation,
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test
FROM flow_conversation
WHERE id = $1 AND workspace_id = $2",
conversation_id,
@@ -148,6 +173,50 @@ async fn delete_conversation(
Ok(format!("Conversation {} deleted", conversation_id))
}
#[derive(Deserialize)]
pub struct UpdateConversation {
pub title: String,
}
async fn update_conversation(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, conversation_id)): Path<(String, Uuid)>,
Json(update): Json<UpdateConversation>,
) -> Result<String> {
// Postgres refuses a NUL in a text column, so it must not reach the query as a 500.
if update.title.contains('\0') {
return Err(windmill_common::error::Error::BadRequest(
"title cannot contain a NUL character".to_string(),
));
}
// The column is VARCHAR(255) and the helper appends an ellipsis to what it cuts, so the
// bound it takes is three short of the column's. A longer title would otherwise reach
// Postgres as a 22001 and come back a 500.
let title = truncate_with_ellipsis(update.title.trim(), 252);
let mut tx = user_db.clone().begin(&authed).await?;
// `updated_at` is kept: the list is ordered by it, and a rename must not move the
// chat to the top the way a new turn does.
let updated = sqlx::query_scalar!(
"UPDATE flow_conversation SET title = $1, updated_at = updated_at
WHERE id = $2 AND workspace_id = $3
RETURNING id",
title,
conversation_id,
&w_id
)
.fetch_optional(&mut *tx)
.await?;
not_found_if_none(updated, "Conversation", conversation_id.to_string())?;
tx.commit().await?;
Ok(format!("Conversation {} updated", conversation_id))
}
async fn list_messages(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
+5
View File
@@ -154,6 +154,7 @@ async fn list_flows(
"favorite.path IS NOT NULL as starred",
"ws_error_handler_muted",
"o.labels",
"(o.value->>'chat_input_enabled')::bool as chat_input_enabled",
"draft.email IS NOT NULL as is_draft",
// Per-path draft owners as a JSON array; see scripts.rs for the rationale
// (non-member superadmin identity fallback via `password`, legacy NULL-email row).
@@ -301,6 +302,10 @@ async fn list_flows(
ws_error_handler_muted: None,
deployment_msg: None,
labels: None,
chat_input_enabled: v
.get("value")
.and_then(|fv| fv.get("chat_input_enabled"))
.and_then(|b| b.as_bool()),
// No deployed row to inherit folder labels from.
inherited_labels: None,
is_draft: true,
@@ -179,9 +179,11 @@ async fn test_trigger_token_labels_still_creatable(db: Pool<Postgres>) -> anyhow
"http-test-user-2-cd34",
"email-test-user-2-ef56",
"my-ci-token",
// Minted client-side by the editor (every TypeScript editor load) and the debugger.
// Minted client-side by the editor (every TypeScript editor load), the debugger and
// the object-storage "Test from a worker" button.
"Ephemeral lsp token",
"debugger-token",
"ephemeral-test-connection: s3_bucket",
] {
let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await;
assert_eq!(
+20 -5
View File
@@ -653,9 +653,11 @@ pub async fn set_flow_memory_id(
pub async fn process_flow_run_query_params(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
job_id: Uuid,
w_id: &str,
flow_path: &str,
run_query: &RunJobQuery,
) -> error::Result<()> {
if let Some(memory_id) = run_query.memory_id {
if let Some(memory_id) = run_query.memory_key(w_id, flow_path) {
set_flow_memory_id(tx, job_id, memory_id).await?;
}
Ok(())
@@ -668,10 +670,17 @@ pub async fn handle_chat_conversation_messages(
flow_path: &str,
run_query: &RunJobQuery,
user_message_raw: Option<&Box<serde_json::value::RawValue>>,
job_id: Uuid,
is_test: bool,
) -> error::Result<()> {
let memory_id = run_query.memory_id.ok_or_else(|| {
// Names the query parameter rather than the field: it is not a flow argument, and
// supplying it as one is the first thing tried on reading `memory_id is required`.
let memory_id = run_query.memory_key(w_id, flow_path).ok_or_else(|| {
windmill_common::error::Error::BadRequest(
"memory_id is required for chat-enabled flows".to_string(),
"memory_id is required for chat-enabled flows. Pass it as the `memory_id` query \
parameter, not as a flow argument: it names the conversation the turn belongs to, \
so a fresh UUID starts one and reusing a UUID continues it."
.to_string(),
)
})?;
@@ -695,13 +704,17 @@ pub async fn handle_chat_conversation_messages(
&authed.username,
&user_message,
memory_id,
is_test,
)
.await?;
// The run this message started. Its args are the only record of what the message
// carried besides its text — attachments and every other flow input — and nothing
// written later points at them: an assistant row holds the AI agent step's job.
add_message_to_conversation_tx(
tx,
memory_id,
None,
Some(job_id),
&user_message,
MessageType::User,
None,
@@ -813,7 +826,7 @@ pub async fn run_flow<'c>(
.await?;
// Set memory_id if provided (for agent memory)
if let Some(memory_id) = run_query.memory_id {
if let Some(memory_id) = run_query.memory_key(w_id, flow_path) {
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
}
@@ -826,6 +839,8 @@ pub async fn run_flow<'c>(
&flow_path.to_string(),
&run_query,
args.args.get("user_message"),
uuid,
false,
)
.await?;
}
+55 -5
View File
@@ -692,16 +692,64 @@ pub async fn delete_jobs(
.await?
.rows_affected();
let conversation_message_deleted = sqlx::query!(
// One row per message deleted, so the conversation of a chat losing several appears
// several times: the count is taken before the dedup below.
let mut conversation_ids: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM flow_conversation_message m
USING flow_conversation c
WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)",
WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)
RETURNING m.conversation_id",
&w_id,
&job_ids
)
.execute(&mut *tx)
.await?
.rows_affected();
.fetch_all(&mut *tx)
.await?;
let conversation_message_deleted = conversation_ids.len() as u64;
// Same rule, lock and statement order as retention (windmill_common::jobs::delete_jobs,
// which says why): a conversation with no messages left goes, and its memory with it.
conversation_ids.sort_unstable();
conversation_ids.dedup();
let mut memory_deleted = 0;
let mut conversation_deleted = 0;
if !conversation_ids.is_empty() {
sqlx::query_scalar!(
"SELECT id FROM flow_conversation WHERE id = ANY($1) AND workspace_id = $2 ORDER BY id FOR UPDATE",
&conversation_ids,
&w_id
)
.fetch_all(&mut *tx)
.await?;
memory_deleted = sqlx::query!(
"DELETE FROM ai_agent_memory a
USING flow_conversation c
WHERE c.id = ANY($1)
AND c.workspace_id = $2
AND a.conversation_id = c.id
AND a.workspace_id = c.workspace_id
AND NOT EXISTS (
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
)",
&conversation_ids,
&w_id
)
.execute(&mut *tx)
.await?
.rows_affected();
conversation_deleted = sqlx::query!(
"DELETE FROM flow_conversation c
WHERE c.id = ANY($1)
AND c.workspace_id = $2
AND NOT EXISTS (
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
)",
&conversation_ids,
&w_id
)
.execute(&mut *tx)
.await?
.rows_affected();
}
// Resolutions are not exported, so a delete-then-reimport of the same UUID would
// otherwise resurrect the old annotation on a job that never carried one.
@@ -737,6 +785,8 @@ pub async fn delete_jobs(
+ zombie_deleted
+ dispatch_event_deleted
+ conversation_message_deleted
+ memory_deleted
+ conversation_deleted
+ resolution_deleted
+ jobs_deleted;
+13 -1
View File
@@ -47,13 +47,25 @@ pub struct RunJobQuery {
pub cache_ignore_s3_path: Option<bool>,
pub skip_preprocessor: Option<bool>,
pub poll_delay_ms: Option<u64>,
pub memory_id: Option<Uuid>,
/// Any string; see [`RunJobQuery::memory_key`].
pub memory_id: Option<String>,
pub trigger_external_id: Option<String>,
pub service_name: Option<String>,
pub suspended_mode: Option<bool>,
}
impl RunJobQuery {
/// The memory id as stored in `flow_status.memory_id`: a uuid is kept, any other string hashed
/// within the workspace and the flow being run.
pub fn memory_key(&self, workspace_id: &str, flow_path: &str) -> Option<Uuid> {
self.memory_id
.as_deref()
.filter(|memory_id| !memory_id.trim().is_empty())
.map(|memory_id| {
windmill_common::flow_conversations::memory_key(workspace_id, flow_path, memory_id)
})
}
pub async fn get_scheduled_for(
&self,
db: &DB,
+5 -2
View File
@@ -3882,8 +3882,8 @@ async fn update_token_label(
Path(token_prefix): Path<String>,
Json(req): Json<UpdateTokenLabelRequest>,
) -> Result<String> {
// The new label must not collide with a system-token namespace (`session`,
// `ephemeral*`, `debugger-token`, `mcp-oauth-*`): those labels are
// The new label must not collide with a system-token namespace (see
// `windmill_common::auth::is_user_token`): those labels are
// load-bearing, and a user-set collision would orphan the token — hidden
// from the UI (`isUserToken`) and rejected by the editability guard below —
// while it still authenticates. (`is_user_token(None)` is true, so clearing
@@ -3922,6 +3922,9 @@ async fn update_token_label(
AND lower(label) NOT LIKE 'ephemeral%'
AND label <> 'debugger-token'
AND label NOT LIKE 'mcp-oauth-%'
AND NOT starts_with(label, 'embed_app:')
AND NOT starts_with(label, 'sdk_app:')
AND NOT starts_with(label, 'impersonation:')
))
RETURNING token_prefix",
req.label.as_deref(),
@@ -1308,26 +1308,22 @@ async fn get_git_sync_deploy_mode(
let configured = !settings.repositories.is_empty();
// Auto-pull runs only on Enterprise-licensed instances (see poll_git_auto_pull);
// without a caller branch there is nothing to match. Either way deploy_on_push
// stays false and the caller falls back (git push via CI, or wmill sync push).
// Auto-pull runs only in builds that compile the poller (`private`); without a
// caller branch there is nothing to match. Either way deploy_on_push stays
// false and the caller falls back (git push via CI, or wmill sync push).
let Some(branch) = q.branch.as_deref() else {
return Ok(Json(GitSyncDeployMode {
configured,
deploy_on_push: false,
}));
};
let licensed = matches!(
windmill_common::ee_oss::get_license_plan().await,
windmill_common::ee_oss::LicensePlan::Enterprise
);
// Count the auto-pull repos that would deploy this branch. We deliberately do
// not check the caller's remote URL: with exactly one such repo the local
// checkout is unambiguously it, and with several we can't tell which is the
// caller's, so we report false and let the CLI ask the user.
let mut matches = 0u32;
if licensed && !root_deleted {
if cfg!(feature = "private") && !root_deleted {
for repo in &settings.repositories {
let Some(auto_pull) = repo.auto_pull.as_ref() else {
continue;
@@ -3762,54 +3758,6 @@ fn cleanup_legacy_git_sync_settings_in_memory(
#[cfg(not(feature = "enterprise"))]
const CE_GIT_SYNC_MAX_USERS: i64 = 2;
/// Auto-pull is licensed per plan, not just per build: the poller only serves
/// Enterprise plans at runtime, so the save path must reject the setting too —
/// otherwise an EE binary without the plan could still register a webhook and
/// receive webhook-driven pulls.
#[cfg(feature = "enterprise")]
async fn check_git_sync_ee_license(feature: &str) -> Result<()> {
if !matches!(
windmill_common::ee_oss::get_license_plan().await,
windmill_common::ee_oss::LicensePlan::Enterprise
) {
return Err(Error::BadRequest(format!(
"{feature} requires an Enterprise license"
)));
}
Ok(())
}
#[cfg(feature = "enterprise")]
async fn check_auto_pull_license() -> Result<()> {
check_git_sync_ee_license("Automatic pull from git").await
}
/// In-app PR creation (promotion/fork deploy branches) drives GitHub API calls
/// from the deploy completion hook; runtime-gate it like auto-pull.
#[cfg(feature = "enterprise")]
async fn check_open_prs_license<'a>(
mut repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
) -> Result<()> {
if repos.any(|r| r.promotion_open_prs || r.fork_open_prs) {
check_git_sync_ee_license("Opening pull requests from Windmill").await?;
}
Ok(())
}
/// Promotion mode (`use_individual_branch`: per-item `wm_deploy/**` deploy
/// branches) is an EE feature; runtime-gate it like auto-pull and PR creation
/// so an enterprise binary without an active plan can't enable it via either
/// git-sync edit endpoint.
#[cfg(feature = "enterprise")]
async fn check_promotion_license<'a>(
mut repos: impl Iterator<Item = &'a windmill_common::workspaces::GitRepositorySettings>,
) -> Result<()> {
if repos.any(|r| r.use_individual_branch.unwrap_or(false)) {
check_git_sync_ee_license("Promotion mode").await?;
}
Ok(())
}
/// Promotion on a dev workspace needs the dev-aware sync script (hub >= 28796):
/// an older pinned script bundles a CLI that force-disables per-item branches
/// on every fork, so enabling promotion would silently keep deploying to the
@@ -4061,18 +4009,6 @@ async fn edit_git_sync_config(
));
}
#[cfg(feature = "enterprise")]
if git_sync_settings
.repositories
.iter()
.any(|r| r.auto_pull.as_ref().is_some_and(|a| a.enabled))
{
check_auto_pull_license().await?;
}
#[cfg(feature = "enterprise")]
check_open_prs_license(git_sync_settings.repositories.iter()).await?;
#[cfg(feature = "enterprise")]
check_promotion_license(git_sync_settings.repositories.iter()).await?;
#[cfg(feature = "enterprise")]
check_dev_promotion_script_version(&db, &w_id, git_sync_settings.repositories.iter())
.await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
@@ -4310,19 +4246,6 @@ async fn edit_git_sync_repository(
));
}
#[cfg(feature = "enterprise")]
if new_config
.repository
.auto_pull
.as_ref()
.is_some_and(|a| a.enabled)
{
check_auto_pull_license().await?;
}
#[cfg(feature = "enterprise")]
check_open_prs_license(std::iter::once(&new_config.repository)).await?;
#[cfg(feature = "enterprise")]
check_promotion_license(std::iter::once(&new_config.repository)).await?;
#[cfg(feature = "enterprise")]
check_dev_promotion_script_version(&db, &w_id, std::iter::once(&new_config.repository)).await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
check_dev_promotion_targets_parent_repo(&db, &w_id, std::iter::once(&new_config.repository))
@@ -4428,13 +4351,6 @@ async fn edit_git_sync_repository(
}
_ => {}
}
// The request-side license gate above only saw the submitted config; the
// preservation can resurrect an enabled auto_pull (None arm), so re-check
// the effective state before it gets written and reconciled.
#[cfg(feature = "enterprise")]
if updated.auto_pull.as_ref().is_some_and(|a| a.enabled) {
check_auto_pull_license().await?;
}
*existing_repo = updated;
} else {
// Repository doesn't exist, add it as a new repository
+71 -23
View File
@@ -11267,11 +11267,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: script args
@@ -11308,11 +11307,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: script args
@@ -11349,11 +11347,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
responses:
"200":
@@ -11376,11 +11373,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
- name: poll_delay_ms
description: delay between polling for job updates in milliseconds
in: query
@@ -11418,11 +11414,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
- name: poll_delay_ms
description: delay between polling for job updates in milliseconds
in: query
@@ -11458,11 +11453,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
- name: poll_delay_ms
description: delay between polling for job updates in milliseconds
in: query
@@ -11506,11 +11500,10 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
- name: poll_delay_ms
description: delay between polling for job updates in milliseconds
in: query
@@ -12010,6 +12003,13 @@ paths:
properties:
draft_only:
type: boolean
chat_input_enabled:
type: boolean
description: |
`chat_input_enabled` of the flow's value,
projected so the list can mark flows that open
as a chat. Omitted when the value has no such
field.
is_draft:
type: boolean
description: |
@@ -12457,6 +12457,15 @@ paths:
in: query
schema:
type: string
- name: kind
description: which conversations to list - the flow editor's test chats, the deployed flow's own (the default), or both
in: query
schema:
type: string
enum:
- test
- deployed
- all
responses:
"200":
description: flow conversations list
@@ -12467,6 +12476,40 @@ paths:
items:
$ref: "#/components/schemas/FlowConversation"
/w/{workspace}/flow_conversations/update/{conversation_id}:
post:
summary: rename flow conversation
operationId: updateFlowConversation
tags:
- flow_conversations
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: conversation_id
description: conversation id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title]
properties:
title:
type: string
description: the chat's name
responses:
"200":
description: flow conversation updated
content:
text/plain:
schema:
type: string
/w/{workspace}/flow_conversations/delete/{conversation_id}:
delete:
summary: delete flow conversation
@@ -14861,11 +14904,10 @@ paths:
schema:
type: boolean
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: flow args
required: true
@@ -14919,11 +14961,10 @@ paths:
schema:
type: boolean
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: flow args
required: true
@@ -15411,11 +15452,10 @@ paths:
type: boolean
- $ref: "#/components/parameters/NewJobId"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: preview
@@ -15443,11 +15483,10 @@ paths:
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: memory_id
description: memory ID for chat-enabled flows
description: Memory id for the flow's AI agent steps. A uuid is used as is; any other string is hashed within the workspace and flow, so the same string always names the same memory of that flow.
in: query
schema:
type: string
format: uuid
requestBody:
description: preview
@@ -28294,7 +28333,7 @@ components:
FlowConversation:
type: object
required:
[id, workspace_id, flow_path, created_at, updated_at, created_by]
[id, workspace_id, flow_path, created_at, updated_at, created_by, is_test]
properties:
id:
type: string
@@ -28321,6 +28360,9 @@ components:
created_by:
type: string
description: Username who created the conversation
is_test:
type: boolean
description: Started from the flow editor's test panel rather than a deployed run
FlowConversationMessage:
type: object
@@ -28951,6 +28993,12 @@ components:
type: boolean
has_deploy_errors:
type: boolean
chat_input_enabled:
type: boolean
description: >-
flow-only. `chat_input_enabled` of the flow's value, projected so
the list can mark flows that open as a chat. Omitted when the
value has no such field.
raw_app:
type: boolean
execution_mode:
+16 -9
View File
@@ -57,7 +57,7 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::{
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
auth::TOKEN_PREFIX_LEN,
auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN},
cache::{self, future::FutureCachedExt},
db::{DbWithOptAuthed, UserDB},
error::{to_anyhow, Error, JsonResult, Result},
@@ -1522,7 +1522,10 @@ async fn mint_raw_app_sdk_token(
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
(label, exp)
}
None => (format!("sdk_app:{app_path}"), requested_exp),
None => (
format!("{RAW_APP_SDK_TOKEN_LABEL_PREFIX}{app_path}"),
requested_exp,
),
};
let token_config = NewToken::new(
Some(label),
@@ -1804,7 +1807,10 @@ pub async fn mint_app_embed_token(
scopes.push(windmill_api_auth::scopes::GUEST_SENTINEL.to_string());
(label, exp)
}
None => (format!("embed_app:{app_path}"), requested_exp),
None => (
format!("{APP_EMBED_TOKEN_LABEL_PREFIX}{app_path}"),
requested_exp,
),
};
let token_config = NewToken::new(
Some(label),
@@ -4304,11 +4310,11 @@ async fn execute_component(
}
}
let is_flow = payload
let flow_path = payload
.path
.as_ref()
.map(|p| p.starts_with("flow/"))
.unwrap_or(false);
.as_deref()
.and_then(|path| path.strip_prefix("flow/"))
.map(str::to_string);
// Tag for inline-script jobs is read from the deployed policy in run mode;
// only preview mode (editor) honors the client-supplied tag. This applies to
@@ -4438,8 +4444,9 @@ async fn execute_component(
// Apply runnable query parameters if provided
if let Some(ref run_query) = payload.run_query_params {
if is_flow {
crate::jobs::process_flow_run_query_params(&mut tx, uuid, run_query).await?;
if let Some(flow_path) = flow_path.as_deref() {
crate::jobs::process_flow_run_query_params(&mut tx, uuid, &w_id, flow_path, run_query)
.await?;
}
}
+4 -1
View File
@@ -9540,7 +9540,7 @@ async fn run_preview_flow_job(
.await?;
// Set memory_id if provided (for agent memory)
if let Some(memory_id) = run_query.memory_id {
if let Some(memory_id) = run_query.memory_key(&w_id, &flow_path) {
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
}
@@ -9553,6 +9553,9 @@ async fn run_preview_flow_job(
&flow_path,
&run_query,
user_message.as_ref(),
uuid,
// Run from the editor's test panel: a trial, not a real conversation.
true,
)
.await?;
}
+20 -5
View File
@@ -116,6 +116,10 @@ struct RunnableItem {
use_codebase: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
has_deploy_errors: Option<bool>,
// flow-only: projected from the value so the home list can badge flows that
// open as a chat without fetching every flow's value.
#[serde(skip_serializing_if = "Option::is_none")]
chat_input_enabled: Option<bool>,
// app-only
#[serde(skip_serializing_if = "Option::is_none")]
raw_app: Option<bool>,
@@ -274,7 +278,7 @@ fn branch_sqls() -> Branches {
o.ws_error_handler_muted, o.created_at as edited_at, \
o.hash, o.language::text as language, o.kind::text as script_kind, o.auto_kind, \
o.codebase IS NOT NULL as use_codebase, \
(o.lock_error_logs IS NOT NULL) as has_deploy_errors, \
(o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \
FROM script o \
@@ -291,6 +295,7 @@ fn branch_sqls() -> Branches {
o.ws_error_handler_muted, o.edited_at, \
NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
(o.value->>'chat_input_enabled')::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
FROM flow o \
@@ -306,7 +311,7 @@ fn branch_sqls() -> Branches {
{draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \
NULL::bool as ws_error_handler_muted, av.created_at as edited_at, \
NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \
av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \
o.versions[array_upper(o.versions, 1)] as version, \
COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
@@ -344,11 +349,21 @@ fn draft_branch_sql(kind: &str) -> String {
let kind_cols = match kind {
"script" => {
"d.value->>'language' as language, d.value->>'kind' as script_kind, \
d.value->>'auto_kind' as auto_kind, false as raw_app"
d.value->>'auto_kind' as auto_kind, false as raw_app, \
NULL::bool as chat_input_enabled"
}
// Type-guarded rather than a bare `::bool` cast: draft JSON is stored
// unvalidated, so a malformed value must yield NULL, not abort the list,
// and the string "true" must not count as enabled.
"flow" => {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
false as raw_app, \
CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \
THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled"
}
_ => {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
(d.typ = 'raw_app') as raw_app"
(d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled"
}
};
format!(
@@ -359,7 +374,7 @@ fn draft_branch_sql(kind: &str) -> String {
NULL::text[] as labels, NULL::text[] as inherited_labels, \
NULL::bool as ws_error_handler_muted, o.created_at as edited_at, \
NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \
o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \
FROM ( \
+1
View File
@@ -33,6 +33,7 @@ path = "src/lib.rs"
tar.workspace = true
hmac.workspace = true
sha2.workspace = true
sha1.workspace = true
thiserror.workspace = true
anyhow.workspace = true
serde.workspace = true
+26 -7
View File
@@ -19,10 +19,11 @@ use crate::{
};
/// Whether `label` denotes a user-created token rather than a system token
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
/// labels are load-bearing — session cleanup, super_admin propagation, expiry
/// notifications and username overrides all key off them — so they must not be
/// user-editable. `None` (no label) is treated as a user token.
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`,
/// `embed_app:*`, `sdk_app:*`, `impersonation:*`). System-token labels are load-bearing —
/// session cleanup, super_admin propagation, expiry notifications and username overrides
/// all key off them — so they must not be user-editable. `None` (no label) is treated as
/// a user token.
///
/// This is the canonical copy. When updating it, also update its mirrors:
/// - the `update_token_label` editability guard (SQL `WHERE`) in
@@ -40,15 +41,30 @@ pub fn is_user_token(label: Option<&str>) -> bool {
&& !l.to_lowercase().starts_with("ephemeral")
&& l != "debugger-token"
&& !l.starts_with("mcp-oauth-")
// Short-lived tokens the server mints per app open or per service-account
// impersonation (EE `users_ee.rs`) and nobody manages, so an expiry warning
// for one is noise.
&& !l.starts_with(APP_EMBED_TOKEN_LABEL_PREFIX)
&& !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX)
&& !l.starts_with("impersonation:")
}
}
}
/// Label prefix, followed by the app path, of the token an app viewer's sandboxed iframe
/// runs with. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:";
/// Label prefix, followed by the app path, of the token a raw app's bundle uses for the
/// frontend SDK. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out.
pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:";
/// Whether `label` belongs to a namespace only the server mints, and which therefore must be
/// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`
/// and `debugger-token` are minted by the editor and the debugger through that same handler,
/// so reserving them would break those features.
/// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`,
/// `debugger-token` and `ephemeral-test-connection: *` are minted by the editor, the debugger
/// and object-storage connection tests through that same handler, so reserving them would
/// break those features.
///
/// `username_override_from_label` trusts a label to name the entity acting only if it is in
/// here, so anything added must be unmintable by a member.
@@ -961,6 +977,9 @@ mod tests {
assert!(!is_user_token(Some("Ephemeral lsp token")));
assert!(!is_user_token(Some("debugger-token")));
assert!(!is_user_token(Some("mcp-oauth-client")));
assert!(!is_user_token(Some("embed_app:f/team/dashboard")));
assert!(!is_user_token(Some("sdk_app:u/admin/raw app")));
assert!(!is_user_token(Some("impersonation:admin@windmill.dev")));
}
#[test]
+111 -22
View File
@@ -7,6 +7,29 @@ use crate::db::DB;
use crate::error::Result;
use crate::utils::truncate_with_ellipsis;
/// Changing it detaches every memory stored under a string memory id.
const MEMORY_ID_NAMESPACE: Uuid = Uuid::from_u128(0x6f1c2d4e_8a3b_5c7d_9e0f_1a2b3c4d5e6f);
/// Memory is stored and carried in `flow_status.memory_id` as a uuid, which names the same memory
/// wherever it is passed, as a chat conversation id must. Any other string names a memory through a
/// name-based (v5) uuid scoped to its workspace and flow, so the same key in two flows or two
/// workspaces names two memories, and chat conversation ids stay unique across workspaces.
pub fn memory_key(workspace_id: &str, flow_path: &str, memory_id: &str) -> Uuid {
let memory_id = memory_id.trim();
Uuid::parse_str(memory_id).unwrap_or_else(|_| {
use sha1::{Digest, Sha1};
let mut hasher = Sha1::new();
hasher.update(MEMORY_ID_NAMESPACE.as_bytes());
for part in [workspace_id, flow_path, memory_id] {
hasher.update(part.as_bytes());
hasher.update([0u8]);
}
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&hasher.finalize()[..16]);
uuid::Builder::from_sha1_bytes(bytes).into_uuid()
})
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)]
#[sqlx(type_name = "MESSAGE_TYPE", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
@@ -26,8 +49,12 @@ pub struct FlowConversation {
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub created_by: String,
/// Started from the flow editor's test panel rather than a deployed run.
pub is_test: bool,
}
/// `is_test` is written on insert. An existing conversation of the other kind refuses the
/// turn, so preview and deployed runs never share one.
pub async fn get_or_create_conversation_with_id(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
@@ -35,42 +62,81 @@ pub async fn get_or_create_conversation_with_id(
username: &str,
title: &str,
conversation_id: Uuid,
is_test: bool,
) -> Result<FlowConversation> {
// Check if conversation already exists
let existing_conversation = sqlx::query_as!(
FlowConversation,
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
FROM flow_conversation
WHERE id = $1 AND workspace_id = $2",
conversation_id,
w_id
)
.fetch_optional(&mut **tx)
.await?;
if let Some(existing) = existing_conversation {
return Ok(existing);
if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? {
return same_kind(existing, is_test);
}
// Truncate title to 25 characters max
let title = truncate_with_ellipsis(title, 25);
// Create new conversation with provided ID
let conversation = sqlx::query_as!(
// Every turn released by the same collector's commit finds no row: the first insert
// wins, the others wait on it, do nothing, and read the row it created.
let created = sqlx::query_as!(
FlowConversation,
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO NOTHING
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test",
conversation_id,
w_id,
flow_path,
username,
title
title,
is_test
)
.fetch_one(&mut **tx)
.fetch_optional(&mut **tx)
.await?;
if let Some(conversation) = created {
return Ok(conversation);
}
Ok(conversation)
// The concurrent first turn that won the insert may have been of the other kind.
let existing = lock_conversation(tx, w_id, conversation_id)
.await?
.ok_or_else(|| {
crate::error::Error::BadRequest(format!(
"conversation {conversation_id} belongs to another workspace"
))
})?;
same_kind(existing, is_test)
}
/// `memory_id` is the caller's to choose, so a preview run could name a deployed
/// conversation and the reverse. A conversation's kind is fixed at creation and nothing
/// would show the mixing afterwards, so the turn is refused before it starts.
fn same_kind(existing: FlowConversation, is_test: bool) -> Result<FlowConversation> {
if existing.is_test == is_test {
return Ok(existing);
}
Err(crate::error::Error::BadRequest(if existing.is_test {
"this conversation was started from the flow editor's test panel; start a new conversation to run the deployed flow".to_string()
} else {
"this conversation belongs to the deployed flow; start a new conversation to test from the flow editor".to_string()
}))
}
/// Locked, so a turn orders against retention collecting the conversation
/// (windmill_common::jobs::delete_jobs): either the turn goes first and the collector then
/// sees its message, or it waits and finds the row gone and creates it again. Unlocked, the
/// message insert would wait on the parent row's lock instead and then fail its FK check.
async fn lock_conversation(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
conversation_id: Uuid,
) -> Result<Option<FlowConversation>> {
Ok(sqlx::query_as!(
FlowConversation,
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test
FROM flow_conversation
WHERE id = $1 AND workspace_id = $2
FOR UPDATE",
conversation_id,
w_id
)
.fetch_optional(&mut **tx)
.await?)
}
/// Add a message to a conversation using an existing transaction
@@ -143,3 +209,26 @@ pub async fn delete_conversation_memory(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A string names a memory only within its workspace and flow; a uuid is used as is.
#[test]
fn memory_key_scopes_strings_but_not_uuids() {
let key = memory_key("ws", "f/support/triage", " customer-1 ");
assert_eq!(key, memory_key("ws", "f/support/triage", "customer-1"));
assert_ne!(
key,
memory_key("other_ws", "f/support/triage", "customer-1")
);
assert_ne!(key, memory_key("ws", "f/sales/triage", "customer-1"));
let conversation = Uuid::from_u128(7).to_string();
assert_eq!(memory_key("ws", "f/a", &conversation), Uuid::from_u128(7));
assert_eq!(
memory_key("other_ws", "f/b", &conversation),
Uuid::from_u128(7)
);
}
}
+52 -3
View File
@@ -478,6 +478,12 @@ pub static WORKER_INTERNAL_SERVER_INLINE_UTILS: OnceCell<WorkerInternalServerInl
/// set-based deletes below cost one scan per table per call instead. Because the cascade no
/// longer fires, every code path that deletes from `v2_job` by id must go through this helper
/// (or delete these tables itself) or it will leave orphan rows behind.
/// **Transaction contract:** call this inside a transaction. The conversation cleanup below
/// locks rows to serialise itself against a concurrent delete, and on an autocommit
/// connection that lock is released at statement end, silently restoring the race.
/// A conversation is collected only once every message row of it has gone with a job; a
/// row written with no job id (an MCP tool call, persisted under no job of its own) keeps
/// its conversation and the agent's memory for it alive for as long as it exists.
pub async fn delete_jobs(conn: &mut sqlx::PgConnection, ids: &[uuid::Uuid]) -> error::Result<()> {
sqlx::query!(
"DELETE FROM dispatch_event WHERE producer_job_id = ANY($1)",
@@ -485,12 +491,55 @@ pub async fn delete_jobs(conn: &mut sqlx::PgConnection, ids: &[uuid::Uuid]) -> e
)
.execute(&mut *conn)
.await?;
sqlx::query!(
"DELETE FROM flow_conversation_message WHERE job_id = ANY($1)",
let mut conversation_ids: Vec<uuid::Uuid> = sqlx::query_scalar!(
"DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id",
ids
)
.execute(&mut *conn)
.fetch_all(&mut *conn)
.await?;
conversation_ids.sort_unstable();
conversation_ids.dedup();
if !conversation_ids.is_empty() {
// A conversation is a view over its messages: once the last one goes with its job,
// the row and the agent's memory for it are all that is left, and nothing else
// collects them — `ai_agent_memory` carries no job id for retention to match on.
// Two statements rather than one CTE: a data-modifying CTE reads the snapshot from
// before the delete above, so every conversation would still look non-empty.
// Two calls each deleting one of a conversation's last messages would each still see
// the other's row — uncommitted deletes are invisible across transactions — so
// neither would collect it and nothing would try again. Taking the conversation row
// first serialises them: the second reads the first's delete and finds it empty.
sqlx::query_scalar!(
"SELECT id FROM flow_conversation WHERE id = ANY($1) ORDER BY id FOR UPDATE",
&conversation_ids
)
.fetch_all(&mut *conn)
.await?;
// Memory first, since it reads the conversation row for its workspace.
sqlx::query!(
"DELETE FROM ai_agent_memory a
USING flow_conversation c
WHERE c.id = ANY($1)
AND a.conversation_id = c.id
AND a.workspace_id = c.workspace_id
AND NOT EXISTS (
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
)",
&conversation_ids
)
.execute(&mut *conn)
.await?;
sqlx::query!(
"DELETE FROM flow_conversation c
WHERE c.id = ANY($1)
AND NOT EXISTS (
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
)",
&conversation_ids
)
.execute(&mut *conn)
.await?;
}
sqlx::query!("DELETE FROM zombie_job_counter WHERE job_id = ANY($1)", ids)
.execute(&mut *conn)
.await?;
+37 -6
View File
@@ -976,18 +976,33 @@ fn six_fields_hint(schedule_str: &str, version: Option<&str>, seconds_required:
}
impl ScheduleType {
/// `NotFound` means the expression has no run left (an expired year, an impossible
/// date), and schedule pushes disable the schedule on it. Every other error must stay
/// transient: croner fails across a DST jump longer than an hour (Antarctica/Troll)
/// and succeeds again once the jump has passed.
pub fn find_next(
&self,
starting_from: &chrono::DateTime<chrono_tz::Tz>,
) -> chrono::DateTime<chrono_tz::Tz> {
) -> Result<chrono::DateTime<chrono_tz::Tz>> {
let no_run_left = || {
Error::NotFound(format!(
"cron: the schedule has no run left after {}",
starting_from.format("%Y-%m-%d %H:%M:%S %Z")
))
};
match self {
ScheduleType::Croner(croner_schedule) => croner_schedule
.find_next_occurrence(starting_from, false)
.expect("cron: a schedule should have a next event"),
ScheduleType::Cron(schedule) => schedule
.after(starting_from)
.next()
.expect("cron: a schedule should have a next event"),
.map_err(|e| match e {
croner::errors::CronError::TimeSearchLimitExceeded => no_run_left(),
e => Error::internal_err(format!(
"cron: could not compute the run after {}: {e}",
starting_from.format("%Y-%m-%d %H:%M:%S %Z")
)),
}),
ScheduleType::Cron(schedule) => {
schedule.after(starting_from).next().ok_or_else(no_run_left)
}
}
}
@@ -1709,6 +1724,22 @@ mod tests {
assert!(!err.contains("6 fields"), "{err}");
}
#[test]
fn find_next_reports_only_a_cron_with_no_run_left_as_not_found() {
use chrono::TimeZone;
let troll: chrono_tz::Tz = "Antarctica/Troll".parse().unwrap();
// Troll's clocks jump from 01:00 to 03:00 on the last Sunday of March.
let before_jump = troll.with_ymd_and_hms(2027, 3, 28, 0, 30, 0).unwrap();
let expired = ScheduleType::from_str("0 0 9 1 1 * 2026", Some("v1"), true).unwrap();
let err = expired.find_next(&before_jump).unwrap_err();
assert!(matches!(err, Error::NotFound(_)), "{err}");
let across_jump = ScheduleType::from_str("0 30 1 * * *", Some("v2"), true).unwrap();
let err = across_jump.find_next(&before_jump).unwrap_err();
assert!(!matches!(err, Error::NotFound(_)), "{err}");
}
/// A worker that restarts must land on the exact same name to reclaim its `worker_ping`
/// row, while still never colliding with the other workers of its own process. The
/// suffix must also stay a single `-` segment, which is what the interactive shell tag
+1 -4
View File
@@ -166,13 +166,10 @@ pub async fn push_scheduled_job<'c>(
}
};
let next = sched.find_next(&starting_from);
// println!("next event ({:?}): {}", tz, next);
// println!("next event(UTC): {}", next.with_timezone(&chrono::Utc));
let next = sched.find_next(&starting_from)?;
// Scheduled events must be stored in the database in UTC
let next = next.with_timezone(&chrono::Utc);
// panic!("next: {}", next);
let already_exists: bool = sqlx::query_scalar!(
// Query plan:
// - use of the `ix_v2_job_root_by_path` index; hence the `parent_job IS NULL` clause.
@@ -921,6 +921,47 @@ mod schedule_push {
Ok(())
}
// -----------------------------------------------------------------------
// try_schedule_next_job: a cron with no run left disables the schedule
// -----------------------------------------------------------------------
#[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))]
async fn test_cron_with_no_run_left_disables_schedule(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, email, extra_perms, ws_error_handler_muted, no_flow_overlap, permissioned_as, cron_version)
VALUES ('test-workspace', 'f/system/test_schedule', 'test-user', now(), '0 0 9 1 1 * 2020', 'UTC', true, 'f/system/test_script', false, 'test@windmill.dev', '{}', false, false, 'u/test-user', 'v1')"
)
.execute(&db)
.await?;
let schedule = make_schedule(|s| {
s.schedule = "0 0 9 1 1 * 2020".to_string();
s.cron_version = Some("v1".to_string());
});
let job = make_completed_job(&schedule);
let tx = db.begin().await?;
let (tx, err) =
try_schedule_next_job(&db, tx, &job, &schedule, &schedule.script_path).await;
assert!(err.is_none(), "completion must go through, got: {err:?}");
tx.commit().await?;
assert_eq!(count_queued_jobs(&db).await, 0);
let (enabled, error): (bool, Option<String>) = sqlx::query_as(
"SELECT enabled, error FROM schedule WHERE workspace_id = 'test-workspace' AND path = 'f/system/test_schedule'",
)
.fetch_one(&db)
.await?;
assert!(!enabled, "schedule with no run left must be disabled");
assert!(
error.as_deref().is_some_and(|e| e.contains("no run left")),
"error should say why, got: {error:?}"
);
Ok(())
}
// -----------------------------------------------------------------------
// try_schedule_next_job: disabled schedule leaves no side effects
// -----------------------------------------------------------------------
+7 -1
View File
@@ -98,6 +98,11 @@ pub struct ListableFlow {
pub deployment_msg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
/// Projected from the flow value so a list can mark a flow that opens as a
/// chat without fetching every flow's value.
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_input_enabled: Option<bool>,
/// True when the authed user has a draft for this flow (draft-only or layered
/// over the deployed row). See ListableScript in scripts.rs.
#[serde(default)]
@@ -1095,7 +1100,8 @@ pub enum FlowModuleValue {
omit_output_from_conversation: bool,
/// When set, the agent brain config (provider/model/system prompt/etc.) and tools are
/// resolved at runtime from this `ai_agent` resource path (hybrid linking). The module's
/// `input_transforms` then only carry the flow-local inputs (user_message/user_attachments).
/// `input_transforms` then only carry the flow-local inputs: user_message,
/// user_attachments, enabled_tools and the history inputs memory_id and previous_messages.
#[serde(default, skip_serializing_if = "Option::is_none")]
agent: Option<String>,
/// Binds an agent's tools to *this* flow's context, keyed by tool id then input key, without
+5 -1
View File
@@ -154,6 +154,8 @@ pub async fn get_flow_job_runnable_and_raw_flow(
pub struct FlowContext {
pub flow_inputs: Option<HashMap<String, Box<RawValue>>>,
pub flow_status: Option<windmill_common::flow_status::FlowStatus>,
/// Path of the flow the run started from, which scopes a string memory id.
pub flow_path: Option<String>,
}
/// Get flow context (chat settings + args + flow_status) from root flow's job data
@@ -171,7 +173,8 @@ pub async fn get_flow_context(db: &DB, job: &MiniPulledJob) -> FlowContext {
r#"
SELECT
j.args as "args: Json<HashMap<String, Box<RawValue>>>",
js.flow_status as "flow_status: Json<windmill_common::flow_status::FlowStatus>"
js.flow_status as "flow_status: Json<windmill_common::flow_status::FlowStatus>",
j.runnable_path
FROM v2_job_status js
INNER JOIN v2_job j ON j.id = js.id
WHERE js.id = $1
@@ -184,6 +187,7 @@ pub async fn get_flow_context(db: &DB, job: &MiniPulledJob) -> FlowContext {
Ok(Some(row)) => FlowContext {
flow_inputs: row.args.map(|j| j.0),
flow_status: row.flow_status.map(|j| j.0),
flow_path: row.runnable_path,
},
Ok(None) => {
tracing::warn!(
+473 -88
View File
@@ -44,7 +44,7 @@ use windmill_common::{
client::AuthedClient,
db::DB,
error::{self, Error},
flow_conversations::MessageType,
flow_conversations::{memory_key, MessageType},
flow_status::AgentAction,
flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue},
get_latest_hash_for_path,
@@ -111,6 +111,148 @@ fn prepare_auto_memory_messages_for_persistence(
non_system_messages[start_idx..].to_vec()
}
/// The inputs a linked step supplies for itself; the resource holds the rest of the brain.
const FLOW_LOCAL_AGENT_KEYS: [&str; 5] = [
"user_message",
"user_attachments",
"enabled_tools",
"memory_id",
"previous_messages",
];
/// The flow-local inputs that name a conversation, which a saved agent never carries.
const STEP_HISTORY_KEYS: [&str; 2] = ["memory_id", "previous_messages"];
/// Where one agent invocation's history comes from.
#[derive(Debug)]
enum HistorySource<'a> {
/// Supplied by the flow and replayed as is: memory is neither read nor written.
Messages(&'a [OpenAIMessage]),
Window {
memory_id: Uuid,
context_length: usize,
},
Stateless,
}
/// A step's memory id counts only as the step authored it. A static empty value is a form
/// placeholder, so it reads as unset rather than as an expression that evaluated to nothing, which
/// runs without memory; an AI-filled value would let the model choose which memory the agent reads.
fn keep_authored_memory_id(
args: &mut AIAgentArgs,
step_input_transforms: &HashMap<String, InputTransform>,
) {
match step_input_transforms.get("memory_id") {
Some(InputTransform::Javascript { .. }) => {}
Some(InputTransform::Static { .. }) if args.memory_id.as_deref() != Some("") => {}
_ => args.memory_id = None,
}
}
/// Reconciles the step's history inputs, the agent's memory policy and the run's memory id. A step
/// holds one of two shapes: an older `auto` or `manual` memory, read as the editor that wrote it
/// meant it, or the current setting plus the step's own history inputs. Also returns lines for the
/// job log: an input that went unused, or a policy that remembers ending up stateless.
fn resolve_history_source<'a>(
args: &'a AIAgentArgs,
run_memory_id: Option<Uuid>,
workspace_id: &str,
flow_path: &str,
) -> (HistorySource<'a>, Vec<&'static str>) {
let mut notes = Vec::new();
let no_memory_id = "No memory id was passed to this run, so the agent runs without memory.";
match &args.memory {
// The step's own history inputs came after these, so a step that still holds one reads it
// alone: what it did before the editor offered them is what it keeps doing.
Some(Memory::Manual { messages }) => {
note_unread_step_inputs(&mut notes, args);
(HistorySource::Messages(messages), notes)
}
Some(Memory::Auto { context_length, memory_id }) => {
note_unread_step_inputs(&mut notes, args);
// An id baked in at save time only ever applied when the run carried none.
match run_memory_id.or(*memory_id) {
Some(memory_id) => (
HistorySource::Window { memory_id, context_length: *context_length },
notes,
),
None => {
notes.push(no_memory_id);
(HistorySource::Stateless, notes)
}
}
}
Some(Memory::Window { context_length }) => {
if args
.previous_messages
.as_ref()
.is_some_and(|messages| !messages.is_empty())
{
notes.push("Managed memory is on, so this step's previous messages are ignored.");
}
let memory_id = match args.memory_id.as_deref() {
Some("") => {
notes.push(
"This step's memory id evaluated to an empty value, so the agent runs without memory.",
);
return (HistorySource::Stateless, notes);
}
Some(step_memory_id) => memory_key(workspace_id, flow_path, step_memory_id),
None => match run_memory_id {
Some(memory_id) => memory_id,
None => {
notes.push(no_memory_id);
return (HistorySource::Stateless, notes);
}
},
};
(
HistorySource::Window { memory_id, context_length: *context_length },
notes,
)
}
Some(Memory::Off) | None => {
if args.memory_id.as_deref().is_some_and(|id| !id.is_empty()) {
notes.push("Managed memory is off, so this step's memory id is ignored.");
}
match &args.previous_messages {
Some(messages) => (HistorySource::Messages(messages), notes),
None => (HistorySource::Stateless, notes),
}
}
}
}
/// An older memory setting reads neither history input, which is only visible in the job log: the
/// editor offers them on a step that has been moved to the current settings.
fn note_unread_step_inputs(notes: &mut Vec<&'static str>, args: &AIAgentArgs) {
if args.memory_id.as_deref().is_some_and(|id| !id.is_empty()) {
notes.push("This step uses an older memory setting, so its memory id is not read.");
}
if args
.previous_messages
.as_ref()
.is_some_and(|messages| !messages.is_empty())
{
notes
.push("This step uses an older memory setting, so its previous messages are not read.");
}
}
/// Whether a request has something to ask the model. Only text output sends previous messages, so
/// an image prompt comes from the user message alone. An empty list is no conversation, except
/// under a legacy `manual` memory, which ran on whatever list it held.
fn has_prompt(
history: &HistorySource,
has_user_message: bool,
is_text_output: bool,
legacy_list: bool,
) -> bool {
has_user_message
|| (is_text_output
&& (legacy_list || matches!(history, HistorySource::Messages(m) if !m.is_empty())))
}
fn find_module_by_id(
modules: &Vec<FlowModule>,
target_id: &str,
@@ -136,14 +278,16 @@ async fn find_ai_agent_tool_module_in_parent_agent(
return Ok(None);
};
let FlowModuleValue::AIAgent { tools, agent, .. } = parent_agent_module.get_value()? else {
let FlowModuleValue::AIAgent { tools, agent, tool_inputs, .. } =
parent_agent_module.get_value()?
else {
return Ok(None);
};
// A linked parent carries no tools on the module (they live in the resource, resolved only in
// the main execution branch). Resolve them from the resource here too, so a nested agent tool
// of a saved+linked agent can still be located when it runs as its own job.
let tools = if let Some(agent_ref) = agent.as_deref() {
let mut tools = if let Some(agent_ref) = agent.as_deref() {
let agent_path = agent_ref
.trim_start_matches("$res:")
.trim_start_matches("res://");
@@ -170,6 +314,9 @@ async fn find_ai_agent_tool_module_in_parent_agent(
} else {
tools
};
// The nested job reads its history inputs from the tool's transforms, which must carry the
// host flow's bindings as the parent evaluated them.
overlay_tool_inputs(&mut tools, &tool_inputs);
for tool in tools {
if tool.id == tool_module_id {
@@ -456,6 +603,7 @@ pub async fn handle_ai_agent_job(
omit_output_from_conversation,
agent,
tool_inputs,
input_transforms: step_input_transforms,
..
} = module.get_value()?
else {
@@ -466,9 +614,11 @@ pub async fn handle_ai_agent_job(
// A linked step takes its brain and tools from the resource and keeps only its own flow-local
// inputs. The brain and the roster stay rigid; what the step binds to this flow is the message
// it asks, which of those tools this use may call, the conversation it is part of, and the
// tools' own inputs — the last overlaid from `tool_inputs` below.
let (args, tools): (AIAgentArgs, Vec<AgentTool>) = if let Some(agent_ref) = agent.as_deref() {
// it asks, which of those tools this use may call, the conversation it is part of (its memory
// id and previous messages), and the tools' own inputs — the last overlaid from `tool_inputs`
// below.
let (mut args, tools): (AIAgentArgs, Vec<AgentTool>) = if let Some(agent_ref) = agent.as_deref()
{
let agent_path = agent_ref
.trim_start_matches("$res:")
.trim_start_matches("res://");
@@ -500,6 +650,12 @@ pub async fn handle_ai_agent_job(
None => Vec::new(),
};
overlay_tool_inputs(&mut tools, &tool_inputs);
// The resource is not validated against a schema, so a history input it happens to carry
// is dropped before interpolation, where a bad `$res:` in it would fail the step. The
// other flow-local keys stay: a resource's own user message is the step's fallback.
for key in STEP_HISTORY_KEYS {
config.remove(key);
}
let brain = transform_json_value(
"ai_agent",
client,
@@ -521,7 +677,7 @@ pub async fn handle_ai_agent_job(
// Only after interpolating the resource: these are caller-controlled and already resolved by
// build_args_map, so passing them through it again would expand contextual values —
// `$WM_TOKEN` in a user message would reach the model provider.
for key in ["user_message", "user_attachments", "enabled_tools"] {
for key in FLOW_LOCAL_AGENT_KEYS {
if let Some(v) = local_args.get(key) {
brain.insert(
key.to_string(),
@@ -546,6 +702,8 @@ pub async fn handle_ai_agent_job(
(args, tools)
};
keep_authored_memory_id(&mut args, &step_input_transforms);
// Nesting is capped at flow → agent → nested agent. When this job is itself a nested tool,
// a linked resource's tool set may still contain AIAgent tools (the editor can't constrain a
// shared resource); don't advertise them — invoking one would only fail the depth check as a
@@ -1010,8 +1168,18 @@ pub async fn run_agent(
// Fetch flow context for input transforms context, chat and memory
let mut flow_context = get_flow_context(db, job).await;
// Determine if we're using manual messages (which bypasses memory)
let use_manual_messages = matches!(args.memory, Some(Memory::Manual { .. }));
// The run's memory id is also the chat conversation id, which a step's own memory id never
// replaces.
let conversation_id = flow_context
.flow_status
.as_ref()
.and_then(|fs| fs.memory_id);
let (history, history_notes) = resolve_history_source(
args,
conversation_id,
&job.workspace_id,
flow_context.flow_path.as_deref().unwrap_or_default(),
);
// Check if user_message is provided and non-empty
let has_user_message = args
@@ -1020,63 +1188,63 @@ pub async fn run_agent(
.map(|m| !m.is_empty())
.unwrap_or(false);
// Validate: at least one of memory with manual messages or user_message must be provided
if !use_manual_messages && !has_user_message {
return Err(Error::internal_err(
"Either 'memory' with manual messages or 'user_message' must be provided".to_string(),
));
}
let is_text_output = output_type == &OutputType::Text;
// Flow-level memory_id (from chat mode) takes precedence over step-level memory_id
let memory_id = flow_context
.flow_status
.as_ref()
.and_then(|fs| fs.memory_id)
.or_else(|| {
// Extract memory_id from Memory::Auto if present
match &args.memory {
Some(Memory::Auto { memory_id, .. }) => *memory_id,
_ => None,
}
});
if is_text_output {
for note in &history_notes {
append_logs(&job.id, &job.workspace_id, format!("{note}\n"), conn).await;
}
} else if !matches!(args.memory, None | Some(Memory::Off))
|| args.memory_id.is_some()
|| args.previous_messages.is_some()
{
append_logs(
&job.id,
&job.workspace_id,
"Image output sends no history, so memory and previous messages are not read.\n",
conn,
)
.await;
}
// A `manual` memory sent whatever list it held, an empty one included, so a step that still has
// one keeps running without a user message.
let legacy_list = matches!(args.memory, Some(Memory::Manual { .. }));
if !has_prompt(&history, has_user_message, is_text_output, legacy_list) {
let missing = if !is_text_output {
"'user_message' must be provided for image output"
} else if matches!(
args.memory,
Some(Memory::Window { .. } | Memory::Auto { .. })
) {
"'user_message' must be provided while managed memory is on"
} else {
"Either 'previous_messages' or 'user_message' must be provided"
};
return Err(Error::internal_err(missing.to_string()));
}
// Load messages based on history mode
if matches!(output_type, OutputType::Text) {
match &args.memory {
Some(Memory::Manual { messages: manual_messages }) => {
// Use explicitly provided messages (bypass memory)
if !manual_messages.is_empty() {
messages.extend(manual_messages.clone());
}
}
Some(Memory::Auto { context_length, .. }) => {
// Auto mode: load from memory
match &history {
HistorySource::Messages(provided) => messages.extend(provided.iter().cloned()),
HistorySource::Window { memory_id, context_length } => {
if let Some(step_id) = effective_flow_step_id {
if let Some(memory_id) = memory_id {
// Read messages from memory
match read_from_memory(db, &job.workspace_id, memory_id, step_id).await {
Ok(Some(loaded_messages)) => {
let messages_to_load = prepare_auto_memory_messages_for_request(
&loaded_messages,
*context_length,
);
messages.extend(messages_to_load);
}
Ok(None) => {}
Err(e) => {
tracing::error!(
"Failed to read memory for step {}: {}",
step_id,
e
);
}
match read_from_memory(db, &job.workspace_id, *memory_id, step_id).await {
Ok(Some(loaded_messages)) => {
let messages_to_load = prepare_auto_memory_messages_for_request(
&loaded_messages,
*context_length,
);
messages.extend(messages_to_load);
}
Ok(None) => {}
Err(e) => {
tracing::error!("Failed to read memory for step {}: {}", step_id, e);
}
}
}
}
_ => {}
HistorySource::Stateless => {}
}
}
@@ -1521,7 +1689,7 @@ pub async fn run_agent(
..Default::default()
});
if persist_output_to_conversation {
if let Some(memory_id) = memory_id {
if let Some(conversation_id) = conversation_id {
let agent_job_id = job.id;
let db_clone = db.clone();
let message_content = "Used websearch tool successfully".to_string();
@@ -1529,7 +1697,7 @@ pub async fn run_agent(
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
&memory_id,
&conversation_id,
Some(agent_job_id),
&message_content,
MessageType::Tool,
@@ -1540,7 +1708,7 @@ pub async fn run_agent(
{
tracing::warn!(
"Failed to add websearch tool message to conversation {}: {}",
memory_id,
conversation_id,
e
);
}
@@ -1573,7 +1741,7 @@ pub async fn run_agent(
// Add assistant message to conversation if chat_input_enabled
if persist_output_to_conversation && !response_content.is_empty() {
if let Some(memory_id) = memory_id {
if let Some(conversation_id) = conversation_id {
let agent_job_id = job.id;
let db_clone = db.clone();
let message_content = response_content.clone();
@@ -1583,7 +1751,7 @@ pub async fn run_agent(
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
&memory_id,
&conversation_id,
Some(agent_job_id),
&message_content,
MessageType::Assistant,
@@ -1594,7 +1762,7 @@ pub async fn run_agent(
{
tracing::warn!(
"Failed to add assistant message to conversation {}: {}",
memory_id,
conversation_id,
e
);
}
@@ -1692,7 +1860,7 @@ pub async fn run_agent(
// Add assistant message to conversation if chat_input_enabled
if persist_output_to_conversation {
if let Some(memory_id) = memory_id {
if let Some(conversation_id) = conversation_id {
let agent_job_id = job.id;
let db_clone = db.clone();
@@ -1710,7 +1878,7 @@ pub async fn run_agent(
tokio::spawn(async move {
if let Err(e) = add_message_to_conversation(
&db_clone,
&memory_id,
&conversation_id,
Some(agent_job_id),
&message_content,
MessageType::Assistant,
@@ -1721,7 +1889,7 @@ pub async fn run_agent(
{
tracing::warn!(
"Failed to add assistant message to conversation {}: {}",
memory_id,
conversation_id,
e
);
}
@@ -1778,13 +1946,10 @@ pub async fn run_agent(
}
}
// Persist complete conversation to memory at the end (only if in auto mode with context length)
// Skip memory persistence if using manual messages (bypass memory entirely)
// final_messages contains the complete history (old messages + new ones)
if matches!(output_type, OutputType::Text) && !use_manual_messages {
if let Some(Memory::Auto { context_length, .. }) = &args.memory {
// final_messages holds the complete history: what was loaded plus this run's messages
if matches!(output_type, OutputType::Text) {
if let HistorySource::Window { memory_id, context_length } = &history {
if let Some(step_id) = effective_flow_step_id {
// Extract OpenAIMessages from final_messages
let all_messages: Vec<OpenAIMessage> =
final_messages.iter().map(|m| m.message.clone()).collect();
@@ -1794,23 +1959,21 @@ pub async fn run_agent(
*context_length,
);
if let Some(memory_id) = memory_id {
if let Err(e) = write_to_memory(
db,
&job.workspace_id,
memory_id,
if let Err(e) = write_to_memory(
db,
&job.workspace_id,
*memory_id,
step_id,
&messages_to_persist,
)
.await
{
tracing::error!(
"Failed to persist {} messages to memory for step {}: {}",
messages_to_persist.len(),
step_id,
&messages_to_persist,
)
.await
{
tracing::error!(
"Failed to persist {} messages to memory for step {}: {}",
messages_to_persist.len(),
step_id,
e
);
}
e
);
}
}
}
@@ -1870,6 +2033,228 @@ mod tests {
}
}
#[derive(Debug, PartialEq)]
enum Resolved {
Messages(usize),
Window(Uuid, usize),
Stateless { noted: bool },
}
/// Every memory shape a worker may still read, resolved against a run with or without a
/// memory id. The hashed id is pinned: changing it detaches memories stored under string ids.
#[test]
fn history_source_resolves_every_memory_shape() {
use serde_json::json;
let run = Uuid::from_u128(1);
let baked = Uuid::from_u128(2);
let cust_1 = Uuid::parse_str("0168fcea-ffa7-5c15-bdb0-7709bb5f540d").unwrap();
let window = json!({ "kind": "window", "context_length": 10 });
let message = json!([{ "role": "user", "content": "earlier" }]);
let two_messages = json!([
{ "role": "user", "content": "earlier" },
{ "role": "assistant", "content": "reply" }
]);
let cases = [
(
"absent memory is off",
json!({}),
Some(run),
Resolved::Stateless { noted: false },
),
(
"legacy off",
json!({ "memory": { "kind": "off" } }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"legacy auto prefers the run's id",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked } }),
Some(run),
Resolved::Window(run, 4),
),
(
"legacy auto falls back to its baked id",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked } }),
None,
Resolved::Window(baked, 4),
),
(
"legacy auto with an empty baked id uses the run's",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": "" } }),
Some(run),
Resolved::Window(run, 4),
),
(
"legacy auto with an empty baked id and no run id is stateless",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": " " } }),
None,
Resolved::Stateless { noted: true },
),
(
"legacy auto without a length is off",
json!({ "memory": { "kind": "auto", "memory_id": baked } }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"a cleared count is off",
json!({ "memory": { "kind": "window", "context_length": null } }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"legacy manual replays its messages",
json!({ "memory": { "kind": "manual", "messages": message } }),
Some(run),
Resolved::Messages(1),
),
(
"window keeps the run's memory",
json!({ "memory": window }),
Some(run),
Resolved::Window(run, 10),
),
(
"window without a memory id is stateless",
json!({ "memory": window }),
None,
Resolved::Stateless { noted: true },
),
(
"a step memory id overrides the run's",
json!({ "memory": window, "memory_id": "cust_1" }),
Some(run),
Resolved::Window(cust_1, 10),
),
(
"a uuid step memory id is used as is",
json!({ "memory": window, "memory_id": baked.to_string() }),
Some(run),
Resolved::Window(baked, 10),
),
(
"a step memory id evaluating to null is stateless",
json!({ "memory": window, "memory_id": null }),
Some(run),
Resolved::Stateless { noted: true },
),
(
"an off policy ignores the step memory id, and says so",
json!({ "memory": { "kind": "off" }, "memory_id": "cust_1" }),
Some(run),
Resolved::Stateless { noted: true },
),
(
"managed memory ignores the step's previous messages",
json!({ "memory": window, "memory_id": "cust_1", "previous_messages": message }),
Some(run),
Resolved::Window(cust_1, 10),
),
(
"memory that is off sends the step's previous messages",
json!({ "previous_messages": message }),
Some(run),
Resolved::Messages(1),
),
(
"a previous messages expression that evaluated to null is no history",
json!({ "previous_messages": null }),
Some(run),
Resolved::Stateless { noted: false },
),
(
"a legacy manual list ignores the step's previous messages",
json!({ "memory": { "kind": "manual", "messages": message }, "previous_messages": two_messages }),
Some(run),
Resolved::Messages(1),
),
(
"legacy auto ignores a step memory id",
json!({ "memory": { "kind": "auto", "context_length": 4, "memory_id": baked }, "memory_id": "cust_1" }),
None,
Resolved::Window(baked, 4),
),
];
for (name, history, run_memory_id, expected) in cases {
let mut raw = json!({ "provider": { "kind": "openai", "resource": {}, "model": "m" } });
raw.as_object_mut()
.unwrap()
.extend(history.as_object().unwrap().clone());
let args: AIAgentArgs = serde_json::from_value(raw).unwrap();
let resolved = match resolve_history_source(&args, run_memory_id, "ws", "f/flow") {
(HistorySource::Messages(m), _) => Resolved::Messages(m.len()),
(HistorySource::Window { memory_id, context_length }, _) => {
Resolved::Window(memory_id, context_length)
}
(HistorySource::Stateless, notes) => {
Resolved::Stateless { noted: !notes.is_empty() }
}
};
assert_eq!(resolved, expected, "{name}");
}
}
/// A placeholder the form seeds must not read as a memory id that evaluated to nothing, which
/// would turn memory off for the step.
#[test]
fn only_an_expression_can_set_an_empty_step_memory_id() {
let transforms = |memory_id: &str| -> HashMap<String, InputTransform> {
HashMap::from([(
"memory_id".to_string(),
serde_json::from_str(memory_id).unwrap(),
)])
};
let args = || -> AIAgentArgs {
serde_json::from_value(serde_json::json!({
"provider": { "kind": "openai", "resource": {}, "model": "m" },
"memory_id": null,
}))
.unwrap()
};
for (transform, expected) in [
(r#"{ "type": "static" }"#, None),
(r#"{ "type": "static", "value": "" }"#, None),
(r#"{ "type": "ai" }"#, None),
(
r#"{ "type": "javascript", "expr": "flow_input.customer_id" }"#,
Some(""),
),
] {
let mut args = args();
keep_authored_memory_id(&mut args, &transforms(transform));
assert_eq!(args.memory_id.as_deref(), expected, "{transform}");
}
}
/// Only text output sends previous messages, so they never stand in for an image prompt.
#[test]
fn previous_messages_never_stand_in_for_an_image_prompt() {
let args: AIAgentArgs = serde_json::from_value(serde_json::json!({
"provider": { "kind": "openai", "resource": {}, "model": "m" },
"previous_messages": [{ "role": "user", "content": "earlier" }],
}))
.unwrap();
let (history, _) = resolve_history_source(&args, None, "ws", "f/flow");
assert!(has_prompt(&history, false, true, false));
assert!(!has_prompt(&history, false, false, false));
assert!(has_prompt(&history, true, false, false));
assert!(!has_prompt(
&HistorySource::Messages(&[]),
false,
true,
false
));
// A legacy `manual` memory ran on an empty list alone, and still does for text output.
assert!(has_prompt(&HistorySource::Messages(&[]), false, true, true));
assert!(!has_prompt(
&HistorySource::Messages(&[]),
false,
false,
true
));
}
#[test]
fn reasoning_keeps_every_iteration_in_order() {
let mut acc = String::new();
@@ -1254,18 +1254,6 @@ async fn maybe_open_git_sync_deploy_pr(
if row.marker.is_none() {
return;
}
// Runtime Enterprise gate, like the poller: the toggles may have been set
// while a license was active (or written directly), and this hook drives
// GitHub API calls with the installation token.
if !matches!(
windmill_common::ee_oss::get_license_plan().await,
windmill_common::ee_oss::LicensePlan::Enterprise
) {
tracing::warn!(
"git sync PR: skipping PR creation for {workspace_id}: requires an Enterprise license"
);
return;
}
let Some(repo_path) = row.repo_path else {
return;
};
+5 -2
View File
@@ -225,8 +225,11 @@ answer, an `assistant` message with `success: false`. `status: 'error'` (with `e
set) means the turn could not run or be followed at all, such as a refused request.
Methods: `sendMessage(text, { inputs? })`, `stop()`, `newConversation()`,
`selectConversation(id)`, `loadConversations({ page?, perPage? })`,
`deleteConversation(id)`, `loadOlderMessages()`, `destroy()`. Switching conversations
`selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`,
`deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`,
`destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's
own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` carries
`isTest`. A rename keeps the conversation's place in the list. Switching conversations
stops following the current answer; the flow keeps running and, with server history,
its answer is there when you come back.
+20 -2
View File
@@ -28,8 +28,16 @@ export interface FlowConversation {
created_at: string
updated_at: string
created_by: string
/** Started from the flow editor's test panel rather than a deployed run. */
is_test: boolean
}
/**
* Which conversations a listing holds: the flow editor's test chats, the deployed flow's
* own (the server's default), or both.
*/
export type ConversationKind = 'test' | 'deployed' | 'all'
export interface FlowConversationMessage {
id: string
conversation_id: string
@@ -167,15 +175,25 @@ export class WindmillChatApi {
async listConversations(
flowPath: string,
options: { page?: number; perPage?: number; signal?: AbortSignal } = {}
options: { page?: number; perPage?: number; kind?: ConversationKind; signal?: AbortSignal } = {}
): Promise<FlowConversation[]> {
const extra: Record<string, string> = { flow_path: flowPath }
if (options.kind !== undefined) extra.kind = options.kind
const res = await this.#request('flow_conversations/list', {
query: pagination(options, { flow_path: flowPath }),
query: pagination(options, extra),
signal: options.signal
})
return (await res.json()) as FlowConversation[]
}
/** Sets a conversation's title. Its place in the list is kept: only a turn moves one. */
async renameConversation(conversationId: string, title: string): Promise<void> {
await this.#request(`flow_conversations/update/${encodeURIComponent(conversationId)}`, {
method: 'POST',
body: { title }
})
}
/**
* Without `afterSeq`: one page counted from the newest message, returned oldest first.
* With `afterSeq`: the messages created after that cursor, oldest first.
+2 -1
View File
@@ -53,7 +53,8 @@ export function useWindmillRuntime(options: WindmillRuntimeOptions): AssistantRu
threads: chat.conversations.map((c) => ({ status: 'regular' as const, id: c.id, title: c.title })),
onSwitchToNewThread: () => chat.newConversation(),
onSwitchToThread: (id) => chat.selectConversation(id),
onDelete: (id) => chat.deleteConversation(id)
onDelete: (id) => chat.deleteConversation(id),
onRename: (id, title) => chat.renameConversation(id, title)
}
}
: undefined
+36 -4
View File
@@ -1,6 +1,7 @@
import {
WindmillApiError,
WindmillChatApi,
type ConversationKind,
type FlowConversation,
type FlowConversationMessage
} from './api'
@@ -18,6 +19,7 @@ import type {
} from './types'
import {
conversationTitle,
truncateTitle,
errorResultMessage,
extractChatAnswer,
isAbortError,
@@ -59,6 +61,8 @@ class ChatImpl implements Chat {
#state: ChatState
#turn: Turn | undefined
#page = 1
/** The kind the caller last listed, so the refresh after a new turn lists the same rows. */
#conversationKind: ConversationKind | undefined
#persistTimer: ReturnType<typeof setTimeout> | undefined
constructor(options: ChatOptions) {
@@ -229,15 +233,21 @@ class ChatImpl implements Chat {
}
loadConversations = async (
options: { page?: number; perPage?: number } = {}
options: { page?: number; perPage?: number; kind?: ConversationKind } = {}
): Promise<Conversation[]> => {
const page = options.page ?? 1
// A different kind is a different listing: its first rows replace the held ones, on
// whichever page they were asked for.
const kindChanged = 'kind' in options && options.kind !== this.#conversationKind
if ('kind' in options) this.#conversationKind = options.kind
const kind = this.#conversationKind
let conversations: Conversation[]
if (this.#state.history === 'server') {
try {
const rows = await this.#api.listConversations(this.#config.flowPath, {
page,
perPage: options.perPage ?? this.#config.pageSize
perPage: options.perPage ?? this.#config.pageSize,
kind
})
conversations = rows.map(fromConversation)
} catch (e) {
@@ -247,10 +257,13 @@ class ChatImpl implements Chat {
} else {
conversations = this.#state.history === 'local' ? this.#local.listConversations() : []
}
// Another kind was asked for while this list was on its way: its rows are not the
// listing any more, whichever response lands last.
if (kind !== this.#conversationKind) return conversations
const known = new Set(this.#state.conversations.map((c) => c.id))
this.#set({
conversations:
page === 1
page === 1 || kindChanged
? conversations
: [...this.#state.conversations, ...conversations.filter((c) => !known.has(c.id))]
})
@@ -273,6 +286,24 @@ class ChatImpl implements Chat {
this.#set({ conversations: this.#state.conversations.filter((c) => c.id !== conversationId) })
}
renameConversation = async (conversationId: string, title: string): Promise<void> => {
// Cut here as the server cuts, so the title shown is the one stored.
const trimmed = truncateTitle(title.trim())
if (!trimmed) return
if (this.#state.history === 'server') {
await this.#api.renameConversation(conversationId, trimmed)
} else if (this.#state.history === 'local') {
this.#local.renameConversation(conversationId, trimmed)
}
// Patched in place: the server keeps `updated_at` on a rename, so the list order the
// next load returns is the one shown now.
this.#set({
conversations: this.#state.conversations.map((c) =>
c.id === conversationId ? { ...c, title: trimmed } : c
)
})
}
loadOlderMessages = async (): Promise<void> => {
const conversationId = this.#state.conversationId
if (
@@ -734,7 +765,8 @@ function fromConversation(row: FlowConversation): Conversation {
id: row.id,
title: row.title ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at
updatedAt: row.updated_at,
isTest: row.is_test
}
}
+7
View File
@@ -4,6 +4,8 @@ export interface LocalHistory {
listConversations(): Conversation[]
getMessages(conversationId: string): ChatMessage[]
upsertConversation(conversation: Conversation): void
/** Changes a stored conversation's title in place; unlike `upsertConversation`, its position is kept. */
renameConversation(conversationId: string, title: string): void
saveMessages(conversationId: string, messages: ChatMessage[]): void
deleteConversation(conversationId: string): void
}
@@ -55,6 +57,11 @@ export function createLocalHistory(storage: StorageLike | undefined, key: string
}
write(s)
},
renameConversation(id, title) {
const s = read()
s.conversations = s.conversations.map((c) => (c.id === id ? { ...c, title } : c))
write(s)
},
saveMessages(id, messages) {
const s = read()
s.messages[id] = messages.map((m) => ({ ...m, pending: false }))
+1
View File
@@ -5,6 +5,7 @@ export {
WindmillApiError,
readServerSentEvents,
type WindmillChatApiOptions,
type ConversationKind,
type FlowConversation,
type FlowConversationMessage,
type JobUpdateEvent,
+2
View File
@@ -11,6 +11,7 @@ export type UseWindmillChat = ChatState &
| 'selectConversation'
| 'loadConversations'
| 'deleteConversation'
| 'renameConversation'
| 'loadOlderMessages'
> & { chat: Chat }
@@ -65,6 +66,7 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat {
selectConversation: chat.selectConversation,
loadConversations: chat.loadConversations,
deleteConversation: chat.deleteConversation,
renameConversation: chat.renameConversation,
loadOlderMessages: chat.loadOlderMessages
}),
[state, chat]
+16 -1
View File
@@ -52,6 +52,11 @@ export interface Conversation {
title: string | undefined
createdAt: string
updatedAt: string
/**
* Started from the flow editor's test panel rather than a deployed run. Known once the
* server has listed the conversation; unset for one only this client has seen.
*/
isTest?: boolean
}
export interface ChatState {
@@ -128,8 +133,18 @@ export interface Chat {
stop(): Promise<void>
newConversation(): void
selectConversation(conversationId: string): Promise<void>
loadConversations(options?: { page?: number; perPage?: number }): Promise<Conversation[]>
/**
* `kind` narrows server history to the flow editor's test chats, the deployed flow's
* own (the server's default), or both. Local history has no test chats and ignores it.
*/
loadConversations(options?: {
page?: number
perPage?: number
kind?: 'test' | 'deployed' | 'all'
}): Promise<Conversation[]>
deleteConversation(conversationId: string): Promise<void>
/** Sets a conversation's title. The list keeps its order: only a turn moves a conversation. */
renameConversation(conversationId: string, title: string): Promise<void>
loadOlderMessages(): Promise<void>
/** Stops background work (stream, polling) and writes local history out. The chat stays usable. */
destroy(): void
+6
View File
@@ -69,6 +69,12 @@ export function conversationTitle(firstMessage: string): string {
return chars.length > 25 ? `${chars.slice(0, 25).join('')}...` : firstMessage
}
/** The server's bound on a typed title: 252 characters plus an ellipsis fits its 255-char column. */
export function truncateTitle(title: string): string {
const chars = Array.from(title)
return chars.length > 252 ? `${chars.slice(0, 252).join('')}...` : title
}
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(abortError())
+127
View File
@@ -414,6 +414,112 @@ describe('createChat with server history', () => {
expect(chat.getState().conversations.map((c) => c.id)).toEqual(['c1', 'c2'])
})
test('lists one kind of conversation and carries which kind each one is', async () => {
const row = (id: string, is_test: boolean) => ({
id,
workspace_id: 'ws',
flow_path: FLOW,
title: id,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
created_by: 'admin',
is_test
})
const { fetch, calls } = fetchMock((c) =>
c.url.pathname === '/api/w/ws/flow_conversations/list'
? json(c.url.searchParams.get('kind') === 'test' ? [row('t1', true)] : [row('d1', false)])
: undefined
)
const chat = createChat(options({}, fetch))
await chat.loadConversations()
expect(calls[0].url.searchParams.has('kind')).toBe(false)
expect(chat.getState().conversations.map((c) => [c.id, c.isTest])).toEqual([['d1', false]])
await chat.loadConversations({ kind: 'test' })
expect(calls[1].url.searchParams.get('kind')).toBe('test')
expect(chat.getState().conversations.map((c) => [c.id, c.isTest])).toEqual([['t1', true]])
})
test('a list for a kind no longer asked for does not replace the newer one', async () => {
const row = (id: string, is_test: boolean) => ({
id,
workspace_id: 'ws',
flow_path: FLOW,
title: id,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
created_by: 'admin',
is_test
})
const { fetch } = fetchMock((c) => {
if (c.url.pathname !== '/api/w/ws/flow_conversations/list') return undefined
if (c.url.searchParams.get('kind') === 'test') {
return new Promise<Response>((r) => setTimeout(() => r(json([row('t1', true)])), 50))
}
return json([row('d1', false)])
})
const chat = createChat(options({}, fetch))
const slow = chat.loadConversations({ kind: 'test' })
await chat.loadConversations({ kind: 'deployed' })
await slow
expect(chat.getState().conversations.map((c) => c.id)).toEqual(['d1'])
// Another kind asked for on a later page starts its own listing rather than appending.
await chat.loadConversations({ page: 2, kind: 'test' })
expect(chat.getState().conversations.map((c) => c.id)).toEqual(['t1'])
})
test('the refresh after a new turn lists the kind last asked for', async () => {
const { fetch, calls } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { output: 'Hello', messages: [] } }])
: undefined,
(c) =>
c.method === 'GET' && c.url.pathname.endsWith('/messages')
? json([messageRow(11, 'user', 'hi'), messageRow(12, 'assistant', 'Hello', { job_id: 'agent-job' })])
: undefined,
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.loadConversations({ kind: 'test' })
await chat.sendMessage('hi')
const lists = calls.filter((c) => c.url.pathname === '/api/w/ws/flow_conversations/list')
expect(lists.length).toBeGreaterThan(1)
expect(lists.every((c) => c.url.searchParams.get('kind') === 'test')).toBe(true)
})
test('renaming a conversation keeps its place in the list', async () => {
const row = (id: string) => ({
id,
workspace_id: 'ws',
flow_path: FLOW,
title: id,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
created_by: 'admin',
is_test: false
})
const { fetch, calls } = fetchMock(
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([row('c1'), row('c2')]) : undefined),
(c) =>
c.method === 'POST' && c.url.pathname === '/api/w/ws/flow_conversations/update/c2'
? text('Conversation c2 updated')
: undefined
)
const chat = createChat(options({}, fetch))
await chat.loadConversations()
await chat.renameConversation('c2', ' Budget review ')
expect(calls[1].body).toEqual({ title: 'Budget review' })
expect(chat.getState().conversations.map((c) => [c.id, c.title])).toEqual([
['c1', 'c1'],
['c2', 'Budget review']
])
// Cut as the server cuts, so what is shown is what is stored.
await chat.renameConversation('c2', 'x'.repeat(300))
expect(chat.getState().conversations[1].title).toBe('x'.repeat(252) + '...')
expect(calls[2].body).toEqual({ title: 'x'.repeat(252) + '...' })
})
test('a turn started right after stop() is not touched by the stop sync', async () => {
let jobs = 0
const { fetch } = fetchMock(
@@ -726,6 +832,27 @@ describe('createChat with server history', () => {
expect((await again.loadConversations()).map((c) => c.id)).toEqual([newer, older])
})
test('renaming a local conversation persists the title without reordering history', async () => {
const storage = memoryStorage()
const { fetch, calls } = fetchMock(run, (c) =>
c.url.pathname === streamPath ? sse([{ type: 'update', completed: true, only_result: 'ok' }]) : undefined
)
const chat = createChat(options({ token: 'tok', storage }, fetch))
await chat.sendMessage('older')
const older = chat.getState().conversationId!
chat.newConversation()
await chat.sendMessage('newer')
const newer = chat.getState().conversationId!
const before = calls.length
await chat.renameConversation(older, 'Renamed')
expect(calls.length).toBe(before)
const again = createChat(options({ token: 'tok', storage }, fetch))
expect((await again.loadConversations()).map((c) => [c.id, c.title])).toEqual([
[newer, 'newer'],
[older, 'Renamed']
])
})
test('destroying the chat mid-turn leaves it idle', async () => {
const { fetch } = fetchMock(run, (c) =>
c.url.pathname === streamPath
+54 -3
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -12,6 +12,14 @@ Symbols, not line numbers, are cited: they drift less.
by `create_session_token` (`windmill-api-users/src/users.rs`). `GET /api/users/refresh_token`
mints one for any non-job token but returns plain text, no redirect.
- **`tokens/impersonate`** (superadmin) returns a multi-use token and sets no cookie.
- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items`
removes an expired `token` row, the monitor emails the owner and raises a critical alert (if
enabled); rows registered by `register_token_expiry_notification` also get an "expiring soon"
warning first. Neither happens when `is_user_token` (`windmill-common/src/auth.rs`) reserves
the label, so a token the system mints for itself, whether from the backend or from the frontend
through `tokens/create`, needs a reserved label. An `ephemeral-` prefix needs no other change
(keep it clear of `is_server_minted_label` if minted through `tokens/create`); a new prefix
also goes into the SQL and Svelte mirrors that function's doc lists.
- **Every superadmin route refuses a job token**: `require_super_admin`
(`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs
`users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user
+58 -5
View File
@@ -16,11 +16,12 @@ every workspace via the standard cached-resource-type sync, like other built-in
- The brain config and tools are resolved at runtime from the resource
(`windmill-worker/src/ai_executor.rs`): the brain is interpolated, so a nested provider `$res:`
credential resolves automatically.
- The step keeps only the flow-local inputs (`user_message`, `user_attachments`, `enabled_tools`)
in its own `input_transforms`; the brain and tools stay in the resource (read-only in the step).
`enabled_tools` says which of the roster this step may call, narrowing one use of a shared agent
without touching the agent: an absent field carries every tool, a list carries the ones it names,
and an empty list carries none.
- The step keeps only the flow-local inputs (`user_message`, `user_attachments`, `enabled_tools`,
and the history inputs `memory_id` and `previous_messages`) in its own `input_transforms`; the
brain and tools stay in the resource (read-only in the step). `enabled_tools` says which of the
roster this step may call, narrowing one use of a shared agent without touching the agent: an
absent field carries every tool, a list carries the ones it names, and an empty list carries
none.
- The agent carries its tools' default input bindings verbatim as authored (static, AI-filled,
or flow expressions), so saving round-trips losslessly. Each host flow overrides what it
needs: `tool_inputs` stores per-tool overrides (a diff from the resource tool's own
@@ -36,6 +37,58 @@ agent step); below the step's inputs, each tool gets a section with the standard
input editors (prop picker included) and a read-only view of its code — edits persist into
`tool_inputs`.
## Memory
Memory is split between three owners, so a saved agent carries whether it remembers and never
which memory it is:
- **Agent: managed memory.** `memory` is a brain key, so it moves with a saved agent.
`{ kind: window, context_length }` has Windmill store the conversation and replay its last N
messages; `{ kind: off }` keeps none. An absent `memory` means off, the default: the editor turns
it on when chat input is enabled. `auto` and `manual` are the older spellings and are still read.
- **Run: memory id.** `flow_status.memory_id`, set when the run is queued: the chat conversation
id, an app chat session id, or the `memory_id` run parameter. Any string is accepted, and one
that is not a uuid is hashed to a v5 uuid scoped to the workspace and the flow the run started
from (`memory_key` in `windmill-common/src/flow_conversations.rs`), so the same key in two flows
names two memories. A uuid is used as is. Nothing is generated at save time, so schedules,
webhooks, evals and plain runs pass no id and run stateless.
- **Step: history inputs.** Flow-local, so they stay on a linked step. Each is read in one memory
state only, and the editor offers it only there, the memory id behind a *Custom* toggle that
writes the key only once it is on. With managed memory on, `memory_id` overrides the run's id,
hashed the same way: a fixed value is one memory shared by every run, an expression such as
`flow_input.customer_id` one memory per key, and an expression that evaluates to nothing runs
stateless rather than falling back to the run's id. With memory off, `previous_messages` supplies
the history itself. An older `auto` or `manual` memory reads neither, so the editor offers them
only once the step is moved to the current settings, which the alert's button does. The editor
never seeds a placeholder for either, because a present key is the step's choice, and a static
empty value reads as unset.
The worker reconciles them once per agent invocation, nested agent tools included, in
`resolve_history_source` (`windmill-worker/src/ai_executor.rs`):
1. A legacy `auto` or `manual` memory: read as the editor that wrote it ran it. `manual` replays
its list; `auto` uses the run's memory id, else the id baked into it, else runs stateless.
Neither history input is read. An `auto` without a count, or with 0, is off and read as such.
2. Managed memory: the memory id is the step's, else the run's. With no memory id the agent runs
stateless, and a step `previous_messages` is ignored.
3. Memory off: the history is `previous_messages`, else nothing. Memory is neither read nor
written, and a step `memory_id` is ignored.
Each ignored input and each stateless fallback is written to the job log.
Memory is stored per (memory id, step id), in `ai_agent_memory` or S3 at
`memory/{workspace}/{memory id}/{step}.json`. The chat transcript (`flow_conversation_message`)
always follows the run's id, even when a step sets its own. Nothing expires stored memory: deleting
a chat conversation deletes its memory, and a memory named by a string id stays until it is
overwritten.
Compatibility runs one way. New workers read every older shape. The editor rewrites a legacy step
only when the author changes it, so a flow nobody edits keeps running on older workers, while a
step saved with `window` or a history input needs a worker that knows them. An id an older editor
baked into `memory` stays a fallback behind the run's id until the author chooses *Keep as memory
id* or *Use the run's memory id*. In a chat flow it is dropped on save, since the conversation id
always took precedence there.
## Drafts
The agent editor edits the resource through a **per-user resource draft** (`draft` table,
+4
View File
@@ -23,6 +23,8 @@ export interface SchemaProperty {
pattern?: string
default?: any
enum?: EnumType
/** Display names by stored value, for an enum's options or a one-of's variants. */
enumLabels?: Record<string, string>
contentEncoding?: 'base64' | 'binary'
format?: string
items?: {
@@ -45,6 +47,8 @@ export interface SchemaProperty {
required?: string[]
showExpr?: string
hideWhenChatEnabled?: boolean
/** Why the oneOf variant is chat mode's to pick. Set = selector disabled, reason shown. */
lockOneOfWhenChatEnabled?: string
password?: boolean
order?: string[]
nullable?: boolean
@@ -27,7 +27,7 @@
let capability = $derived(
provider && model
? getReasoningCapability(provider, model)
: { supported: false, levels: [], canDisable: false }
: { supported: false, levels: [], canDisable: false, known: false }
)
// The token that turns reasoning off on a model that reasons by default
+31 -11
View File
@@ -123,6 +123,8 @@
workspace?: string | undefined
s3StorageConfigured?: boolean
chatInputEnabled?: boolean
/** Why the oneOf variant is fixed. Set = the selector is disabled and says so. */
oneOfLockedReason?: string
actions?: import('svelte').Snippet
innerBottomSnippet?: import('svelte').Snippet
fieldHeaderActions?: import('svelte').Snippet
@@ -184,6 +186,7 @@
workspace = undefined,
s3StorageConfigured = true,
chatInputEnabled = false,
oneOfLockedReason = undefined,
actions,
innerBottomSnippet,
fieldHeaderActions,
@@ -206,6 +209,15 @@
let tagKey = $derived(
oneOf?.find((o) => Object.keys(o.properties ?? {})?.includes('kind')) ? 'kind' : 'label'
)
// `oneOfSelected` is resynced in an effect, one pass after the variants or the value change. A
// variant that just left the list while the selection still names it, as when a value moves
// off a legacy kind the list offered only for it, would render the nested form against nothing
// for that pass and let it rewrite the value. The value's own tag settles it at once.
let effectiveOneOfSelected = $derived.by(() => {
if (oneOf?.some((o) => o.title === oneOfSelected)) return oneOfSelected
const tag = value?.[tagKey]
return oneOf?.some((o) => o.title === tag) ? tag : oneOfSelected
})
async function updateOneOfSelected(oneOf: SchemaProperty[] | undefined) {
if (
oneOf &&
@@ -1104,11 +1116,15 @@
{:else if inputCat == 'object' || inputCat == 'resource-object' || isListJson}
{#if oneOf && oneOf.length >= 2}
<div class="flex flex-col gap-2 w-full border rounded-md p-4">
{#if oneOfLockedReason !== undefined}
<div class="text-2xs text-tertiary">{oneOfLockedReason}</div>
{/if}
{#if oneOf && oneOf.length >= 2}
<ToggleButtonGroup
selected={oneOfSelected}
selected={effectiveOneOfSelected}
wrap
class="mb-4"
disabled={disabled || oneOfLockedReason !== undefined}
on:selected={({ detail }) => {
oneOfSelected = detail
const selectedObjProperties =
@@ -1136,12 +1152,16 @@
>
{#snippet children({ item })}
{#each oneOf as obj}
<ToggleButton value={obj.title ?? ''} label={obj.title} {item} />
<ToggleButton
value={obj.title ?? ''}
label={extra?.['enumLabels']?.[obj.title ?? ''] ?? obj.title}
{item}
/>
{/each}
{/snippet}
</ToggleButtonGroup>
{#if oneOfSelected}
{@const objIdx = oneOf.findIndex((o) => o.title === oneOfSelected)}
{#if effectiveOneOfSelected}
{@const objIdx = oneOf.findIndex((o) => o.title === effectiveOneOfSelected)}
{@const obj = oneOf[objIdx]}
{#if obj && obj.properties && Object.keys(obj.properties).length > 0}
{#key redraw}
@@ -1156,10 +1176,10 @@
{workspace}
bind:schema={
() => ({
properties: obj.properties ?? {},
order: obj.order,
properties: obj?.properties ?? {},
order: obj?.order,
$schema: '',
required: obj.required ?? [],
required: obj?.required ?? [],
type: 'object'
}),
() => {
@@ -1193,16 +1213,16 @@
{workspace}
hiddenArgs={['label', 'kind']}
schema={{
properties: obj.properties,
order: obj.order,
properties: obj?.properties ?? {},
order: obj?.order,
$schema: '',
required: obj.required ?? [],
required: obj?.required ?? [],
type: 'object'
}}
bind:args={
() => value,
(v) => {
value = { ...v, [tagKey]: oneOfSelected }
value = { ...v, [tagKey]: effectiveOneOfSelected }
}
}
{shouldDispatchChanges}
@@ -43,6 +43,8 @@
interface Props {
schema: Schema | any
hiddenArgs?: string[]
/** Fields another part of the app owns: shown, but not renameable, deletable or retypeable. */
lockedArgs?: string[]
args?: Record<string, any>
shouldHideNoInputs?: boolean
noVariablePicker?: boolean
@@ -89,6 +91,7 @@
let {
schema = $bindable(),
hiddenArgs = [],
lockedArgs = [],
args = $bindable(undefined),
shouldHideNoInputs = false,
noVariablePicker = false,
@@ -587,6 +590,7 @@
>
{#if keys.length > 0}
{#each keys as argName, i (argName)}
{@const locked = lockedArgs.includes(argName)}
<div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -605,7 +609,7 @@
>
<div class="flex flex-row gap-2 text-sm">
{argName}
{#if !uiOnly}
{#if !uiOnly && !locked}
<div onclick={stopPropagation(preventDefault(bubble('click')))}>
<Popover placement="bottom-end" closeButton>
{#snippet trigger()}
@@ -654,7 +658,7 @@
<span class="text-red-500 text-xs"> Required </span>
{/if}
{#if !uiOnly}
{#if !uiOnly && !locked}
<button
class="delete-schema-field-button
rounded-full p-1 text-gray-500 bg-white
@@ -701,6 +705,7 @@
<ToggleButtonGroup
tabListClass="flex-wrap"
class="h-auto"
disabled={lockedArgs.includes(opened ?? '')}
bind:selected={
() => computeSelected(schema.properties[opened ?? '']),
(v) => {
@@ -19,6 +19,7 @@
import { Copy, Expand } from 'lucide-svelte'
import HighlightTheme from './HighlightTheme.svelte'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
import FlowGraphViewerStepHeader from './FlowGraphViewerStepHeader.svelte'
interface Props {
schema?: any | undefined
@@ -28,6 +29,8 @@
// The workspace the viewed flow belongs to (differs from the nav workspace in fork/session
// editors); used to qualify resource links.
workspace?: string
/** Given, the step header starts with a back control that calls it. */
onBack?: () => void
}
let {
@@ -35,7 +38,8 @@
stepDetail = undefined,
jobScriptHash = undefined,
hideDefaultInputs = false,
workspace = undefined
workspace = undefined,
onBack = undefined
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let codeViewer: Drawer | undefined = $state()
@@ -104,57 +108,20 @@
{/if}
</div>
{:else if stepDetail == 'Input'}
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
{#if schema}
<SchemaViewer {schema} />
{:else}
<p class="font-medium text-secondary text-center pt-4 pb-8"> No input schema </p>
{/if}
{:else if stepDetail == 'Result'}
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
<p class="font-medium text-secondary text-center pt-4 pb-8"> End of the flow </p>
{:else if typeof stepDetail != 'string' && stepDetail.value}
<!-- A direct child of the scrolling root: a sticky row can only hold within its parent's
box, so wrapped with the path link below it would scroll away with that wrapper. -->
<FlowGraphViewerStepHeader {stepDetail} {onBack} />
<div class="">
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
<Badge color="indigo">
{stepDetail.id}
</Badge>
{/if}
<span
class={twMerge(
'font-semibold text-emphasis text-sm',
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
)}
>
{#if stepDetail.summary}
{stepDetail.summary}
{:else if stepDetail.value.type == 'identity'}
Identity
{:else if stepDetail.value.type == 'forloopflow'}
For loop {#if stepDetail.value.parallel}(parallel){/if}
{#if stepDetail.value.skip_failures}(skip failures){/if}
{#if stepDetail.value.squash}(squash){/if}
{:else if stepDetail.value.type == 'branchall'}
Run all branches {#if stepDetail.value.parallel}(parallel){/if}
{:else if stepDetail.value.type == 'branchone'}
Run one branch
{:else if stepDetail.value.type == 'flow'}
Inner flow
{:else if stepDetail.value.type == 'whileloopflow'}
While loop {#if stepDetail.value.skip_failures}(skip failures){/if}
{#if stepDetail.value.squash}(squash){/if}
{:else if stepDetail.id === 'failure'}
Error handler
{:else if stepDetail.id === 'preprocessor'}
Preprocessor
{:else if stepDetail.value.type == 'rawscript'}
Inline {stepDetail.value.language} script
{:else if stepDetail.value.type == 'script'}
Workspace script
{:else if stepDetail.value.type == 'aiagent'}
AI Agent
{/if}
</span>
</div>
{#if stepDetail.value.type == 'script'}
<div class="pb-2">
<a
@@ -0,0 +1,80 @@
<script lang="ts">
import type { FlowModule } from '$lib/gen'
import { Badge, Button } from './common'
import { ArrowLeft } from 'lucide-svelte'
interface Props {
/** A module, or the graph's pseudo-nodes by id (`Input`, `Result`). */
stepDetail: FlowModule | string
/** Given, the row starts with a back control; the caller decides where back leads. */
onBack?: () => void
}
let { stepDetail, onBack = undefined }: Props = $props()
const module = $derived(typeof stepDetail === 'string' ? undefined : stepDetail)
// The error handler and the preprocessor are named by their role, not by an id badge.
const showId = $derived(
module?.id !== undefined && module.id !== 'failure' && module.id !== 'preprocessor'
)
const title = $derived.by((): string => {
if (typeof stepDetail === 'string') {
if (stepDetail === 'Input') return 'Flow inputs'
if (stepDetail === 'Result') return 'Result'
return stepDetail
}
if (stepDetail.summary) return stepDetail.summary
if (stepDetail.id === 'failure') return 'Error handler'
if (stepDetail.id === 'preprocessor') return 'Preprocessor'
const v = stepDetail.value
switch (v?.type) {
case 'identity':
return 'Identity'
case 'forloopflow':
return (
'For loop' +
(v.parallel ? ' (parallel)' : '') +
(v.skip_failures ? ' (skip failures)' : '') +
(v.squash ? ' (squash)' : '')
)
case 'whileloopflow':
return (
'While loop' + (v.skip_failures ? ' (skip failures)' : '') + (v.squash ? ' (squash)' : '')
)
case 'branchall':
return 'Run all branches' + (v.parallel ? ' (parallel)' : '')
case 'branchone':
return 'Run one branch'
case 'flow':
return 'Inner flow'
case 'rawscript':
return `Inline ${v.language} script`
case 'script':
return 'Workspace script'
case 'aiagent':
return 'AI Agent'
default:
return stepDetail.id
}
})
</script>
<!-- -top-2: the row pins at the scroll container's content edge, and FlowGraphViewerStep pads
its root by that much, so at top-0 the body would show through the padding above the row. -->
<div class="sticky -top-2 z-10 flex w-full items-center gap-2 bg-surface py-2">
{#if onBack}
<Button
unifiedSize="sm"
variant="subtle"
iconOnly
startIcon={{ icon: ArrowLeft }}
title="Back to the flow graph"
onclick={onBack}
/>
{/if}
{#if showId && module}
<Badge color="indigo">{module.id}</Badge>
{/if}
<span class="min-w-0 truncate text-sm font-semibold text-emphasis" {title}>{title}</span>
</div>
@@ -470,9 +470,10 @@
)
return jobId ?? ''
}}
hideSidebar={true}
conversationKind="test"
path={$pathStore}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
</div>
{:else}
@@ -19,6 +19,7 @@
import DynamicInputHelpBox from './flows/content/DynamicInputHelpBox.svelte'
import type { PropPickerWrapperContext } from './flows/propPicker/PropPickerWrapper.svelte'
import { codeToStaticTemplate, getDefaultExpr } from './flows/utils.svelte'
import { keepsManagedMemory } from './flows/agentFormFields'
import SimpleEditor from './SimpleEditor.svelte'
import { Button, ButtonType } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -53,6 +54,8 @@
label?: string
/** Replaces the label header, so a setting's own toggle can name the field. */
header?: Snippet
/** Indent the input under the header's label, for a header that starts with a switch. */
indentUnderHeader?: boolean
/** Renders after the label: a button to unset the field, a badge. */
labelExtra?: Snippet
/** Drop the schema's description paragraph, for a form that carries it in a tooltip. */
@@ -119,6 +122,7 @@
argName = $bindable(),
label = undefined,
header = undefined,
indentUnderHeader = true,
labelExtra = undefined,
hideDescription = false,
subtleControls = false,
@@ -863,7 +867,7 @@
<!-- A custom header means a setting's toggle owns this field, so the input is
indented under the toggle's label: `xs` switch (w-7) plus its ml-2. -->
<div
class="relative w-full {header ? 'pl-9' : ''}"
class="relative w-full {header && indentUnderHeader ? 'pl-9' : ''}"
onkeyup={handleKeyUp}
transition:slideDynamic|global={{ duration: animateAppear ? 150 : 0 }}
>
@@ -999,6 +1003,11 @@
{helperScript}
{s3StorageConfigured}
{chatInputEnabled}
oneOfLockedReason={chatInputEnabled &&
arg?.type === 'static' &&
keepsManagedMemory(arg.value)
? schema.properties[argName]?.lockOneOfWhenChatEnabled
: undefined}
otherArgs={Object.fromEntries(
Object.entries(otherArgs).map(([key, transform]) => [
key,
@@ -8,6 +8,7 @@
import { getContext, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import { evalValue } from './flows/utils.svelte'
import { memoryPropertyFor } from './flows/flowInfers'
import type { FlowModule } from '$lib/gen'
import type { PickableProperties } from './flows/previousResults'
import type SimpleEditor from './SimpleEditor.svelte'
@@ -61,6 +62,9 @@
* for a surface whose form cannot open a row at all. A schema key the field registry doesn't
* know is kept, so a new one is never silently dropped. */
let schemaKeys = $derived(Object.keys(schema?.properties ?? {}))
// A legacy memory kind this step still holds stays one of the options, or the one-of field would
// turn the test run's memory off.
let isAgent = $derived((mod.value as { type?: string })?.type === 'aiagent')
let visibleKeys = $derived.by(() => {
const all = schemaKeys
@@ -71,7 +75,11 @@
for (const key of openAgentFields(openFieldsKey)) visible.add(key)
for (const key of runInputKeys) visible.add(key)
const known = new Set(AGENT_FIELDS.map((f) => f.key))
return all.filter((key) => !known.has(key) || visible.has(key))
// Listed in the agent form's order rather than the schema's, so the two read the same.
const position = new Map(AGENT_FIELDS.map((f, i) => [f.key, i]))
return all
.filter((key) => !known.has(key) || visible.has(key))
.sort((a, b) => (position.get(a) ?? Infinity) - (position.get(b) ?? Infinity))
})
let keys: string[] = $state([])
@@ -182,7 +190,12 @@
(v) => stepsInputArgs?.setStepInputArgs(mod.id, argName, v)
}
type={schema.properties[argName].type}
oneOf={schema.properties[argName].oneOf}
oneOf={isAgent && argName === 'memory'
? memoryPropertyFor(
schema.properties[argName],
stepsInputArgs?.getStepInputArgs(mod.id, argName)
)?.oneOf
: schema.properties[argName].oneOf}
required={schema?.required?.includes(argName)}
pattern={schema.properties[argName].pattern}
bind:editor={editor[argName]}
+17 -8
View File
@@ -21,6 +21,7 @@
type LinkedAgentDraft
} from './flows/linkedAgentDrafts'
import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils'
import { AGENT_HISTORY_KEYS } from './flows/agentFormFields'
import { sendUserToast } from '$lib/toast'
interface Props {
@@ -170,11 +171,21 @@
}
const agentVal = draft ? inlineAgentDraft(val, draft.args) : val
// `args` is built from the whole AI agent schema whatever the step is, so on a linked step
// it carries every brain key as undefined even though the form renders only the flow-local
// ones (`flowLocalAgentSchema`). Overlaying those would shadow the brain the draft just
// supplied with nothing, so an inlined step takes only the inputs its form actually offers.
const formKeys = draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args)
// `args` spans the whole AI agent schema, so on a linked step it carries every brain key as
// undefined; overlaying those would shadow the draft's brain, so an inlined step takes only
// the inputs its form offers. A blank history input is unset, as on the step: an expression
// evaluating to nothing reads as an empty memory id, and the step's transform is stale.
const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length)
const formKeys = (
draft ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) : Object.keys(args)
).filter(
(key) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
)
const stepTransforms = Object.fromEntries(
Object.entries((agentVal.input_transforms ?? {}) as Record<string, InputTransform>).filter(
([key]) => !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key])
)
)
// The test form only covers the schema it was given, and for a standalone agent that may be
// the flow-local one (the agent editor shows the brain in its own form, not here). Take the
@@ -182,9 +193,7 @@
// in the form after the test panel mounted is what runs. A linked agent needs none of this:
// the server reads its brain from the resource.
const inputTransforms: { [key: string]: JavascriptTransform | InputTransform } = {
...(agentVal.agent
? {}
: ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>)),
...(agentVal.agent ? {} : stepTransforms),
...Object.fromEntries(
formKeys.map((key) => [
key,
@@ -174,7 +174,7 @@ export async function main(bucket: any, api_token: string) {
async function mintApiToken(): Promise<string> {
return await UserService.createToken({
requestBody: {
label: `test connection: ${resourceType}`,
label: `ephemeral-test-connection: ${resourceType}`,
expiration: new Date(Date.now() + API_TOKEN_TTL_MS).toISOString(),
scopes: ['settings:write']
}
@@ -35,6 +35,7 @@
HistoryIcon
} from 'lucide-svelte'
import FlowHistory from '$lib/components/flows/FlowHistory.svelte'
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork'
@@ -48,6 +49,9 @@
draft_path?: string
draft_users?: { username?: string | null }[]
canWrite: boolean
/** Projected from the flow value by the listing; a chat-input flow opens
* as a conversation and is badged as such. */
chat_input_enabled?: boolean
}
marked: string | undefined
shareModal: ShareModal
@@ -124,6 +128,10 @@
<FlowHistory bind:this={flowHistory} path={flow.path} />
{/if}
{#snippet chatBadge()}
<ChatFlowBadge />
{/snippet}
<Row
aiId={`flow-row-${flow.path}`}
aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`}
@@ -140,6 +148,7 @@
canFavorite={!flow.draft_only}
{depth}
{rowSelection}
titleBadge={flow.chat_input_enabled ? chatBadge : undefined}
>
{#snippet badges()}
{#if flow.archived}
@@ -67,6 +67,9 @@
badges?: import('svelte').Snippet
actions?: import('svelte').Snippet
customSummary?: import('svelte').Snippet
/** Rendered inline right after the title, unlike `badges`, which sit in
* their own column and are hidden below `lg`. */
titleBadge?: import('svelte').Snippet
/** Overrides the secondary path line (e.g. to strike a renamed path).
* Falls back to the plain `path` string when not provided. */
pathDisplay?: import('svelte').Snippet
@@ -101,6 +104,7 @@
badges,
actions,
customSummary,
titleBadge,
pathDisplay,
onSelect = () => {}
}: Props = $props()
@@ -275,7 +279,12 @@
</div>
{/if}
<div class="grow min-w-0">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
<div
class={twMerge(
'text-emphasis flex-wrap text-left text-xs font-semibold',
titleBadge ? 'inline-flex items-center gap-2' : ''
)}
>
{#if customSummary}
{@render customSummary?.()}
{:else if marked}
@@ -283,6 +292,7 @@
{:else}
{!summary || summary.length == 0 ? displayPath : summary}
{/if}
{@render titleBadge?.()}
</div>
<div class="text-hint text-3xs truncate text-left font-normal" title={path}>
{#if pathDisplay}
@@ -0,0 +1,314 @@
<script lang="ts">
/**
* The model button every chat puts in the bottom-right of its composer: the trigger
* names the model and its reasoning effort, and the menu holds the choices behind
* both. Driven entirely by ChatModelSettingsConfig, so the session chat and the flow
* chat render the same control from different data — see chatModelSettings.ts.
*/
import { ChevronDown, Check, Loader2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import ReasoningEffortSlider from './ReasoningEffortSlider.svelte'
import { getReasoningCapability, resolveEffectiveReasoning } from './reasoningRegistry'
import {
fixedReasoningReason,
reasoningControlState,
reasoningDisplay,
type ChatModelSettingsConfig,
type ChoiceSection
} from './chatModelSettings'
import type { Item } from '$lib/utils'
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
import { twMerge } from 'tailwind-merge'
type MeltItem = MenubarMenuElements['item']
type MeltBuilders = ReturnType<typeof createDropdownMenu>['builders']
let { config }: { config: ChatModelSettingsConfig } = $props()
const reasoning = $derived(config.reasoning)
const capability = $derived(
reasoning?.provider && reasoning.model
? getReasoningCapability(reasoning.provider, reasoning.model)
: { supported: false, levels: [] as string[], canDisable: false, known: false }
)
const controlState = $derived(reasoningControlState(reasoning, capability))
const fixedReason = $derived(fixedReasoningReason(reasoning, capability))
// Effective effort accounts for the default-on level on capable models.
const effective = $derived(
reasoning?.provider && reasoning.model
? resolveEffectiveReasoning({
provider: reasoning.provider,
model: reasoning.model,
reasoning: reasoning.value
})
: undefined
)
// The stops, the one in use and the trigger's suffix are decided together, in one tested
// place: a stop the slider shows as `off` must not read as the provider's `none` on the button.
const display = $derived(reasoningDisplay(reasoning, capability, effective))
const stops = $derived(display.stops)
const currentStop = $derived(display.currentStop)
const effortLabel = $derived(display.label)
let effortSlider: ReasoningEffortSlider | undefined = $state(undefined)
// The trigger label resizes when the effort changes (dragging the slider while the menu
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize
// would shift the popover, so freeze the trigger to its width at open time and release it
// on close — no movement while open, natural sizing the rest of the time.
let menuOpen = $state(false)
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
// Blocks are separated, not prefixed: a rule belongs between two of them, so the first
// one rendered must not draw one above itself whichever block that turns out to be.
const BLOCK_CLASS =
'border-border-light [&:not(:first-child)]:border-t [&:not(:first-child)]:mt-1 [&:not(:first-child)]:pt-1'
const ROW_CLASS =
'w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer'
</script>
{#snippet trigger()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
disabled={config.readOnly}
endIcon={config.readOnly ? undefined : { icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title={config.readOnly ? config.readOnlyReason : config.title}
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{config.label}</span>
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if config.badge}
<span
class={twMerge(
'shrink-0 rounded-full px-1.5 text-2xs',
config.badge.warn
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'
)}>{config.badge.text}</span
>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet typedField(
value: string,
placeholder: string,
onCommit: (value: string) => void,
close: () => void
)}
{#key value}
<TextInput
size="sm"
{value}
inputProps={{
placeholder,
onchange: (e) => onCommit(e.currentTarget.value.trim()),
// Capture, not bubble: Svelte delegates `keydown` to the root, which sits above the
// menu — so a bubble handler here would run only after melt's own listener had read
// the key as typeahead and moved focus. A capture key is not delegatable, so this
// becomes a real listener on the input and sees the event first.
onkeydowncapture: (e) => {
// Escape cancels: let it reach the menu with the value untouched.
if (e.key === 'Escape') return
// Tab closes the menu, unmounting this field before focus moves, so no change
// event would ever fire. Commit on the way past.
if (e.key === 'Tab') {
onCommit(e.currentTarget.value.trim())
return
}
// Enter means done: commit and close, rather than leaving the menu open around a
// field the commit is about to rebuild.
if (e.key === 'Enter') {
e.preventDefault()
onCommit(e.currentTarget.value.trim())
close()
return
}
// Everything else is typing; the menu reads loose keys as typeahead.
e.stopPropagation()
}
}}
/>
{/key}
{/snippet}
{#snippet section(sec: ChoiceSection, item: MeltItem, close: () => void)}
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">{sec.label}</div>
{#if sec.loading}
<div class="flex items-center gap-2 px-3 py-1.5 text-tertiary">
<Loader2 size={14} class="animate-spin" /> Loading...
</div>
{:else if sec.options.length === 0}
<div class="px-3 py-1.5 text-tertiary">{sec.emptyMessage ?? 'Nothing to choose from'}</div>
{:else}
<div class={twMerge('overflow-y-auto', sec.maxHeight ?? 'max-h-48')}>
{#each sec.options as option (option.key)}
<MenuItem {item} class={ROW_CLASS} onClick={() => option.onSelect()}>
<span class="truncate grow min-w-0">{option.label}</span>
{#if option.hint}
<span class="shrink-0 text-tertiary truncate max-w-[70px]">{option.hint}</span>
{/if}
{#if option.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
{/if}
{#if sec.custom && !sec.loading}
{@const custom = sec.custom}
<div class="px-3 pt-1 pb-1.5">
{@render typedField(
'',
custom.placeholder,
(value) => {
if (value) custom.onCommit(value)
},
close
)}
</div>
{/if}
{/snippet}
{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)}
{#each items.filter((row) => !row.hide) as row (row.displayName)}
{#if row.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
{#if row.submenuItems}
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
<DropdownSubmenuItem item={row} {builders} meltItem={item} />
{:else}
<MenuItem {item} class={ROW_CLASS} onClick={(e) => row.action?.(e)}>
{#if row.icon}
<row.icon size={14} class="shrink-0" />
{/if}
<span class="truncate grow min-w-0 text-2xs text-secondary">{row.displayName}</span>
{#if row.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/if}
{/each}
{/snippet}
{#if config.readOnly}
{@render trigger()}
{:else}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
{@render trigger()}
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
{#if config.topItems}
<div class={BLOCK_CLASS}>
{@render rows(config.topItems(close), item, builders)}
</div>
{/if}
{#each config.sections ?? [] as sec (sec.label)}
<div class={BLOCK_CLASS}>
{@render section(sec, item, close)}
</div>
{/each}
{#if reasoning}
<div class={BLOCK_CLASS}>
{#if controlState === 'fixed'}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason={fixedReason}
/>
{:else if controlState === 'awaiting-model'}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Pick a model first"
/>
{:else if controlState === 'unknown'}
<!-- No rules for this provider, so no ladder to offer. The flow still takes a
token, so it is typed rather than picked: claiming the model cannot think
would be a guess, and offering nothing would leave it settable nowhere. -->
<div class="px-3 pt-1 pb-1.5">
<div class="text-2xs uppercase tracking-wide text-secondary mb-1">Thinking</div>
{@render typedField(reasoning.value ?? '', 'none', reasoning.onSelect, close)}
<div class="text-2xs text-tertiary mt-1">
Windmill has no thinking levels for this provider — type what it accepts.
</div>
</div>
{:else if controlState === 'ladder'}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the row
above. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper
{item}
onKeydown={(e) => effortSlider?.adjust(e)}
class="block group"
>
<ReasoningEffortSlider
bind:this={effortSlider}
{stops}
current={currentStop}
onSelect={reasoning.onSelect}
format={(stop) => (stop === reasoning?.offToken ? 'off' : stop)}
overrideLabel={stops.includes(currentStop) ? undefined : effortLabel}
/>
</MenuItemWrapper>
{:else}
<!-- Kept in place rather than dropped: the row saying the model cannot think
is the answer to why there is no slider. -->
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Not supported by this model"
/>
{/if}
</div>
{/if}
{#if config.bottomItems}
<div class={BLOCK_CLASS}>
{@render rows(config.bottomItems(close), item, builders)}
</div>
{/if}
</div>
{/snippet}
</DropdownV2>
{/if}
@@ -0,0 +1,172 @@
<script lang="ts">
/**
* The reasoning-effort control: a thin slider over a model's ordered effort stops.
*
* Presentational on purpose. Callers keep their own value convention — the copilot's
* REASONING_OFF sentinel and an agent's `reasoning_effort` token mean off in
* different ways — and hand this component a resolved list of stops plus the current
* one, so the two never have to agree on anything but the ordering.
*/
interface Props {
/** Ordered stops, least effort first. Fewer than two renders no slider. */
stops: string[]
current: string
onSelect: (stop: string) => void
/** When set, the section renders disabled with this as the explanation. */
unsupportedReason?: string
/** Display name for a stop whose value is a provider sentinel rather than a word. */
format?: (stop: string) => string
/** Shown in place of the current stop — a state the slider has no position for. */
overrideLabel?: string
}
let {
stops,
current,
onSelect,
unsupportedReason,
format = (stop: string) => stop,
overrideLabel
}: Props = $props()
/**
* A `current` naming no stop is a real state, not a missing one: an agent that leaves the
* effort unset sends nothing and the provider decides. Three things follow, and they only
* hold together.
*
* The thumb rests at the start, because a range input always has one somewhere, and
* `overrideLabel` is what tells the reader this is not the lowest stop. The track is
* unfilled there, which index 0 gives for free. And since the input's value already reads
* 0, picking the lowest stop by pointer fires no `input` event — so a click has to be
* committed explicitly, or that stop is reachable only by keyboard.
*/
const hasPosition = $derived(stops.indexOf(current) >= 0)
const stopIndex = $derived(Math.max(0, stops.indexOf(current)))
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
const fillPct = $derived(
stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0
)
/** Left/right stepping, for a caller that owns the keyboard (a melt menu item). */
export function adjust(e: KeyboardEvent) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const next = Math.min(
stops.length - 1,
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
)
onSelect(stops[next])
}
// Melt's roving focus blurs the focused element on pointermove, which aborts a native
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
function isolatePointer(node: HTMLElement) {
const stop = (e: Event) => e.stopPropagation()
node.addEventListener('pointerdown', stop)
node.addEventListener('pointermove', stop)
return {
destroy() {
node.removeEventListener('pointerdown', stop)
node.removeEventListener('pointermove', stop)
}
}
}
</script>
{#if unsupportedReason}
<!-- Kept visible rather than hidden: the absence of the control is itself the answer,
but only if it says why. -->
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
<div class="text-2xs text-tertiary mt-0.5">{unsupportedReason}</div>
</div>
{:else}
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
<span class="text-2xs text-secondary tabular-nums">{overrideLabel ?? format(current)}</span>
</div>
{#if stops.length > 1}
<!-- Only the slider area reflects an enclosing menu item's highlight, not the header. -->
<div class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover">
<input
type="range"
min="0"
max={stops.length - 1}
step="1"
value={stopIndex}
style="--fill: {fillPct}%"
oninput={(e) => onSelect(stops[+e.currentTarget.value])}
onclick={(e) => {
// `click`, not `pointerup`: it is the event that means pressed and released on
// the track, so a press that began on the row above cannot commit an effort
// nobody chose. Only the click that moved nothing — any other stop has already
// committed through `oninput`, and doing it again would write it twice.
if (!hasPosition && +e.currentTarget.value === stopIndex) {
onSelect(stops[stopIndex])
}
}}
use:isolatePointer
class="lean-range no-default-style w-full"
aria-label="Reasoning effort"
/>
</div>
{/if}
{/if}
<style>
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
rules — so they are wrapped in :global (the class is unique to this component). */
.lean-range {
-webkit-appearance: none;
appearance: none;
height: 10px;
margin: 0;
padding: 0;
/* override the global `input { background-color: ... !important }` so only the
thin track shows, not a full-height band behind it */
background-color: transparent !important;
cursor: pointer;
outline: none;
}
.lean-range:focus,
.lean-range:focus-visible {
outline: none;
}
:global(.lean-range::-webkit-slider-runnable-track) {
height: 3px;
border-radius: 9999px;
background: linear-gradient(
to right,
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
rgb(var(--color-surface-secondary)) var(--fill, 0%)
);
}
:global(.lean-range::-webkit-slider-thumb) {
-webkit-appearance: none;
appearance: none;
margin-top: -3.5px;
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-track) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-secondary));
}
:global(.lean-range::-moz-range-progress) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-thumb) {
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
</style>
@@ -25,7 +25,7 @@
/** The failing run's error, used when there is no job to point at. */
error?: string
/** The failing run's job id. Preferred over `error`: the chat reads the
* run itself with `get_job_logs`, which gives it the logs rather than
* run itself with `get_run`, which gives it the logs rather than
* just the thrown value, and keeps the composer readable. */
jobId?: string
/** Set when this sits in a flow step's preview, so the session opens on
@@ -44,6 +44,7 @@
import Markdown from 'svelte-exmarkdown'
import { twMerge } from 'tailwind-merge'
import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
import { getChatViewHost } from './chatViewHost'
import { getAiChatManager } from './aiChatManagerContext'
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
import AIChatInput from './AIChatInput.svelte'
@@ -68,14 +69,19 @@
import { base } from '$lib/base'
const MAX_YOLO_TOOLTIP_TOOLS = 8
const chatHost = getChatViewHost()
// The skill and MCP menus take an AIChatManager itself, which the seam deliberately
// doesn't carry. They render only under GLOBAL, which a non-copilot host never sets.
const aiChatManager = getAiChatManager()
// The free grant pays for the copilot's own model, so its banners belong only to a host
// that sends to that model. A flow chat's turn runs on the flow's provider.
const freeTier = $derived(chatHost.supportsModelSettings ? $copilotInfo.freeTier : undefined)
// The user spent their one-time free Windmill AI grant: there is no model left to send
// to, so say so in the thread itself rather than only failing on send.
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
let freeTierExhausted = $derived(freeTier?.exhausted === true)
// Still on the free grant: keep how much is left in view right above the composer, so
// running out isn't a surprise. Once spent, the exhausted banner replaces it.
let freeTier = $derived($copilotInfo.freeTier)
let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted)
@@ -174,8 +180,12 @@
wideLayout = false,
emptyHint,
inputPreface,
footerSettings,
initialInstructions = undefined,
onDraftChange = undefined
onDraftChange = undefined,
placeholder = undefined,
scrollElement = $bindable(),
onTranscriptScroll = undefined
}: {
messages: DisplayMessage[]
pastChats: { id: string; title: string }[]
@@ -202,9 +212,18 @@
wideLayout?: boolean
emptyHint?: Snippet
inputPreface?: Snippet
/** The settings control at the footer's right edge, where the copilot puts its
* model picker. A host that configures its turn elsewhere replaces it here. */
footerSettings?: Snippet
// Seed / observe the main composer's draft text (see AIChatInput).
initialInstructions?: string
onDraftChange?: (text: string) => void
/** Composer placeholder. Falls back to the per-AI-mode wording. */
placeholder?: string
/** The transcript's scroll container. A host that paginates older messages
* needs it to measure and restore the scroll position. */
scrollElement?: HTMLDivElement | undefined
onTranscriptScroll?: () => void
} = $props()
let aiChatInput: AIChatInput | undefined = $state()
@@ -223,7 +242,7 @@
let panelEl: HTMLDivElement | undefined = $state()
$effect(() => {
function onWindowKeydownCapture(e: KeyboardEvent) {
if (e.key !== 'Escape' || !aiChatManager.loading) return
if (e.key !== 'Escape' || !chatHost.loading) return
const active = document.activeElement
const focusOnChat =
!active || active === document.body || (panelEl?.contains(active) ?? false)
@@ -231,22 +250,21 @@
// row alone stops the turn — wherever it is mounted, since the preview panel holds the
// form outside `panelEl`. Matched by call: two chats can be loading at once, and one's
// row must not answer for the other.
if (aiChatManager.hasPendingRunForm) {
if (chatHost.hasPendingRunForm) {
const row = active?.closest('[data-run-form-actions]')
const toolCallId = row?.getAttribute('data-run-form-actions')
if (!toolCallId || !aiChatManager.isRunFormPending(toolCallId)) return
if (!toolCallId || !chatHost.isRunFormPending(toolCallId)) return
} else if (!focusOnChat) return
e.preventDefault()
// Immediate form: other chat panels' identical listeners must not
// also cancel on body focus, nor a drawer/modal close on this press.
e.stopImmediatePropagation()
aiChatManager.cancel()
chatHost.cancel()
}
window.addEventListener('keydown', onWindowKeydownCapture, true)
return () => window.removeEventListener('keydown', onWindowKeydownCapture, true)
})
let scrollEl: HTMLDivElement | undefined = $state()
// Programmatic-scroll guard. `scrollDown()` triggers an async `scroll`
// event; if a token-append between the scrollTo and the dispatch makes
// scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and
@@ -259,22 +277,23 @@
// Instant scroll — smooth would animate every token append, racing with
// the next scrollDown and confusing the onscroll bottom-detection below.
function scrollDown() {
if (!scrollEl) return
if (!scrollElement) return
programmaticScrollAt = Date.now()
scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'auto' })
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' })
}
let height = $state(0)
$effect(() => {
if (aiChatManager.automaticScroll && height) {
if (chatHost.automaticScroll && height) {
scrollDown()
}
// Recompute the scroll-to-latest visibility on every content-height
// change. `onScroll` only fires for actual scroll events, so without
// this the arrow can go stale when content grows past the threshold
// while auto-scroll is disabled (user scrolled up mid-stream).
if (scrollEl && height) {
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
if (scrollElement && height) {
const distance =
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX
}
})
@@ -289,8 +308,9 @@
const SCROLL_TO_LATEST_THRESHOLD_PX = 200
let showScrollToLatest = $state(false)
function onScroll() {
if (!scrollEl) return
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
if (!scrollElement) return
const distance =
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
// Always refresh the arrow visibility — even during the cooldown,
// because clicking the arrow itself triggers a programmatic scroll
// whose only event would otherwise be swallowed, leaving the arrow
@@ -303,14 +323,15 @@
return
}
if (distance <= STICK_TO_BOTTOM_PX) {
aiChatManager.enableAutomaticScroll()
chatHost.enableAutomaticScroll()
} else {
aiChatManager.disableAutomaticScroll()
chatHost.disableAutomaticScroll()
}
onTranscriptScroll?.()
}
function submitSuggestion(suggestion: string) {
aiChatManager.sendRequest({ instructions: suggestion })
chatHost.sendRequest({ instructions: suggestion })
}
export function focusInput() {
@@ -319,35 +340,31 @@
$effect(() => {
if (aiChatInput) {
aiChatManager.setAiChatInput(aiChatInput)
chatHost.setAiChatInput(aiChatInput)
}
return () => {
aiChatManager.setAiChatInput(null)
chatHost.setAiChatInput(null)
}
})
// Also shown for a run held by another tab, labeled with where it is: the
// dots say a turn is in flight even before the reader reaches the footer
// note. Remote runs pause nothing and offer no Stop — this tab can't cancel.
const showTypingIndicator = $derived(aiChatManager.loading || aiChatManager.runHeldElsewhere)
const showTypingIndicator = $derived(chatHost.loading || chatHost.runHeldElsewhere)
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
// `@`-context is still invoked inline by typing `@` in the input, so the button
// is redundant. NAVIGATOR/ASK/API don't take @-context at all.
const showContextPicker = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.APP
chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP
)
// File attachment is GLOBAL-mode only.
const canAttachFiles = $derived(aiChatManager.mode === AIMode.GLOBAL && !disabled)
// Steers the OS file picker toward text + image formats (soft hint; both attach
// to the message — text files after a content sniff).
const TEXT_FILE_ACCEPT =
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
const canAttachFiles = $derived(chatHost.supportsMessageAttachments && !disabled)
// Folders are linked as session-wide assets, which only a host that reads files in
// the browser can do — a host running the turn server-side takes attachments only.
const canLinkFolders = $derived(chatHost.supportsLinkedFolders && !disabled)
let fileInputEl = $state<HTMLInputElement | null>(null)
let folderInputEl = $state<HTMLInputElement | null>(null)
let dragDepth = $state(0)
@@ -373,12 +390,12 @@
}
async function handleAddFiles(files: FileList | FileToAttach[]) {
const { added, rejected } = await aiChatManager.attachedFiles.addFiles(files)
const { added, rejected } = await chatHost.attachedFiles.addFiles(files)
reportAddResult(added, rejected)
}
async function addDirHandle(dir: FileSystemDirectoryHandle) {
const { added, rejected } = await aiChatManager.attachedFiles.addFolder(dir)
const { added, rejected } = await chatHost.attachedFiles.addFolder(dir)
reportAddResult(added, rejected)
}
@@ -471,8 +488,13 @@
const textFiles = looseFiles.filter((f) => !isImageFile(f))
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
// Folders link as a live handle.
for (const h of handles.filter(isDirectoryHandle)) {
await addDirHandle(h)
const dirs = handles.filter(isDirectoryHandle)
if (dirs.length > 0 && !canLinkFolders) {
sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
} else {
for (const h of dirs) {
await addDirHandle(h)
}
}
} else {
// Fallback (no File System Access API): snapshot dropped files AND folders by walking
@@ -497,7 +519,10 @@
topLevelText.push(file)
}
}
if (folderEntries.length > 0) await handleAddFiles(folderEntries)
if (folderEntries.length > 0) {
if (canLinkFolders) await handleAddFiles(folderEntries)
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
}
if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText)
}
}
@@ -524,9 +549,9 @@
input.value = ''
}
const autonomyAvailability = $derived({
autoAcceptEditsAvailable: aiChatManager.autoAcceptEditsAvailable,
autoAcceptToolConfirmationsAvailable: aiChatManager.autoAcceptToolConfirmationsAvailable,
planModeAvailable: aiChatManager.planModeAvailable
autoAcceptEditsAvailable: chatHost.autoAcceptEditsAvailable,
autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable,
planModeAvailable: chatHost.planModeAvailable
})
const availableAutonomyModeOptions = $derived(
autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability))
@@ -534,8 +559,8 @@
// Fall back to ask-permission when the persisted mode isn't applicable in the
// current AI mode (e.g. auto-accept edits while in a mode without edits).
const effectiveAutonomyMode = $derived(
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
? aiChatManager.autonomyMode
availableAutonomyModeOptions.some((option) => option.mode === chatHost.autonomyMode)
? chatHost.autonomyMode
: AIAutonomyMode.DEFAULT
)
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
@@ -544,13 +569,13 @@
// The typing-dots indicator implies the AI is busy, which is misleading while
// the loop is parked on the user; surface a text pill instead so users know to
// act on the tool above.
const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages))
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
// Gated on `loading` because a card restored from history still looks parked:
// its resolver left with the old page, so the composer must not advertise an
// answer it cannot deliver.
const pendingQuestionToolCallId = $derived.by(() => {
if (!aiChatManager.loading) {
if (!chatHost.loading) {
return undefined
}
const pending = pendingUserActionDetail(messages)
@@ -559,14 +584,14 @@
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) {
if (chatHost.mode !== AIMode.APP || !chatHost.appAiChatHelpers) {
return undefined
}
return aiChatManager.appAiChatHelpers.getSelectedContext()
return chatHost.appAiChatHelpers.getSelectedContext()
})
const yoloBypassedTools = $derived.by(() => {
return aiChatManager.tools
return chatHost.tools
.filter((tool) => tool.requiresConfirmation === true || tool.bypassedByAutoAccept === true)
.map((tool) => ({
name: tool.def.function.name,
@@ -583,8 +608,7 @@
Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length)
)
const showFlowPendingActionControls = $derived(
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
!aiChatManager.autoAcceptEditsActive
(chatHost.flowAiChatHelpers?.hasPendingChanges() ?? false) && !chatHost.autoAcceptEditsActive
)
// A disabled state with no message (a remote hold, a spent free grant) keeps
// the footer toolbar in place — swapping it for an empty strip would make
@@ -592,11 +616,15 @@
// a real message (archived, AI off) still shows it, hold or not, matching
// the precedence disabledMessage itself encodes.
const footerMessageShown = $derived(disabled && disabledMessage !== '')
// `canAttachFiles` belongs in the group too: in GLOBAL mode the `+` always has the
// context picker or the autonomy selector beside it, but a host with attachments and
// nothing else would lose the group and the `+` with it.
const showFooterLeftControls = $derived(
!footerMessageShown &&
(showContextPicker ||
(canAttachFiles ||
showContextPicker ||
showAutonomyModeSelector ||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
(chatHost.mode === AIMode.SCRIPT && hasDiff))
)
</script>
@@ -694,12 +722,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#each pastChats as chat (chat.id)}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
disabled={aiChatManager.loading ||
aiChatManager.sendInFlight ||
aiChatManager.runHeldElsewhere}
title={aiChatManager.runHeldElsewhere
disabled={chatHost.loading ||
chatHost.sendInFlight ||
chatHost.runHeldElsewhere}
title={chatHost.runHeldElsewhere
? 'Wait for the turn in the other tab to switch conversation'
: aiChatManager.loading || aiChatManager.sendInFlight
: chatHost.loading || chatHost.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
onclick={() => {
@@ -731,10 +759,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Popover>
<Button
title={aiChatManager.runHeldElsewhere
title={chatHost.runHeldElsewhere
? 'Wait for the turn in the other tab to start a new chat'
: 'New chat'}
disabled={aiChatManager.runHeldElsewhere}
disabled={chatHost.runHeldElsewhere}
on:click={() => {
saveAndClear()
}}
@@ -769,7 +797,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div class="flex-1 min-h-0 relative">
<div
class="absolute inset-0 overflow-y-scroll pt-2 scrollbar-subtle"
bind:this={scrollEl}
bind:this={scrollElement}
onscroll={onScroll}
>
<div
@@ -800,16 +828,16 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<ChatTypingIndicator
loading={showTypingIndicator}
paused={waitingForUserAction}
label={aiChatManager.runHeldElsewhere
label={chatHost.runHeldElsewhere
? 'Running in another tab'
: aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
: chatHost.loadingLabel
? chatHost.loadingLabel
: chatHost.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: chatHost.currentReasoningActive &&
!chatHost.currentReply &&
!chatHost.currentReasoning
? (chatHost.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
</div>
@@ -832,7 +860,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
aria-label="Scroll to latest message"
startIcon={{ icon: ArrowDown }}
on:click={() => {
aiChatManager.enableAutomaticScroll()
chatHost.enableAutomaticScroll()
scrollDown()
}}
/>
@@ -854,7 +882,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
variant="default"
btnClasses="bg-green-500 hover:bg-green-600 text-white hover:text-white"
onclick={() => {
aiChatManager.flowAiChatHelpers?.acceptAllModuleActions()
chatHost.flowAiChatHelpers?.acceptAllModuleActions()
}}
>
Accept all
@@ -866,7 +894,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
variant="default"
btnClasses="dark:opacity-50 opacity-60 hover:opacity-100"
onclick={() => {
aiChatManager.flowAiChatHelpers?.rejectAllModuleActions()
chatHost.flowAiChatHelpers?.rejectAllModuleActions()
}}
>
Reject all
@@ -876,7 +904,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
<div>
<QueuedMessageChip />
{#if aiChatManager.mode === AIMode.GLOBAL && !aiChatManager.isSessionChat}
{#if chatHost.mode === AIMode.GLOBAL && !chatHost.isSessionChat}
<!-- Standalone Jobs bar for the global side-panel chat. In /sessions the
Jobs segment lives inside the session bar (SessionChangesBar). -->
<div class="mb-1">
@@ -898,9 +926,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
bind:this={aiChatInput}
bind:selectedContext
{availableContext}
{placeholder}
{initialInstructions}
{onDraftChange}
showContext={aiChatManager.mode !== AIMode.GLOBAL}
showContext={chatHost.mode !== AIMode.GLOBAL}
{disabled}
{pendingQuestionToolCallId}
isFirstMessage={messages.length === 0}
@@ -925,7 +954,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
/>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
{#if chatHost.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
@@ -969,7 +998,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
// together: awaited inline they queue, and the whole menu —
// attachments included — waits out two round trips.
const closeMenu = () => (plusMenuOpen = false)
const inGlobal = aiChatManager.mode === AIMode.GLOBAL
const inGlobal = chatHost.mode === AIMode.GLOBAL
const [skillItems, mcpItems] = await Promise.all([
inGlobal ? skillsMenu.items(closeMenu) : undefined,
inGlobal ? mcpMenu.items(closeMenu) : undefined
@@ -983,19 +1012,23 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
linkFiles()
}
},
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
}
},
...(canLinkFolders
? [
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
}
}
]
: []),
...(skillItems
? [
{
@@ -1054,7 +1087,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
bind:this={fileInputEl}
type="file"
multiple
accept={TEXT_FILE_ACCEPT}
accept={chatHost.attachmentAccept}
class="hidden no-default-style"
onchange={onFileInputChange}
/>
@@ -1075,7 +1108,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
availableAutonomyModeOptions.map((option) => ({
displayName: option.label,
selected: effectiveAutonomyMode === option.mode,
action: () => aiChatManager.setAutonomyMode(option.mode)
action: () => chatHost.setAutonomyMode(option.mode)
}))}
placement="bottom-start"
fixedHeight={false}
@@ -1102,18 +1135,18 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#if effectiveAutonomyMode === AIAutonomyMode.PLAN}
<span class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.modeNote}</span>
{/if}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && chatHost.autoAcceptToolConfirmationsAvailable}
<Tooltip small placement="top">
<AlertTriangle class="w-3 h-3 text-red-500" />
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">
{aiChatManager.autoAcceptEditsAvailable
{chatHost.autoAcceptEditsAvailable
? 'Bypass permissions auto-accepts edits and tool usage.'
: 'Bypass permissions auto-accepts tool usage.'}
</p>
<p class="mt-1">
{aiChatManager.autoAcceptEditsAvailable
{chatHost.autoAcceptEditsAvailable
? 'This can result in edits being applied or tools being called without user confirmation.'
: 'This can result in tools being called without user confirmation.'}
</p>
@@ -1134,7 +1167,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Tooltip>
{/if}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff && !disabled}
{#if chatHost.mode === AIMode.SCRIPT && hasDiff && !disabled}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
@@ -1145,25 +1178,27 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</div>
{:else}
<div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center">
{#if aiChatManager.mode === AIMode.GLOBAL}
{#if chatHost.mode === AIMode.GLOBAL}
<AttachedFilesBar />
{/if}
{#if !hideModeSelector}
<ChatMode />
{/if}
{#if aiChatManager.mode === AIMode.APP}
{#if chatHost.mode === AIMode.APP}
<DatatableCreationPolicy />
{/if}
<ContextUsageIndicator />
<!-- Unconditional: this composer mounts only via `AIChat` ← `SessionWrapper`,
and `sessionRuntime` locks a session to GLOBAL, where the settings
modal's Instructions section owns the prompt entries. -->
<AIChatModelSettings promptSettings={false} />
{#if aiChatManager.mode === AIMode.GLOBAL}
{#if chatHost.supportsModelSettings}
<!-- `promptSettings={false}`: in a session, GLOBAL, the settings modal's
Instructions section owns the prompt entries. -->
<AIChatModelSettings promptSettings={false} />
{/if}
{@render footerSettings?.()}
{#if chatHost.mode === AIMode.GLOBAL}
<AssistantSettingsModal bind:this={assistantSettings} />
{/if}
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if appContext.inspectorElement}
<div
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-2xs"
@@ -1210,7 +1245,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
</div>
</div>
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
{#if (chatHost.mode === AIMode.NAVIGATOR || chatHost.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
<div class="px-2 mt-4">
<div class="flex flex-col gap-2">
{#each suggestions as suggestion (suggestion)}
@@ -12,6 +12,8 @@
} from './context'
import { AIMode } from './AIChatManager.svelte'
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
import { formatMention } from './mention'
import { twMerge } from 'tailwind-merge'
import { tick, untrack, type Snippet } from 'svelte'
@@ -45,7 +47,10 @@
isImageViewerOpen
} from '$lib/components/common/image/ExpandableImage.svelte'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// Resolved here, not where it is used: getContext is only legal during component
// initialisation, and the mention consumer below runs inside the send gesture.
const chatManager = getAiChatManager()
interface Props {
availableContext: ContextElement[]
@@ -65,15 +70,15 @@
showContext?: boolean
bottomRightSnippet?: Snippet
onKeyDown?: (e: KeyboardEvent) => void
// When provided, overrides `aiChatManager.loading` for the send/stop
// When provided, overrides `chatHost.loading` for the send/stop
// button — useful for callers driving their own request lifecycle
// (e.g. the inline ⌘K widget runs requests outside the global
// `aiChatManager.loading` flag).
// `chatHost.loading` flag).
loading?: boolean
// Called when the user clicks Stop. Defaults to `aiChatManager.cancel()`.
// Called when the user clicks Stop. Defaults to `chatHost.cancel()`.
onCancel?: () => void
// Observe the composer draft as it changes (the text is local state —
// `aiChatManager.instructions` only carries programmatic prompts). Used by
// `chatHost.instructions` only carries programmatic prompts). Used by
// sessions to persist the typed-but-unsent prompt with the session draft.
onDraftChange?: (text: string) => void
// tool_call_id of the askUserQuestion the turn is parked on, when it is. A
@@ -132,7 +137,7 @@
// The composer unlocks by itself when the other tab's turn ends, so the
// placeholder names what it is waiting on (the typing indicator says
// where the run is).
if (aiChatManager.runHeldElsewhere) {
if (chatHost.runHeldElsewhere) {
return 'Waiting for the turn in the other tab to finish'
}
if (pendingQuestionToolCallId !== undefined) {
@@ -147,7 +152,7 @@
return placeholder
}
switch (aiChatManager.mode) {
switch (chatHost.mode) {
case AIMode.SCRIPT:
return 'Modify this script...'
case AIMode.FLOW:
@@ -213,19 +218,24 @@
// against a concurrent drop.
let pendingImages = $state(0)
/** Attach dropped/pasted image files (downscaled + bounded). GLOBAL mode only. */
/** Attach dropped/pasted image files (downscaled + bounded). */
export async function addImages(files: (File | Blob)[]) {
if (aiChatManager.mode !== AIMode.GLOBAL) return
if (!chatHost.supportsMessageAttachments) return
const imageFiles = files.filter(isImageFile)
if (imageFiles.length === 0) return
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
// drop/paste handler that can't surface a rejection.
const model = tryGetCurrentModel()
// Only known text-only models fail this, so attaching would certainly 400 the
// next turn — refuse rather than warn and send it anyway.
if (model && !modelSupportsVision(model.provider, model.model)) {
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
return
// The vision check is about the model this composer's own turn will hit, so it
// only applies to a host that picks that model. Elsewhere the model is chosen
// in the flow and tryGetCurrentModel would answer for the wrong one.
if (chatHost.supportsModelSettings) {
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
// drop/paste handler that can't surface a rejection.
const model = tryGetCurrentModel()
// Only known text-only models fail this, so attaching would certainly 400 the
// next turn — refuse rather than warn and send it anyway.
if (model && !modelSupportsVision(model.provider, model.model)) {
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
return
}
}
// Count decodes already in flight: two drops that both read the image count
// before either resolves would each claim the same free slots and overshoot
@@ -308,13 +318,13 @@
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes
)
$effect(() => {
aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
})
$effect(() => () => aiChatManager.clearComposerStaged(composerKey))
$effect(() => () => chatHost.clearComposerStaged(composerKey))
/** Attach dropped/picked text files (sniffed + bounded). GLOBAL mode only. */
/** Attach dropped/picked text files (sniffed + bounded). */
export async function addTextFiles(candidates: File[]) {
if (aiChatManager.mode !== AIMode.GLOBAL) return
if (!chatHost.supportsMessageAttachments) return
if (candidates.length === 0) return
const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles
if (remaining <= 0) {
@@ -346,7 +356,7 @@
// stage stands in for it, so counting both would charge those bytes twice.
let budget =
MAX_CONVERSATION_FILE_BYTES -
aiChatManager.attachmentBytesExcluding(composerKey) -
chatHost.attachmentBytesExcluding(composerKey) -
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
pendingFileBytes
const withinBudget: File[] = []
@@ -388,7 +398,7 @@
// from the budget — the decoded sizes replace it.
const liveBudget =
MAX_CONVERSATION_FILE_BYTES -
aiChatManager.attachmentBytesExcluding(composerKey) -
chatHost.attachmentBytesExcluding(composerKey) -
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
(pendingFileBytes - reservedBytes)
const { droppedAtBudget } = draft.addFiles(reads, liveBudget)
@@ -420,9 +430,9 @@
// Modes that show the rich textarea with @-context support (workspace
// scripts, workspace flows, code blocks, DBs, etc.).
const isContextEnabledMode = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.GLOBAL
chatHost.mode === AIMode.SCRIPT ||
chatHost.mode === AIMode.FLOW ||
chatHost.mode === AIMode.GLOBAL
)
const domSelectorChips = $derived(
@@ -551,14 +561,14 @@
* the composer. The conversation is left untouched — resending creates a new
* message, unlike the bubble's edit pencil which rewinds the conversation. */
function recallLastSentMessage(): boolean {
const messages = aiChatManager.displayMessages
const messages = chatHost.displayMessages
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== 'user' || message.synthetic) continue
// Images come from the stored turn, never the bubble: a provider
// rejection strips them from history while the bubble keeps its copy,
// and recalling that copy would re-attach the refused image.
const images = aiChatManager.storedImages(i) ?? []
const images = chatHost.storedImages(i) ?? []
// Eligibility looks at the bubble, though: the last thing the user
// actually sent is the recall boundary, so a context-only turn (GLOBAL
// allows text-free sends with chips) recalls its chips, and a turn
@@ -582,8 +592,7 @@
// count against the conversation budget — re-admit them instead of
// copying, or resending would blow past MAX_CONVERSATION_FILE_BYTES.
if (message.files?.length) {
const budget =
MAX_CONVERSATION_FILE_BYTES - aiChatManager.attachmentBytesExcluding(composerKey)
const budget = MAX_CONVERSATION_FILE_BYTES - chatHost.attachmentBytesExcluding(composerKey)
const { droppedAtBudget } = draft.addFiles(message.files, budget)
if (droppedAtBudget > 0) {
const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000)
@@ -654,10 +663,10 @@
if (
contextElement.type === 'app_datatable' &&
aiChatManager.mode === AIMode.APP &&
aiChatManager.appAiChatHelpers
chatHost.mode === AIMode.APP &&
chatHost.appAiChatHelpers
) {
const appAiChatHelpers = aiChatManager.appAiChatHelpers
const appAiChatHelpers = chatHost.appAiChatHelpers
appAiChatHelpers.addTableToWhitelist(
contextElement.datatableName,
contextElement.schemaName,
@@ -699,8 +708,10 @@
* consuming past them would hand this message a mention the user picked for
* the next one. */
function consumeMentionsIfGlobal() {
if (aiChatManager.mode !== AIMode.GLOBAL) return
aiChatManager.contextManager?.consumeMentionContext()
if (chatHost.mode !== AIMode.GLOBAL) return
// The mention context belongs to the copilot's own ContextManager, which only
// the manager has — the GLOBAL guard above means this host is always it.
chatManager.contextManager?.consumeMentionContext()
}
function sendRequest() {
@@ -709,13 +720,18 @@
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) {
return
}
// A host whose consumer needs a message of its own refuses an attachment-only
// turn. Returning before `take()` keeps the chips where the user put them.
if (chatHost.requiresMessageText && draft.text.trim() === '') {
return
}
// Read before `take()` empties the draft the id derives from, and only take
// once the answer is delivered — an undelivered one would leave the user
// with neither their text nor a resumed turn.
const answeredQuestionId = questionAnsweredBySend
if (
answeredQuestionId &&
aiChatManager.handleUserQuestionAnswer(answeredQuestionId, [
chatHost.handleUserQuestionAnswer(answeredQuestionId, [
expanded(chatDraft(draft.text.trim(), draft.pastes))
])
) {
@@ -727,7 +743,7 @@
contextTextareaComponent?.clearForSend()
return
}
if (aiChatManager.loading) {
if (chatHost.loading) {
// Queue the message instead of silently discarding it — it is
// auto-sent when the streaming turn completes successfully.
// Editing-while-loading keeps the old discard behavior. Paste
@@ -738,10 +754,10 @@
// chips picked at press time.
if (
editingMessageIndex === null &&
(!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0))
(!draft.isEmpty || (chatHost.mode === AIMode.GLOBAL && selectedContext.length > 0))
) {
const sent = draft.take()
aiChatManager.queueMessage(
chatHost.queueMessage(
expanded(chatDraft(sent.text, sent.pastes)),
sent.images,
[...selectedContext],
@@ -758,7 +774,7 @@
// message's original chips), so send exactly what's shown — the user may
// have added or removed chips.
const sent = draft.take()
aiChatManager.restartGeneration(
chatHost.restartGeneration(
editingMessageIndex,
sent.text,
sent.pastes,
@@ -771,9 +787,9 @@
const sent = draft.take()
// Pin before consuming: the manager falls back to the live selection only
// when given no override, and the consume below empties it.
const carried = aiChatManager.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
consumeMentionsIfGlobal()
aiChatManager.sendRequest({
chatHost.sendRequest({
instructions: sent.text,
pastes: sent.pastes,
images: sent.images,
@@ -1005,31 +1021,40 @@
<!-- The turn stays `loading` while parked on a question, but a drafted answer
is what the button should ship then — otherwise the only pointer action on
a typed answer would be Stop. Anything else keeps Stop. -->
{@const isLoading = (loading ?? aiChatManager.loading) && !questionAnsweredBySend}
{@const isLoading = (loading ?? chatHost.loading) && !questionAnsweredBySend}
{@const emptyDraft = draft.isEmpty}
<!-- A text-free GLOBAL draft with context chips is a valid turn (Enter
already sends it), so the button stays enabled there for pointer/touch
parity — mirrors the sendRequest guard. Custom onSendRequest consumers
(inline ⌘K) and editor copilots need content. -->
{@const needsText = chatHost.requiresMessageText && draft.text.trim() === ''}
<!-- The wording is about the attachment, so it earns its place only once there is one:
an empty composer is the idle state, not a refusal. -->
{@const needsTextForAttachment = needsText && !emptyDraft}
{@const sendDisabled =
disabled ||
pendingImages > 0 ||
pendingFiles > 0 ||
ingestionHolds > 0 ||
needsText ||
(emptyDraft &&
(onSendRequest !== undefined ||
aiChatManager.mode !== AIMode.GLOBAL ||
chatHost.mode !== AIMode.GLOBAL ||
selectedContext.length === 0))}
<Button
variant="subtle"
unifiedSize="md"
iconOnly
title={isLoading ? 'Stop' : 'Send'}
title={isLoading
? 'Stop'
: needsTextForAttachment
? 'Write a message to send with the attachment'
: 'Send'}
startIcon={{ icon: isLoading ? Square : ArrowUp }}
disabled={!isLoading && sendDisabled}
on:click={() => {
if (isLoading) {
onCancel ? onCancel() : aiChatManager.cancel()
onCancel ? onCancel() : chatHost.cancel()
} else if (!sendDisabled) {
submitRequest()
}
@@ -1115,9 +1140,9 @@
class="relative mt-1"
role="presentation"
onkeydown={(e) => {
if (e.key === 'Escape' && aiChatManager.loading) {
if (e.key === 'Escape' && chatHost.loading) {
e.preventDefault()
aiChatManager.cancel()
chatHost.cancel()
} else if (
e.key === 'ArrowUp' &&
!e.defaultPrevented &&
@@ -1139,14 +1164,14 @@
// custom-send consumers (inline widget) have their own history
// semantics.
if (
aiChatManager.queuedMessage ||
aiChatManager.queuedImages.length > 0 ||
aiChatManager.queuedFiles.length > 0 ||
(aiChatManager.queuedContext?.length ?? 0) > 0
chatHost.queuedMessage ||
chatHost.queuedImages.length > 0 ||
chatHost.queuedFiles.length > 0 ||
(chatHost.queuedContext?.length ?? 0) > 0
) {
e.preventDefault()
aiChatManager.dequeueMessage()
} else if (!aiChatManager.sendInFlight && recallLastSentMessage()) {
chatHost.dequeueMessage()
} else if (!chatHost.sendInFlight && recallLastSentMessage()) {
// History recall waits for the in-flight turn: from the moment the
// composer clears, the turn's bubble, stored images and context land
// across several awaits, so recalling now would return an incomplete
@@ -1163,10 +1188,10 @@
bind:this={contextTextareaComponent}
bind:value={draft.text}
bind:pastes={draft.pastes}
onImageFiles={aiChatManager.mode === AIMode.GLOBAL
onImageFiles={chatHost.supportsMessageAttachments
? (pasted) => void addImages(pasted)
: undefined}
onTextFiles={aiChatManager.mode === AIMode.GLOBAL
onTextFiles={chatHost.supportsMessageAttachments
? (pasted) => void addTextFiles(pasted)
: undefined}
{availableContext}
@@ -1196,7 +1221,7 @@
</div>
{/if}
</div>
{:else if aiChatManager.mode === AIMode.APP}
{:else if chatHost.mode === AIMode.APP}
{#if showContext}
{@render badgeRow()}
{/if}
@@ -1264,30 +1289,38 @@
</Portal>
{/if}
{:else}
<div class={twMerge('relative w-full scroll-pb-2 pt-2', className)}>
<textarea
bind:this={instructionsTextareaComponent}
bind:value={draft.text}
use:autosize={{ maxHeight: '40vh' }}
onkeydown={(e) => {
if (onKeyDown) {
onKeyDown(e)
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendRequest()
}
}}
rows={1}
placeholder={modePlaceholder}
class={twMerge('resize-none', CHAT_INPUT_PADDING)}
{disabled}
></textarea>
{#if !bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
{/if}
<!-- Same box as the rich composer above, so a host on the plain textarea shows
the identical chip rows inside the identical field. -->
<div class={composerBoxClass(disabled)}>
{@render badgeRow()}
{@render imageChipsRow()}
<div class={twMerge('relative w-full', className)}>
<textarea
bind:this={instructionsTextareaComponent}
bind:value={draft.text}
use:autosize={{ maxHeight: '40vh' }}
onkeydown={(e) => {
if (onKeyDown) {
onKeyDown(e)
}
// An Enter that confirms an IME composition (Japanese, Chinese) is not a
// send; it would ship the unfinished text.
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
sendRequest()
}
}}
rows={1}
placeholder={modePlaceholder}
class={twMerge('resize-none', COMPOSER_FIELD_RESET, CHAT_INPUT_PADDING)}
{disabled}
></textarea>
{#if !bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
{/if}
</div>
</div>
{/if}
{#if bottomRightSnippet}
@@ -1,3 +1,4 @@
import type { ChatViewHost } from './chatViewHost'
import type { ScriptLang } from '$lib/gen/types.gen'
import { JobService, type CompletedJob } from '$lib/gen'
import type { FlowOptions, ScriptOptions } from './ContextManager.svelte'
@@ -445,9 +446,27 @@ function planModeHostFor(m: AIChatManager): PlanModeHost {
}
}
export class AIChatManager {
export class AIChatManager implements ChatViewHost {
contextManager = new ContextManager()
historyManager = new HistoryManager()
// The copilot owns its model choice and its own transcript, so both chat
// affordances apply here. See ChatViewHost for hosts where they don't.
supportsModelSettings = true
supportsMessageEditing = true
// The copilot turn is the attachments themselves when there is no text.
requiresMessageText = false
// Attachments and linked folders are GLOBAL-mode affordances. Declared as
// getters because `mode` changes under a mounted composer.
get supportsMessageAttachments() {
return this.mode === AIMode.GLOBAL
}
get supportsLinkedFolders() {
return this.mode === AIMode.GLOBAL
}
// Steers the OS file picker toward text + image formats (a soft hint; both attach to
// the message — text files after a content sniff).
attachmentAccept =
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
/** Files the user attached to the current GLOBAL-mode conversation. */
attachedFiles = new AttachedFilesStore()
/** Markdown artifacts the copilot created for the current session. */
@@ -3,7 +3,7 @@
import type { DisplayMessage, ToolDisplayMessage } from './shared'
import ContextElementBadge from './ContextElementBadge.svelte'
import AssistantMessage from './AssistantMessage.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { Button } from '$lib/components/common'
import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte'
import AIChatInput from './AIChatInput.svelte'
@@ -15,7 +15,7 @@
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
import { workspaceStore } from '$lib/stores'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// Paths in a message name items the chat's tools reach, so they resolve against the
// operating workspace, never `workspaceStore`: a fork session leaves the store on the
@@ -25,7 +25,7 @@
// Registers the dependency that `operatingWorkspace`'s own untracked
// `get(workspaceStore)` cannot.
void $workspaceStore
return aiChatManager.operatingWorkspace
return chatHost.operatingWorkspace
})
// Per-message expand/collapse state for paste chips shown in the bubble.
@@ -62,7 +62,12 @@
let editContext = $state<ContextElement[]>([])
function editMessage() {
if (message.role !== 'user' || editingMessageIndex !== null || aiChatManager.loading) {
if (
!chatHost.supportsMessageEditing ||
message.role !== 'user' ||
editingMessageIndex !== null ||
chatHost.loading
) {
return
}
editContext = [...(message.contextElements ?? [])]
@@ -79,7 +84,9 @@
message.role === 'tool' && 'mb-1',
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
isLast && '!mb-12',
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
message.role !== 'user' || !chatHost.supportsMessageEditing
? 'cursor-default'
: 'cursor-pointer'
)}
role="button"
tabindex="0"
@@ -116,7 +123,7 @@
bind:selectedContext={editContext}
initialInstructions={message.content}
initialPastes={message.pastes}
initialImages={aiChatManager.storedImages(messageIndex)}
initialImages={chatHost.storedImages(messageIndex)}
initialFiles={message.files}
{editingMessageIndex}
onClickOutside={() => (editingMessageIndex = null)}
@@ -185,9 +192,9 @@
on:click={() => {
if (message.snapshot) {
if (message.snapshot.type === 'flow') {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
chatHost.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
} else if (message.snapshot.type === 'app') {
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
chatHost.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
}
}
}}
@@ -206,7 +213,7 @@
variant="default"
title="Retry generation"
startIcon={{ icon: RefreshCwIcon }}
onclick={() => aiChatManager.retryRequest(messageIndex)}
onclick={() => chatHost.retryRequest(messageIndex)}
>
Retry
</Button>
@@ -1,10 +1,12 @@
<script lang="ts">
import { ChevronDown, Check, User, Building2, Settings, ExternalLink } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
import Button from '$lib/components/common/button/Button.svelte'
/**
* The session chat's model button: a fixed ChatModelSettings config over the copilot's
* own state — the workspace's configured models, the session's model/effort selection
* and its localStorage pins, the custom-prompt editors, and the free-tier grant.
*/
import { User, Building2, Settings, ExternalLink } from 'lucide-svelte'
import ChatModelSettings from '../ChatModelSettings.svelte'
import { carriedReasoning, type ChatModelSettingsConfig } from '../chatModelSettings'
import {
COPILOT_SESSION_MODEL_SETTING_NAME,
COPILOT_SESSION_PROVIDER_SETTING_NAME,
@@ -28,7 +30,6 @@
import { thinkingPreferences } from './thinkingPreferences.svelte'
import {
getReasoningCapability,
resolveEffectiveReasoning,
REASONING_OFF,
type ReasoningProviderModel
} from '../reasoningRegistry'
@@ -60,57 +61,16 @@
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
let capability = $derived(
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
)
// Effective effort accounts for the default-on level on capable models.
let currentEffort = $derived(resolveEffectiveReasoning(providerModel))
// Slider stops: an off position only where the model can truly disable (else the
// provider would coerce it to the lowest level), then the provider-native levels.
let stops = $derived([...(capability.canDisable ? [REASONING_OFF] : []), ...capability.levels])
let currentStop = $derived(
providerModel.reasoning === REASONING_OFF
? REASONING_OFF
: (currentEffort ?? stops[stops.length - 1])
)
let stopIndex = $derived(Math.max(0, stops.indexOf(currentStop)))
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
let fillPct = $derived(stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0)
// Button suffix: the effort token, or 'off' when explicitly disabled. Omitted entirely
// for models with no reasoning support.
let effortLabel = $derived(capability.supported ? (currentEffort ?? REASONING_OFF) : undefined)
// The trigger label resizes when the effort changes (e.g. dragging the slider while the menu
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize would
// shift the popover. So we freeze the trigger to its width at open time and release it on close —
// no movement while open, and natural sizing (no reserved padding) the rest of the time.
let menuOpen = $state(false)
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
function selectModel(m: AIProviderModel) {
// Carry the effort onto the new model only if it supports that level ('off'
// only where the model can truly disable); otherwise drop it so the model's
// default applies.
const carried = providerModel.reasoning
const cap = getReasoningCapability(m.provider, m.model)
const keep =
carried === REASONING_OFF
? cap.canDisable
: carried !== undefined && cap.levels.includes(carried)
$copilotSessionModel = { ...m, ...(keep ? { reasoning: carried } : {}) }
const keep = carriedReasoning(
providerModel.reasoning,
REASONING_OFF,
getReasoningCapability(m.provider, m.model)
)
$copilotSessionModel = { ...m, ...(keep !== undefined ? { reasoning: keep } : {}) }
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model)
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider)
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep ? carried : undefined)
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep)
}
function selectReasoning(value: string) {
@@ -233,9 +193,8 @@
}
}
// Prompt parameters, surfaced as a melt submenu (hover-opens and is floating-positioned,
// so it flips on screen edges instead of overflowing). The menu keeps itself open on
// item click (closeOnItemClick=false), so these actions close it explicitly via `close`.
// Prompt parameters, surfaced as a melt submenu. The menu keeps itself open on item
// click, so these actions close it explicitly before opening a modal.
function paramItems(close: () => void): Item {
return {
displayName: 'Parameters',
@@ -270,155 +229,56 @@
}
}
// Keep the slider's pointer events from bubbling to the enclosing melt item: melt's
// roving focus blurs the focused element on pointermove, which would abort the native
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
function isolatePointer(node: HTMLElement) {
const stop = (e: Event) => e.stopPropagation()
node.addEventListener('pointerdown', stop)
node.addEventListener('pointermove', stop)
return {
destroy() {
node.removeEventListener('pointerdown', stop)
node.removeEventListener('pointermove', stop)
const config = $derived<ChatModelSettingsConfig>({
label: providerModel.model,
title: 'Model & reasoning settings',
badge: freeTier && !freeTier.exhausted ? { text: 'Free', warn: freeRunningLow } : undefined,
// Off in a session: the assistant settings modal's Instructions section owns the
// prompt entries there, so the menu would offer the same thing twice.
topItems: promptSettings ? (close) => [paramItems(close)] : undefined,
sections: [
{
label: 'Model',
options: models.map((m) => ({
key: `${m.provider}/${m.model}`,
label: m.model,
selected: m.model === providerModel.model && m.provider === providerModel.provider,
onSelect: () => selectModel(m)
}))
}
}
}
// Adjust the reasoning effort with the arrow keys while the Thinking item is focused.
function adjustEffort(e: KeyboardEvent) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const next = Math.min(
stops.length - 1,
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
)
selectReasoning(stops[next])
}
],
reasoning: {
provider: providerModel.provider as AIProvider,
model: providerModel.model,
value: providerModel.reasoning,
offToken: REASONING_OFF,
// The copilot fills an unset effort in before it calls the provider, so unset
// really does run at the default level and the button may name it.
sendsDefaultWhenUnset: true,
// The session chat's model is always its own to change.
writable: true,
typedWhenUnknown: false,
onSelect: selectReasoning
},
// A reading preference rather than a model parameter: it applies to every chat in
// this browser, including thinking already in the transcript. No close(): flipping
// it should not dismiss the menu.
bottomItems: () => [
{
displayName: 'Always expand thinking',
selected: thinkingPreferences.expandByDefault,
action: () => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)
}
]
})
</script>
{#snippet externalLinkIcon()}
<ExternalLink size={14} class="shrink-0 text-secondary" />
{/snippet}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
endIcon={{ icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title="Model & reasoning settings"
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{providerModel.model}</span>
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if freeTier && !freeTier.exhausted}
<span
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'}">Free</span
>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
{#if promptSettings}
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
{/if}
<ChatModelSettings {config} />
<div class="my-1 border-t border-border-light"></div>
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
<div class="max-h-48 overflow-y-auto">
{#each models as m (m.provider + m.model)}
<MenuItem
{item}
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
onClick={() => selectModel(m)}
>
<span class="truncate grow min-w-0">{m.model}</span>
{#if m.model === providerModel.model && m.provider === providerModel.provider}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
<div class="my-1 border-t border-border-light"></div>
{#if capability.supported}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the Parameters
trigger. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper {item} onKeydown={adjustEffort} class="block group">
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
<span class="text-2xs text-secondary tabular-nums">{currentStop}</span>
</div>
{#if stops.length > 1}
<!-- Only the slider area reflects the item's highlight, not the header. -->
<div
class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover"
>
<input
type="range"
min="0"
max={stops.length - 1}
step="1"
value={stopIndex}
style="--fill: {fillPct}%"
oninput={(e) => selectReasoning(stops[+e.currentTarget.value])}
use:isolatePointer
class="lean-range no-default-style w-full"
aria-label="Reasoning effort"
/>
</div>
{/if}
</MenuItemWrapper>
{:else}
<!-- Reasoning unsupported: keep the section but show it disabled with a reason,
rather than hiding it. Not a melt item, so it's skipped by keyboard navigation. -->
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
</div>
{/if}
<!-- A reading preference rather than a model parameter: it applies to every
chat in this browser, including thinking already in the transcript. -->
<MenuItem
{item}
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
onClick={() => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)}
>
<span class="truncate grow min-w-0 text-2xs text-secondary">Always expand thinking</span>
{#if thinkingPreferences.expandByDefault}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
</div>
{/snippet}
</DropdownV2>
<!-- Only where the entries that open it are rendered. -->
{#if promptSettings}
<AIPromptsModal
bind:open={modalOpen}
@@ -436,61 +296,3 @@
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
/>
{/if}
<style>
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
rules — so they are wrapped in :global (the class is unique to this component). */
.lean-range {
-webkit-appearance: none;
appearance: none;
height: 10px;
margin: 0;
padding: 0;
/* override the global `input { background-color: ... !important }` so only the
thin track shows, not a full-height band behind it */
background-color: transparent !important;
cursor: pointer;
outline: none;
}
.lean-range:focus,
.lean-range:focus-visible {
outline: none;
}
:global(.lean-range::-webkit-slider-runnable-track) {
height: 3px;
border-radius: 9999px;
background: linear-gradient(
to right,
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
rgb(var(--color-surface-secondary)) var(--fill, 0%)
);
}
:global(.lean-range::-webkit-slider-thumb) {
-webkit-appearance: none;
appearance: none;
margin-top: -3.5px;
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-track) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-secondary));
}
:global(.lean-range::-moz-range-progress) {
height: 3px;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
:global(.lean-range::-moz-range-thumb) {
width: 10px;
height: 10px;
border: none;
border-radius: 9999px;
background: rgb(var(--color-surface-accent-primary));
}
</style>
@@ -4,7 +4,7 @@
import { CircleHelp, ArrowUp, Plus, Square, SquareCheck } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import type { UserQuestionDisplay } from './shared'
// Sessions inject a per-pane `AIChatManager` via context; outside of
@@ -12,7 +12,7 @@
// this, answers clicked inside a session would dispatch to the singleton's
// pending callbacks map (which doesn't have the session manager's question
// callback), and the AI loop would stall.
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
interface Props {
toolCallId: string
@@ -93,14 +93,14 @@
}
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [choice])
chatHost.handleUserQuestionAnswer(toolCallId, [choice])
}
function submitPicked() {
if (!multiSelect || picked.size === 0) {
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [...picked])
chatHost.handleUserQuestionAnswer(toolCallId, [...picked])
}
function submitCustomAnswer() {
@@ -119,7 +119,7 @@
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [answer])
chatHost.handleUserQuestionAnswer(toolCallId, [answer])
}
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
@@ -12,6 +12,7 @@
workspaceItemRegistry
} from './workspaceItems.svelte'
import { markdownProse } from '$lib/components/markdownProse'
import DisplayResult from '$lib/components/DisplayResult.svelte'
interface Props {
message: DisplayMessage
@@ -60,6 +61,20 @@
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
}
const stepName = $derived(message.role === 'assistant' ? message.stepName : undefined)
// A flow step can return a file rather than text; the raw JSON would be
// unreadable, so hand it to the result viewer instead of the markdown renderer.
const s3Object = $derived.by(() => {
if (!message.content.startsWith('{')) return undefined
try {
const parsed = JSON.parse(message.content)
return parsed?.type === 'windmill_s3_object' && parsed?.s3 ? parsed : undefined
} catch {
return undefined
}
})
const candidatePaths = $derived(extractCandidatePaths(message.content))
const rendererPlugin = {
renderer: {
@@ -98,6 +113,12 @@
})
</script>
{#if stepName}
<div class="text-2xs text-tertiary font-medium mb-1 truncate" title="Answered by {stepName}">
{stepName}
</div>
{/if}
{#if reasoning}
<ChatCollapsibleCard
label={reasoningLabel}
@@ -112,7 +133,9 @@
</ChatCollapsibleCard>
{/if}
{#if message.content}
{#if s3Object}
<DisplayResult result={s3Object} workspaceId={workspace} noControls={true} />
{:else if message.content}
<div class="w-full space-y-2 {markdownProse.sm}">
<Markdown md={message.content} {plugins} />
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
import autosize from '$lib/autosize'
import { tick, type Snippet } from 'svelte'
import type { ContextElement } from './context'
@@ -767,21 +768,7 @@
}
</script>
<!-- The composer box: border + rounded live HERE (on the wrapper), not on the
textarea, so context chips can sit INSIDE the box, above the text. The
textarea's own @tailwindcss/forms border/ring is neutralized below. -->
<!-- The disabled treatment lives on the wrapper for the same reason the box
does: `disabled` on the textarea alone leaves the field looking exactly
like a usable one, so the only cue that typing is refused is placeholder
text the eye reads as an invitation. -->
<div
class={twMerge(
'w-full scroll-pb-2 rounded-md border border-border-light transition-colors',
disabled
? 'bg-surface-disabled cursor-not-allowed'
: 'bg-surface-input focus-within:border-border-selected'
)}
>
<div class={composerBoxClass(disabled)}>
<!-- Context chips live inside the input box, above the textarea. The snippet
self-guards (renders nothing when empty) so no blank row appears. -->
{@render leading?.()}
@@ -830,11 +817,7 @@
{placeholder}
class={twMerge(
'textarea-input resize-none caret-black dark:caret-white overflow-clip',
// The box (border/ring) lives on the wrapper; kill the textarea's own
// @tailwindcss/forms border, focus ring, and background so only the
// wrapper reads as the field.
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0',
'disabled:cursor-not-allowed disabled:placeholder:text-disabled',
COMPOSER_FIELD_RESET,
CHAT_INPUT_PADDING,
className
)}
@@ -1,16 +1,16 @@
<script lang="ts">
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { AIMode } from './AIChatManager.svelte'
import UsageMeter from './UsageMeter.svelte'
import { formatTokenCount } from './tokenUsage'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// The `/compact` slash command is only wired up in session-chat GLOBAL mode,
// so only advertise it where it actually works.
let canCompact = $derived(aiChatManager.isSessionChat && aiChatManager.mode === AIMode.GLOBAL)
let canCompact = $derived(chatHost.isSessionChat && chatHost.mode === AIMode.GLOBAL)
let providerModel = $derived(
$copilotSessionModel ?? $copilotInfo.defaultModel ?? $copilotInfo.aiModels[0]
@@ -27,10 +27,10 @@
// The same number the compaction trigger uses: the provider's report when
// one describes the current history (one turn stale by nature), otherwise
// a live chars/4 estimate of the stored context.
let usedTokens = $derived(Math.round(aiChatManager.contextTokens))
let usedTokens = $derived(Math.round(chatHost.contextTokens))
// Always surface usage once a conversation has started, at any fill level, so
// the user can watch context grow toward the compaction threshold.
let visible = $derived(usedTokens > 0 && aiChatManager.messages.length > 0)
let visible = $derived(usedTokens > 0 && chatHost.messages.length > 0)
// Compaction triggers at 80% of the window (COMPACTION_TRIGGER_RATIO); the
// gauge fills toward that point and turns red once it is reached.
@@ -3,7 +3,7 @@
import { FileText, X } from 'lucide-svelte'
import ContextElementBadge from './ContextElementBadge.svelte'
import { contextElementKey } from './context'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
// The single message typed while a turn was streaming, waiting to be
// auto-sent when the turn finishes. Rendered above the whole input stack
@@ -11,7 +11,7 @@
// conversation". Pressing Enter again appends another line to it; clicking
// the chip body (its X, or ArrowUp in the empty input) removes it and
// restores its content into the input so nothing is lost.
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
</script>
<!-- Attachment-only and context-only queues have empty text; without their
@@ -20,23 +20,23 @@
here only for context-ONLY queues: text queues pin the same chips, but
those stay visible in the composer, and repeating them would read as two
selections. -->
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0 || (aiChatManager.queuedContext?.length ?? 0) > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || (chatHost.queuedContext?.length ?? 0) > 0}
<!-- The body and the X are sibling buttons for the same action (an X inside a
clickable chip would be a nested interactive control, invalid ARIA). -->
<div
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60 hover:opacity-100"
>
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0}
<button
type="button"
class="min-w-0 grow text-left cursor-pointer"
title={aiChatManager.queuedMessage}
title={chatHost.queuedMessage}
aria-label="Remove queued message and put it back in the input"
onclick={() => aiChatManager.dequeueMessage()}
onclick={() => chatHost.dequeueMessage()}
>
{#if aiChatManager.queuedImages.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedImages as image, i (i)}
{#if chatHost.queuedImages.length > 0}
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
{#each chatHost.queuedImages as image, i (i)}
<img
src={image.dataUrl}
alt={image.name ?? 'queued image'}
@@ -45,9 +45,9 @@
{/each}
</div>
{/if}
{#if aiChatManager.queuedFiles.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedFiles as file, i (i)}
{#if chatHost.queuedFiles.length > 0}
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
{#each chatHost.queuedFiles as file, i (i)}
<span
class="flex flex-row items-center gap-1 px-1.5 rounded border border-border-light text-2xs text-secondary max-w-36"
title={file.name}
@@ -58,20 +58,20 @@
{/each}
</div>
{/if}
{#if aiChatManager.queuedMessage}
{#if chatHost.queuedMessage}
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
{aiChatManager.queuedMessage}
{chatHost.queuedMessage}
</p>
{/if}
</button>
{:else if aiChatManager.queuedContext?.length}
{:else if chatHost.queuedContext?.length}
<!-- Context badges are interactive themselves (popover preview), so a
context-only queue gets a plain row instead of the clickable body —
nesting the badges in it would be invalid ARIA and a badge click
would dequeue out from under the opening popover. The X (and
ArrowUp in the empty input) still restores the queue. -->
<div class="min-w-0 grow flex flex-row flex-wrap gap-1">
{#each aiChatManager.queuedContext as element (contextElementKey(element))}
{#each chatHost.queuedContext as element (contextElementKey(element))}
<ContextElementBadge contextElement={element} compact />
{/each}
</div>
@@ -82,7 +82,7 @@
iconOnly
title="Remove queued message and put it back in the input"
startIcon={{ icon: X }}
on:click={() => aiChatManager.dequeueMessage()}
on:click={() => chatHost.dequeueMessage()}
/>
</div>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { twMerge } from 'tailwind-merge'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
interface Props {
toolCallId: string | undefined
@@ -25,11 +25,11 @@
class: className
}: Props = $props()
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
function respond(confirmed: boolean) {
if (toolCallId) {
aiChatManager.handleToolConfirmation(toolCallId, confirmed)
chatHost.handleToolConfirmation(toolCallId, confirmed)
}
}
</script>
@@ -21,9 +21,9 @@
} from './planMode'
import { Button } from '$lib/components/common'
import { markdownProse } from '$lib/components/markdownProse'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
import { twMerge } from 'tailwind-merge'
@@ -69,7 +69,7 @@
const planLabel = $derived((planState && planCopy?.[planState]) ?? '')
const planDoc = $derived(
message.planArtifactId
? aiChatManager.artifacts.artifacts.find((a) => a.id === message.planArtifactId)
? chatHost.artifacts.artifacts.find((a) => a.id === message.planArtifactId)
: undefined
)
// The version this card wrote, not the document's current one, since later proposals move it on.
@@ -201,7 +201,7 @@
title="Open this plan in the side panel: {planDoc.name}"
startIcon={{ icon: FileText, classes: PLAN_MODE_TEXT_COLOR }}
endIcon={{ icon: PanelRight }}
on:click={() => aiChatManager.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)}
on:click={() => chatHost.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)}
>
<span class="font-main">Plan</span>
</Button>
@@ -0,0 +1,152 @@
import { getContext, setContext } from 'svelte'
import type { AIMode, AIAutonomyMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import type { DisplayMessage, Tool } from './shared'
import type { ContextElement } from './context'
import type { AttachedImage } from './imageUtils'
import type { AttachedTextFile } from './textFileUtils'
import type { PasteAttachment } from './pasteTokens'
import type { AttachedFilesStore } from './files/attachedFiles.svelte'
import type { SessionArtifactsStore } from './artifacts/artifactsState.svelte'
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
import type { FlowAIChatHelpers } from './flow/core'
import type { AppAIChatHelpers } from './app/core'
import type AIChatInput from './AIChatInput.svelte'
export type ChatSendRequestOptions = {
instructions?: string
pastes?: PasteAttachment[]
images?: AttachedImage[]
files?: AttachedTextFile[]
/** Selected-context snapshot for this turn, in place of the live selection. Set
* whenever a send settles its context ahead of the turn. A host with no context
* of its own ignores it. */
contextOverride?: ContextElement[]
/** Where `contextOverride` came from. 'pinned': chips picked for THIS message, so
* they are consumed from the live selection on send. 'replay': an edit or retry
* resending an older message's context, already consumed long ago. */
contextOverrideOrigin?: 'pinned' | 'replay'
}
/**
* What the chat view components (AIChatDisplay and everything it renders) need
* from whatever is driving the conversation. AIChatManager implements it for the
* copilot's own LLM loop; FlowChatViewHost implements it over a flow run's
* conversation so both chats render through the same components.
*
* Each affordance is gated by the field that answers for it attachments by
* `supportsMessageAttachments`, the model button by `supportsModelSettings`, and so on
* so a host turns on exactly what it can serve, and a new one is a matter of answering
* these fields rather than of being a copilot. `mode` is the exception, still read
* directly for chrome that only the copilot has.
*/
export interface ChatViewHost {
// Transcript
displayMessages: DisplayMessage[]
/** API-level messages. Only the count is read (context usage visibility). */
messages: readonly unknown[]
contextTokens: number
/** The workspace a message's paths and jobs resolve against, which a fork session
* pins away from the navigated one. */
readonly operatingWorkspace: string | undefined
loading: boolean
/** A turn this tab can neither follow nor stop, held by another tab on the same chat. */
readonly runHeldElsewhere: boolean
loadingLabel: string | undefined
compacting: boolean
currentReply: string
currentReasoning: string
currentReasoningActive: boolean
readonly reasoningHiddenIndicatorLabel: string | undefined
readonly automaticScroll: boolean
enableAutomaticScroll: () => void
disableAutomaticScroll: () => void
// Composer
instructions: string
readonly sendInFlight: boolean
/** Resolves to whether the draft was consumed as a turn. */
sendRequest: (options?: ChatSendRequestOptions) => Promise<boolean | undefined>
cancel: (reason?: string) => void
setAiChatInput: (aiChatInput: AIChatInput | null) => void
readonly queuedMessage: string
queuedContext: ContextElement[] | undefined
readonly queuedImages: AttachedImage[]
readonly queuedFiles: AttachedTextFile[]
queueMessage: (
text: string,
images?: AttachedImage[],
context?: ContextElement[],
files?: AttachedTextFile[]
) => void
dequeueMessage: () => void
setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void
clearComposerStaged: (key: string) => void
attachmentBytesExcluding: (selfKey: string) => number
// Per-message actions
storedImages: (displayMessageIndex: number) => AttachedImage[] | undefined
retryRequest: (messageIndex: number) => void
restartGeneration: (
displayMessageIndex: number,
newContent?: string,
pastes?: PasteAttachment[],
images?: AttachedImage[],
editedContext?: ContextElement[],
files?: AttachedTextFile[]
) => void | Promise<void>
handleUserQuestionAnswer: (toolId: string, choices: string[]) => boolean
handleToolConfirmation: (toolId: string, confirmed: boolean) => void
/** A tool is waiting on a run form the user is filling in. Escape belongs to that form
* then, not to the turn see AIChatDisplay's window handler. */
readonly hasPendingRunForm: boolean
isRunFormPending: (toolCallId: string) => boolean
// Copilot-only surfaces. Left undefined/false by hosts that have no LLM loop
// of their own; the chrome they drive hides itself.
mode?: AIMode
isSessionChat: boolean
/** Model + reasoning picker. Off where the model is configured elsewhere. */
supportsModelSettings: boolean
/** Click a user message to edit and resend it. Needs a host that can rewind
* its own transcript, which a host replaying a server-side run cannot. */
supportsMessageEditing: boolean
/** The `+` menu's file entry and drag-and-drop onto the panel. */
supportsMessageAttachments: boolean
/** The turn needs text: attachments alone cannot be sent. True where the consumer
* requires a message of its own an AI agent step refuses a run with neither a
* `user_message` nor manual memory. */
requiresMessageText: boolean
/** The `+` menu's folder entries, backed by `attachedFiles`. A linked folder is a
* live handle on the user's disk, so only a host reading files in the browser has one. */
supportsLinkedFolders: boolean
/** `accept` for the file picker. */
attachmentAccept: string
tools: Tool<any>[]
autonomyMode: AIAutonomyMode
setAutonomyMode: (mode: AIAutonomyMode) => void
readonly autoAcceptEditsActive: boolean
readonly autoAcceptEditsAvailable: boolean
readonly autoAcceptToolConfirmationsAvailable: boolean
readonly planModeAvailable: boolean
attachedFiles: AttachedFilesStore
artifacts: SessionArtifactsStore
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
flowAiChatHelpers?: FlowAIChatHelpers
appAiChatHelpers?: AppAIChatHelpers
}
const CHAT_VIEW_HOST_CONTEXT_KEY = 'chatViewHost'
export function setChatViewHost(host: ChatViewHost) {
setContext(CHAT_VIEW_HOST_CONTEXT_KEY, host)
}
/**
* Resolve the host driving the chat in this subtree. Falls back to the
* AIChatManager (scoped instance or app-wide singleton) so every existing
* copilot chat keeps working without setting anything.
*/
export function getChatViewHost(): ChatViewHost {
return getContext<ChatViewHost>(CHAT_VIEW_HOST_CONTEXT_KEY) ?? getAiChatManager()
}

Some files were not shown because too many files have changed in this diff Show More