mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-20 00:02:28 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2beda70df | ||
|
|
6649f1c520 | ||
|
|
967a7b1a8a | ||
|
|
e20cd87ef9 | ||
|
|
c297ed0052 | ||
|
|
4eab995cf7 | ||
|
|
381d4470ef | ||
|
|
e954d33613 | ||
|
|
5bb37ca338 | ||
|
|
189793c2e4 | ||
|
|
68f2248018 | ||
|
|
23c24a9688 | ||
|
|
a9ec0aec3a | ||
|
|
9d348f84c7 | ||
|
|
02e47de8b4 | ||
|
|
64dffe6106 | ||
|
|
3d08197182 |
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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,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+)*$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+10
-3
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+65
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
Generated
+1
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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 ( \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
WindmillApiError,
|
||||
readServerSentEvents,
|
||||
type WindmillChatApiOptions,
|
||||
type ConversationKind,
|
||||
type FlowConversation,
|
||||
type FlowConversationMessage,
|
||||
type JobUpdateEvent,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+54
-3
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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 folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t 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 folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t 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
Reference in New Issue
Block a user