From 51bd8692a482850f7ac8b04dd16db5876336b5b9 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 17 Jun 2026 14:50:10 +0200 Subject: [PATCH 01/15] feat: queue messages typed while ai chat is streaming (#9525) * feat(frontend): queue messages typed while ai chat is streaming * fix(frontend): avoid losing queued chat messages on send early-return * test(frontend): cover queued chat message semantics in AIChatManager * fix(frontend): complete ChatLoopResult mock in queued message tests * feat(frontend): single appendable queued message, send on cancel * fix(frontend): only auto-send queued message on a user cancel, not programmatic * chore(frontend): remove queued-message dev preview page * fix(frontend): clear queued chat message on conversation switch --- .../copilot/chat/AIChatDisplay.svelte | 2 + .../copilot/chat/AIChatInput.svelte | 19 ++ .../copilot/chat/AIChatManager.svelte.ts | 115 +++++++++- .../copilot/chat/AIChatManager.test.ts | 207 +++++++++++++++++- .../copilot/chat/QueuedMessageChip.svelte | 33 +++ 5 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 6eb1374ed9..cd9c50d1c2 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -38,6 +38,7 @@ import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import AIChatInput from './AIChatInput.svelte' + import QueuedMessageChip from './QueuedMessageChip.svelte' import { getModifierKey } from '$lib/utils' import type { SelectedContext } from './app/core' @@ -533,6 +534,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/if}
+ {#if inputPreface} {@render inputPreface()} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 4fb15b224b..3d9e5c550f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -188,6 +188,14 @@ focusInput() } + /** Put text back into the textarea (queued-message delete, or restore + * after a cancelled/errored turn), prepended to any draft so nothing + * the user typed is lost. */ + export function prependText(text: string) { + instructions = instructions.trim() ? `${text}\n\n${instructions}` : text + focusInput() + } + function clickOutside(node: HTMLElement) { function handleClick(event: MouseEvent) { if (node && !node.contains(event.target as Node)) { @@ -270,6 +278,17 @@ function sendRequest() { if (aiChatManager.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 + // tokens are expanded into the queued text (the queue is plain + // strings), so the full content survives the auto-send. + if (editingMessageIndex === null && instructions.trim()) { + aiChatManager.queueMessage(expanded(chatDraft(instructions, pastes))) + contextTextareaComponent?.clearForSend() + instructions = '' + pastes = [] + } return } if (editingMessageIndex !== null) { diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 94c08019f7..ca8ffd483e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -89,6 +89,10 @@ import { getLocalSetting, storeLocalSetting } from '$lib/utils' // from mode switches, and the estimate's chars/4 error. const COMPACTION_TRIGGER_RATIO = 0.8 const COMPACTION_TARGET_RATIO = 0.7 +// Abort reason for a deliberate user cancel (Esc / Stop). Programmatic cancels +// (panel teardown, save-and-clear) pass their own reason, so the queued-message +// flush can tell "the user wants to move on" from "the turn was torn down". +const USER_CANCEL_REASON = 'user_cancelled' const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode' const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode' const WEB_SEARCH_ERROR_HINT = @@ -220,6 +224,11 @@ export class AIChatManager { savedSize = $state(0) instructions = $state('') pendingPrompt = $state('') + // Message typed while a turn is streaming. There is only ever one queued + // message; pressing Enter again appends another line to it. Auto-sent when + // the turn finishes (clean completion or user cancel). Ephemeral — never + // saved to displayMessages or history. + queuedMessage = $state('') loading = $state(false) currentReply = $state('') currentReasoning = $state('') @@ -509,6 +518,38 @@ export class AIChatManager { this.aiChatInput = aiChatInput } + /** Queue the message typed while a turn is streaming. There is only ever + * one queued message; pressing Enter again appends the new text as another + * line so it all goes out as a single message. */ + queueMessage(text: string) { + const trimmed = text.trim() + if (!trimmed) { + return + } + this.queuedMessage = this.queuedMessage ? `${this.queuedMessage}\n${trimmed}` : trimmed + } + + /** Remove the queued message and put its text back into the input. */ + dequeueMessage() { + if (!this.queuedMessage) { + return + } + const message = this.queuedMessage + this.queuedMessage = '' + this.restoreToInput(message) + } + + /** Put text the user typed back where they can see it: into the input + * when it's mounted, otherwise back into the queue so it reappears with + * the chat panel instead of being silently dropped. */ + private restoreToInput(text: string) { + if (this.aiChatInput) { + this.aiChatInput.prependText(text) + } else { + this.queuedMessage = text + } + } + focusInput() { if (this.aiChatInput) { this.aiChatInput.focusInput() @@ -789,16 +830,22 @@ export class AIChatManager { } // Roll a turn that produced nothing usable back out of the transcript and - // hand its text back to the composer for editing/resending. + // hand its text back to the composer for editing/resending. `restoreToInput` + // is false when a queued message is about to take over (a user cancel with + // something queued) — then the rolled-back prompt is dropped rather than + // shoved back into the input, so the handoff to the queued message is clean. private restoreUnsentTurn = ( displayLenAfterUser: number, modelLenAfterUser: number, instructions: string, - pastes: PasteAttachment[] + pastes: PasteAttachment[], + restoreToInput: boolean = true ) => { this.displayMessages = this.displayMessages.slice(0, displayLenAfterUser - 1) this.messages = this.messages.slice(0, modelLenAfterUser - 1) - this.aiChatInput?.restoreInstructions(instructions, pastes) + if (restoreToInput) { + this.aiChatInput?.restoreInstructions(instructions, pastes) + } } private chatRequest = async ({ @@ -1003,9 +1050,12 @@ export class AIChatManager { isPreprocessor?: boolean } = {} ) => { + // Returns whether the message was actually turned into a chat turn — + // the queue flush uses this to restore messages dropped by an early + // return instead of silently losing them. const requestedMode = options.mode ?? this.mode if (!isAIModeVisible(requestedMode)) { - return + return false } this.changeMode(requestedMode, undefined, { lang: options.lang, @@ -1015,7 +1065,7 @@ export class AIChatManager { this.instructions = options.instructions } if (!this.instructions.trim()) { - return + return false } if (this.beforeSend) { try { @@ -1032,7 +1082,7 @@ export class AIChatManager { }. Your message was not sent — please try again.`, true ) - return + return false } } const isFirstUserTurn = !this.displayMessages.some((message) => message.role === 'user') @@ -1045,6 +1095,10 @@ export class AIChatManager { // from saveChat) must not make the catch commit the turn a second time. let turnOutcomeHandled = false let webSearchUnavailable = false + // Gates the queued-message flush below: only a cleanly committed turn + // auto-sends the next queued message. Cancel, error, and empty-response + // rollbacks leave it false so queued text is restored to the input. + let turnCommittedCleanly = false try { const oldSelectedContext = this.contextManager?.getSelectedContext() ?? [] if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) { @@ -1313,7 +1367,17 @@ export class AIChatManager { // (or only reasoning) — treat the turn as unsent (matches Claude Code). // contextUsage is left as-is: the turn is rolled back, so the last // report (pre-turn, possibly debited by compaction) still stands. - this.restoreUnsentTurn(displayLenAfterUser, modelLenAfterUser, sentInstructions, sentPastes) + // When the user cancelled with a message queued, that message is + // about to auto-send (see the flush below) — drop the rolled-back + // prompt instead of restoring it to the input so the handoff is clean. + const willAutoSendQueued = this.wasCancelledByUser() && !!this.queuedMessage + this.restoreUnsentTurn( + displayLenAfterUser, + modelLenAfterUser, + sentInstructions, + sentPastes, + !willAutoSendQueued + ) if (this.displayMessages.length === 0) { // saveChat no-ops on an empty transcript; the chat persisted earlier // this turn would linger in history and resurface the rolled-back @@ -1340,6 +1404,10 @@ export class AIChatManager { this.acceptPendingFlowEdits() } await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) + // Only this branch is a clean send: the queued-message flush below + // auto-sends the next message after it (set after saveChat so a + // persistence failure falls through to the restore path instead). + turnCommittedCleanly = true if (isFirstUserTurn && this.afterFirstTurnSaved) { void Promise.resolve(this.afterFirstTurnSaved()).catch((e) => { console.error('AIChatManager afterFirstTurnSaved hook failed', e) @@ -1369,6 +1437,31 @@ export class AIChatManager { } finally { this.loading = false } + // Flush the queued message. Send it after a cleanly committed turn OR a + // deliberate user cancel (Esc / Stop) — in both cases the user is ready + // to move on, so it sends automatically. A genuine error, an + // empty-response rollback, or a programmatic cancel (panel teardown, + // save-and-clear) leaves it in place as a card so it isn't fired into a + // failed or torn-down turn. + if ((turnCommittedCleanly || this.wasCancelledByUser()) && this.queuedMessage) { + const next = this.queuedMessage + this.queuedMessage = '' + const accepted = await this.sendRequest({ instructions: next }) + if (accepted === false) { + // The auto-send bailed before becoming a turn (e.g. beforeSend + // failed); keep it as the queued message instead of losing it. + this.queuedMessage = next + } + } + return true + } + + // True when the current turn's controller was aborted by a deliberate user + // cancel (Esc / Stop), as opposed to a programmatic cancel (panel teardown, + // save-and-clear) or no abort at all. Gates the queued-message auto-send. + private wasCancelledByUser(): boolean { + const signal = this.abortController?.signal + return !!signal?.aborted && signal.reason === USER_CANCEL_REASON } cancel = (reason?: string) => { @@ -1380,7 +1473,7 @@ export class AIChatManager { resolveQuestion(undefined) } this.userQuestionCallbacks.clear() - const cancelReason = reason ?? 'user_cancelled' + const cancelReason = reason ?? USER_CANCEL_REASON console.log('cancelling request:', { reason: cancelReason, abortController: this.abortController @@ -1460,6 +1553,9 @@ export class AIChatManager { saveAndClear = async () => { this.cancel('saveAndClear') + // Drop any message queued in this conversation so it can't auto-send into + // the fresh chat or linger as a card across the switch. + this.queuedMessage = '' await this.historyManager.save(this.displayMessages, this.messages, this.contextUsage) this.displayMessages = [] this.messages = [] @@ -1469,6 +1565,9 @@ export class AIChatManager { loadPastChat = async (id: string) => { const chat = this.historyManager.loadPastChat(id) if (chat) { + // Drop any message queued in the current conversation so it doesn't + // auto-send into the loaded one or linger as a card across the switch. + this.queuedMessage = '' this.displayMessages = chat.displayMessages this.messages = chat.actualMessages this.contextUsage = normalizeContextUsage(chat.contextUsage) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index d61f7ae746..01929cefc7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -315,6 +315,205 @@ describe('AIChatManager persisted autonomy default', () => { }) }) +describe('AIChatManager queued messages', () => { + const model = { provider: 'openai', model: 'gpt-4o' } + + // The turn-outcome handling rolls back turns with no usable output, so a + // "successful" send must produce a reply to take the clean-commit path + // (which is what gates the queued-message auto-send). + const replyWith = (reply: string) => + mocks.runChatLoop.mockImplementation(async (config: any) => { + const message = { role: 'assistant' as const, content: reply } + config.addedMessages?.push(message) + return { + addedMessages: [message], + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + hitMaxIterations: false + } + }) + + beforeEach(() => { + localStorage.clear() + mocks.getCurrentModel.mockReturnValue(model) + mocks.tryGetCurrentModel.mockReturnValue(model) + }) + + function createInputMock() { + return { + prependText: vi.fn(), + restoreInstructions: vi.fn(), + focusInput: vi.fn() + } + } + + function createManager(input?: ReturnType) { + const manager = new AIChatManager() + manager.mode = AIMode.NAVIGATOR + if (input) { + manager.setAiChatInput(input as unknown as Parameters[0]) + } + return manager + } + + it('queues a single trimmed message and ignores blank input', () => { + const manager = createManager() + manager.queueMessage(' first ') + manager.queueMessage(' ') + expect(manager.queuedMessage).toBe('first') + }) + + it('appends additional lines to the single queued message', () => { + const manager = createManager() + manager.queueMessage('first line') + manager.queueMessage('second line') + expect(manager.queuedMessage).toBe('first line\nsecond line') + }) + + it('dequeues the message and restores it into the input', () => { + const input = createInputMock() + const manager = createManager(input) + manager.queuedMessage = 'line one\nline two' + + manager.dequeueMessage() + + expect(manager.queuedMessage).toBe('') + expect(input.prependText).toHaveBeenCalledWith('line one\nline two') + }) + + it('re-queues instead of dropping when the input is unmounted', () => { + const manager = createManager() + manager.queuedMessage = 'keep me' + + manager.dequeueMessage() + + // no input to restore into → the message stays queued + expect(manager.queuedMessage).toBe('keep me') + }) + + it('auto-sends the queued message on a clean completion', async () => { + replyWith('done') + const manager = createManager(createInputMock()) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + expect(mocks.runChatLoop).toHaveBeenCalledTimes(2) + expect(manager.queuedMessage).toBe('') + const userMessages = manager.displayMessages + .filter((m) => m.role === 'user') + .map((m) => m.content) + expect(userMessages).toEqual(['first', 'followup']) + }) + + it('keeps the queued message as a card (not flushed to input) when the turn errors', async () => { + const input = createInputMock() + const manager = createManager(input) + mocks.runChatLoop.mockRejectedValue(new Error('provider down')) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) + // stays a card, nothing flushed into the input + expect(manager.queuedMessage).toBe('followup') + expect(input.prependText).not.toHaveBeenCalled() + }) + + it('auto-sends the queued message when the user cancels the turn (Esc/Stop)', async () => { + const manager = createManager(createInputMock()) + // the followup turn completes cleanly... + replyWith('done') + // ...but the first turn is cancelled by the user + mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => { + abortController.abort('user_cancelled') + throw new Error('aborted') + }) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + // cancel sends the queued message automatically + expect(manager.queuedMessage).toBe('') + const userMessages = manager.displayMessages + .filter((m) => m.role === 'user') + .map((m) => m.content) + expect(userMessages).toContain('followup') + }) + + it('does NOT auto-send on a programmatic cancel (e.g. save-and-clear / teardown)', async () => { + const manager = createManager(createInputMock()) + replyWith('done') + // the turn is aborted programmatically, not by the user pressing Esc/Stop + mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => { + abortController.abort('saveAndClear') + throw new Error('aborted') + }) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + // a non-user abort must not fire the queued message; it stays a card + expect(manager.queuedMessage).toBe('followup') + expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) + }) + + it('does not restore the cancelled prompt to the input when a queued message takes over', async () => { + const input = createInputMock() + const manager = createManager(input) + replyWith('done') + // cancel before any usable output → the rollback (restoreUnsentTurn) path + mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => { + abortController.abort('user_cancelled') + throw new Error('aborted') + }) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'the long cancelled prompt' }) + + // clean handoff: queued message sent, cancelled prompt NOT shoved back in + expect(manager.queuedMessage).toBe('') + expect(input.restoreInstructions).not.toHaveBeenCalled() + }) + + it('re-queues the message when its auto-send is rejected by beforeSend', async () => { + replyWith('done') + const input = createInputMock() + const manager = createManager(input) + // first turn goes through, the queued auto-send is rejected + manager.beforeSend = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('workspace commit failed')) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) + // the rejected message stays a card rather than being lost or moved to input + expect(manager.queuedMessage).toBe('followup') + expect(input.prependText).not.toHaveBeenCalled() + }) + + it('drops the queued message when switching conversations (no cross-chat leak)', async () => { + const manager = createManager(createInputMock()) + + manager.queuedMessage = 'meant for chat A' + await manager.saveAndClear() + expect(manager.queuedMessage).toBe('') + + manager.queuedMessage = 'still meant for chat A' + vi.spyOn(manager.historyManager, 'loadPastChat').mockReturnValue({ + id: 'chat-b', + title: 'Chat B', + displayMessages: [], + actualMessages: [], + lastModified: 0 + } as unknown as ReturnType) + await manager.loadPastChat('chat-b') + expect(manager.queuedMessage).toBe('') + }) +}) + describe('AIChatManager context compaction', () => { // claude-sonnet-4-6 resolves to a known 1M window (modelConfig is // unmocked): compaction triggers at a projected 800k and drops head @@ -708,7 +907,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockImplementation(async (config) => { config.callbacks.onNewToken('Here is the partial ') config.callbacks.onNewToken('answer') - config.abortController.abort('user_cancelled') + config.abortController.abort() throw new Error('aborted') }) @@ -733,7 +932,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockImplementation(async (config) => { config.callbacks.onReasoningStart?.() config.callbacks.onReasoningDelta?.('still thinking...') - config.abortController.abort('user_cancelled') + config.abortController.abort() throw new Error('aborted') }) @@ -764,7 +963,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockImplementation(async (config) => { config.callbacks.onNewToken('Partial from Claude') config.callbacks.onMessageEnd() - config.abortController.abort('user_cancelled') + config.abortController.abort() throw new Error('aborted') }) @@ -790,7 +989,7 @@ describe('AIChatManager sendRequest lifecycle', () => { config.callbacks.onNewToken('The full answer') config.addedMessages!.push({ role: 'assistant', content: 'The full answer' }) config.callbacks.onMessageEnd() - config.abortController.abort('user_cancelled') + config.abortController.abort() throw new Error('aborted') }) diff --git a/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte b/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte new file mode 100644 index 0000000000..3d40225360 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte @@ -0,0 +1,33 @@ + + +{#if aiChatManager.queuedMessage} +
+
+

+ {aiChatManager.queuedMessage} +

+
+
+{/if} From f4425fca9fb0d02b845bd72888ade54905c5a30b Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:01:03 +0200 Subject: [PATCH 02/15] feat(ai-chat): self-hosted docs tools via windmill.dev llms.txt + ask benchmark (#9578) * feat(ai-chat): add self-hosted docs tools fetching from windmill.dev llms.txt Co-Authored-By: Claude Fable 5 * test(ai-evals): add ask benchmark mode comparing inkeep vs llms.txt docs tools Co-Authored-By: Claude Fable 5 * test(ai-chat): fix docs link sanitizer tests to match skip-all-`../` guard Co-Authored-By: Claude Fable 5 * feat(ai-chat): add hybrid full-text docs search tool and ask variant Co-Authored-By: Claude Fable 5 * feat(ai-chat): expose docs search tools in the global workspace assistant Co-Authored-By: Claude Fable 5 * refactor(ai-chat): drop inkeep/llmstxt arms, keep only hybrid docs search Co-Authored-By: Claude Fable 5 * docs(ai-chat): remove docs-tool benchmark write-up Co-Authored-By: Claude Fable 5 * refactor(ai-evals): remove ask mode, cover docs search via global mode Co-Authored-By: Claude Fable 5 * nits * refactor(ai-chat): swap navigator + api copilots from inkeep to search_docs Co-Authored-By: Claude Fable 5 * fix(ai-chat): point read_docs_page empty-path hint at search_docs Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- ai_evals/adapters/frontend/benchmarkRunner.ts | 7 +- .../adapters/frontend/vitestAdapter.test.ts | 3 +- ai_evals/cases/global.yaml | 73 ++ ai_evals/core/cases.test.ts | 15 + ai_evals/core/runSuite.ts | 4 +- ai_evals/core/types.ts | 11 +- .../copilot/chat/AIChatManager.svelte.ts | 5 +- .../lib/components/copilot/chat/api/core.ts | 7 +- .../lib/components/copilot/chat/ask/core.ts | 16 +- .../components/copilot/chat/docs/core.test.ts | 464 ++++++++++ .../lib/components/copilot/chat/docs/core.ts | 863 ++++++++++++++++++ .../components/copilot/chat/global/core.ts | 10 + .../components/copilot/chat/navigator/core.ts | 97 +- 13 files changed, 1471 insertions(+), 104 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/docs/core.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/docs/core.ts diff --git a/ai_evals/adapters/frontend/benchmarkRunner.ts b/ai_evals/adapters/frontend/benchmarkRunner.ts index 1729df7170..32107eadf1 100644 --- a/ai_evals/adapters/frontend/benchmarkRunner.ts +++ b/ai_evals/adapters/frontend/benchmarkRunner.ts @@ -96,7 +96,12 @@ async function getModeRunner( } function parseMode(value: string | undefined): FrontendBenchmarkMode { - if (value === "flow" || value === "app" || value === "script" || value === "global") { + if ( + value === "flow" || + value === "app" || + value === "script" || + value === "global" + ) { return value; } throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`); diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index ebbbac8d11..2739eedce2 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -434,5 +434,6 @@ benchmarkIt( resetBenchmarkMockBackend() } }, - 600_000 + // Full-suite runs (30+ cases at concurrency 2-3) routinely exceed 10 minutes. + 7_200_000 ) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 28839b0594..766515519b 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -870,3 +870,76 @@ judgeChecklist: - fetches the logs for the requested job id - explains the failure from the returned logs (connection refused to the upstream API) + +# --- Documentation search (search_docs) --- +# Pure product-knowledge questions: the assistant should consult the docs via +# search_docs and answer conversationally, not draft or mutate anything. No +# draft is produced, so the global judge is skipped and we validate tool use. + +- id: global-docs-ai-agent-step + prompt: |- + Does Windmill support a flow step where an LLM decides which of my scripts to call based on the input? + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - search_docs + forbiddenToolsUsed: + - write_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + +- id: global-docs-retry-step + prompt: |- + How does automatic retry work for a flow step that calls a flaky API? + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - search_docs + forbiddenToolsUsed: + - write_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + +- id: global-docs-key-value-store + prompt: |- + Can I use a Redis-style key-value store from my Windmill scripts, and how? + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - search_docs + forbiddenToolsUsed: + - write_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + +- id: global-docs-cron-schedule-format + prompt: |- + How do Windmill's cron schedules work, and what format does the schedule expression use? + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - search_docs + forbiddenToolsUsed: + - write_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true diff --git a/ai_evals/core/cases.test.ts b/ai_evals/core/cases.test.ts index 05e2f1527b..9955a73fa9 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -246,6 +246,21 @@ describe("loadCases", () => { }); }); + it("loads global docs-search cases as tool-use checks", async () => { + const globalCases = await loadCases("global"); + const docsCases = globalCases.filter((entry) => + entry.id.startsWith("global-docs-"), + ); + expect(docsCases.length).toBeGreaterThanOrEqual(3); + + // Each docs case verifies the assistant reaches for search_docs and does not + // draft anything; with no draft, the global judge is skipped. + for (const entry of docsCases) { + expect(entry.skipJudge).toBe(true); + expect(entry.toolExpect?.requiredToolsUsed).toContain("search_docs"); + } + }); + it("loads tool expectations for workspace mutation cases", async () => { const scriptCases = await loadCases("script"); const caseEntry = scriptCases.find( diff --git a/ai_evals/core/runSuite.ts b/ai_evals/core/runSuite.ts index ed82d841cb..bb0f9b99a4 100644 --- a/ai_evals/core/runSuite.ts +++ b/ai_evals/core/runSuite.ts @@ -225,7 +225,9 @@ async function runCaseAttempts(input: { checklist: input.evalCase.judgeChecklist, initial, expected: input.modeRunner.mode === "cli" ? undefined : expected, - actual: run.actual, + actual: input.modeRunner.prepareJudgeActual + ? input.modeRunner.prepareJudgeActual(run.actual) + : run.actual, model: input.judgeModel, }); diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 27c2fcddac..9e2e32d5c3 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -172,7 +172,10 @@ export interface ToolValidationSpec { toolCallArgs?: ToolCallArgumentRule[]; } -export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec | GlobalValidationSpec; +export type EvalValidationSpec = + | FlowValidationSpec + | AppValidationSpec + | GlobalValidationSpec; export interface EvalCase { id: string; @@ -294,6 +297,12 @@ export interface ModeRunner { context: ModeRunContext; }): Promise; buildArtifacts?(actual: TActual): BenchmarkArtifactFile[]; + /** + * Optional transform applied to `actual` before it is handed to the LLM judge. + * Use it to strip fields the judge must stay blind to (e.g. which docs-tool + * arm produced an answer). When omitted, the judge receives `actual` as-is. + */ + prepareJudgeActual?(actual: TActual): unknown; } export interface BenchmarkAttemptResult { diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index ca8ffd483e..cb775a6084 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -49,6 +49,7 @@ import { get } from 'svelte/store' import { BROWSER } from 'esm-env' import { workspaceStore, type DBSchemas } from '$lib/stores' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' +import { readDocsPageTool, searchDocsTool } from './docs/core' import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte' import { createAppBackendRunnableContextElement, @@ -400,7 +401,7 @@ export class AIChatManager { try { this.apiTools = await loadApiTools() if (this.mode === AIMode.API) { - this.tools = [...this.apiTools] + this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools] } } catch (err) { console.error('Error loading api tools', err) @@ -666,7 +667,7 @@ export class AIChatManager { } else if (mode === AIMode.API) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareApiSystemMessage(customPrompt) - this.tools = [...this.apiTools] + this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools] this.helpers = {} } else if (mode === AIMode.GLOBAL) { const customPrompt = getCombinedCustomPrompt(mode) diff --git a/frontend/src/lib/components/copilot/chat/api/core.ts b/frontend/src/lib/components/copilot/chat/api/core.ts index 4e47baea72..e4c2ab07c7 100644 --- a/frontend/src/lib/components/copilot/chat/api/core.ts +++ b/frontend/src/lib/components/copilot/chat/api/core.ts @@ -4,7 +4,6 @@ import type { } from 'openai/resources/index.mjs' import type { Tool } from '../shared' import { loadApiTools } from './apiTools' -import { getDocumentationTool } from '../navigator/core' import { userStore } from '$lib/stores' import { get } from 'svelte/store' @@ -14,13 +13,13 @@ You are Windmill's intelligent assistant, designed to interact with the platform Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications. You have access to these tools: -1. Get documentation for user requests (get_documentation) +1. Search the documentation (search_docs) and read a documentation page (read_docs_page) 2. A comprehensive list of API endpoints to interact with the Windmill backend INSTRUCTIONS: - You can directly query, list, create, update, and delete various Windmill resources like scripts, flows, jobs, resources, variables, schedules, and workers through the provided API tools. - When users ask about specific data or want to perform operations, use the appropriate API endpoints to fulfill their requests. -- Use get_documentation to retrieve accurate information about features, concepts, and best practices when needed. +- Use search_docs (then read_docs_page on a returned Source URL) to retrieve accurate information about features, concepts, and best practices when needed. - Always present API results in a clear, readable format for the user. - If you need to make multiple related API calls to fulfill a request, do so systematically and explain what you're doing. - When showing lists of items, provide meaningful summaries rather than overwhelming the user with raw data. @@ -55,8 +54,6 @@ export async function getApiTools(): Promise[]> { return apiToolsCache } -export const apiTools: Tool<{}>[] = [getDocumentationTool] - export function prepareApiSystemMessage(customPrompt?: string): ChatCompletionSystemMessageParam { let content = CHAT_SYSTEM_PROMPT(get(userStore)?.username ?? '') diff --git a/frontend/src/lib/components/copilot/chat/ask/core.ts b/frontend/src/lib/components/copilot/chat/ask/core.ts index f9ba219599..b93ace1179 100644 --- a/frontend/src/lib/components/copilot/chat/ask/core.ts +++ b/frontend/src/lib/components/copilot/chat/ask/core.ts @@ -3,19 +3,23 @@ import type { ChatCompletionUserMessageParam } from 'openai/resources/index.mjs' import type { Tool } from '../shared' -import { getDocumentationTool } from '../navigator/core' +import { readDocsPageTool, searchDocsTool } from '../docs/core' export const CHAT_SYSTEM_PROMPT = ` You are Windmill's intelligent assistant, designed to answer questions about its functionality. It is your only purpose to help the user in the context of the windmill application. Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications. You have access to these tools: -1. Get documentation for user requests (get_documentation) +1. Search the documentation (search_docs) +2. Read a documentation page (read_docs_page) INSTRUCTIONS: -- When user asks about something, use the get_documentation tool to retrieve accurate information about how to fulfill the user's request. -- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible. -- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team. +- Call search_docs FIRST with a few distinctive keywords from the user's question to find the most relevant documentation pages and matching snippets. +- If the snippets already answer the question, answer directly. Otherwise call read_docs_page with one of the returned Source URLs to read the full page; if read_docs_page returns a list of section headings, call it again with the same path and a \`section\` argument to read the relevant section. +- If the first search returns nothing useful, retry with different or broader keywords before giving up. +- Answer based ONLY on what you find in the documentation. Do not invent features, flags, syntax, or behavior that you did not see in the docs. +- Always include the documentation URL(s) you consulted in your answer. Cite the exact "Source" URL shown in the search results (or the "Source page" URL at the top of a read page) — never reconstruct a URL from a link inside the page body. +- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team. GENERAL PRINCIPLES: - Be concise but thorough @@ -23,7 +27,7 @@ GENERAL PRINCIPLES: - If you encounter an error or can't complete a request, explain why and suggest alternatives ` -export const askTools: Tool<{}>[] = [getDocumentationTool] +export const askTools: Tool<{}>[] = [searchDocsTool, readDocsPageTool] export function prepareAskSystemMessage(customPrompt?: string): ChatCompletionSystemMessageParam { let content = CHAT_SYSTEM_PROMPT diff --git a/frontend/src/lib/components/copilot/chat/docs/core.test.ts b/frontend/src/lib/components/copilot/chat/docs/core.test.ts new file mode 100644 index 0000000000..da0e7f0880 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/docs/core.test.ts @@ -0,0 +1,464 @@ +import { describe, expect, it } from 'vitest' +import { + buildDocsOutline, + canonicalDocsPageUrl, + extractDocsSection, + formatDocsSearchResults, + makeSnippet, + mergeDocsSearchResults, + normalizeDocsUrl, + parseDocsFullText, + parseDocsHeadings, + parseDocsIndex, + renderDocsPageResult, + sanitizeDocsMarkdownLinks, + searchDocsIndex, + searchDocsPages +} from './core' + +const SAMPLE = `# Jobs + +Intro text about jobs. + +## Job kinds + +Some kinds. + +## Result + +### Result of jobs that failed + +\`\`\` +{ "error": "boom" } +\`\`\` + +### Result streaming + +#### Returning a stream directly + +\`\`\`python +# Returning a stream directly is a comment heading that must be ignored +def main(): + pass +\`\`\` + +## Retention policy + +Final section. +` + +describe('parseDocsHeadings', () => { + it('parses headings with their levels and ignores headings inside fenced code blocks', () => { + const headings = parseDocsHeadings(SAMPLE) + const titles = headings.map((h) => `${h.level}:${h.title}`) + + expect(titles).toEqual([ + '1:Jobs', + '2:Job kinds', + '2:Result', + '3:Result of jobs that failed', + '3:Result streaming', + '4:Returning a stream directly', + '2:Retention policy' + ]) + // The "# Returning a stream directly is a comment..." line inside the + // python fence must not be parsed as a heading. + expect(titles).not.toContain('1:Returning a stream directly is a comment heading that must be ignored') + }) + + it('returns startIndex offsets that point at the heading line', () => { + const headings = parseDocsHeadings(SAMPLE) + for (const heading of headings) { + expect(SAMPLE.slice(heading.startIndex)).toMatch( + new RegExp(`^#{${heading.level}}\\s+${heading.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) + ) + } + }) + + it('handles tilde fences', () => { + const content = '# Title\n\n~~~\n# not a heading\n~~~\n\n## Real\n' + const headings = parseDocsHeadings(content) + expect(headings.map((h) => h.title)).toEqual(['Title', 'Real']) + }) +}) + +describe('extractDocsSection', () => { + it('extracts a section from its heading up to the next same-or-higher level heading', () => { + const section = extractDocsSection(SAMPLE, 'Result') + expect(section).toBeDefined() + expect(section).toContain('## Result') + expect(section).toContain('### Result of jobs that failed') + expect(section).toContain('### Result streaming') + // Stops before the next level-2 heading. + expect(section).not.toContain('## Retention policy') + }) + + it('matches case-insensitively and tolerates punctuation differences', () => { + const section = extractDocsSection(SAMPLE, 'retention-policy!') + expect(section).toBeDefined() + expect(section).toContain('## Retention policy') + expect(section).toContain('Final section.') + }) + + it('returns the deepest section bounded by the next same-level heading', () => { + const section = extractDocsSection(SAMPLE, 'Result streaming') + expect(section).toBeDefined() + expect(section).toContain('### Result streaming') + expect(section).toContain('#### Returning a stream directly') + expect(section).not.toContain('## Retention policy') + }) + + it('returns undefined when no heading matches', () => { + expect(extractDocsSection(SAMPLE, 'Nonexistent section')).toBeUndefined() + }) +}) + +describe('buildDocsOutline', () => { + it('lists headings with approximate per-section sizes and indentation', () => { + const outline = buildDocsOutline(SAMPLE) + expect(outline).toContain('- Jobs (~') + expect(outline).toContain(' - Job kinds (~') + expect(outline).toContain(' - Result of jobs that failed (~') + }) + + it('handles pages with no headings', () => { + expect(buildDocsOutline('just some text\nwith no headings')).toBe( + '(no markdown headings found on this page)' + ) + }) +}) + +describe('normalizeDocsUrl', () => { + it('appends .md to a bare path', () => { + expect(normalizeDocsUrl('/docs/core_concepts/jobs')).toBe( + 'https://www.windmill.dev/docs/core_concepts/jobs.md' + ) + }) + + it('accepts a path without a leading slash', () => { + expect(normalizeDocsUrl('docs/core_concepts/jobs')).toBe( + 'https://www.windmill.dev/docs/core_concepts/jobs.md' + ) + }) + + it('accepts a full URL and strips anchors and query strings', () => { + expect( + normalizeDocsUrl('https://www.windmill.dev/docs/core_concepts/jobs#result?foo=bar') + ).toBe('https://www.windmill.dev/docs/core_concepts/jobs.md') + }) + + it('does not double-append .md', () => { + expect(normalizeDocsUrl('/docs/core_concepts/jobs.md')).toBe( + 'https://www.windmill.dev/docs/core_concepts/jobs.md' + ) + }) + + it('strips a trailing slash before appending .md', () => { + expect(normalizeDocsUrl('/docs/core_concepts/jobs/')).toBe( + 'https://www.windmill.dev/docs/core_concepts/jobs.md' + ) + }) + + it('strips docusaurus numeric ordering prefixes from path segments', () => { + expect(normalizeDocsUrl('/docs/flows/13_flow_branches')).toBe( + 'https://www.windmill.dev/docs/flows/flow_branches.md' + ) + }) + + it('converts a .mdx source suffix to .md', () => { + expect(normalizeDocsUrl('/docs/flows/13_flow_branches.mdx')).toBe( + 'https://www.windmill.dev/docs/flows/flow_branches.md' + ) + }) +}) + +describe('sanitizeDocsMarkdownLinks', () => { + const PAGE = 'https://www.windmill.dev/docs/flows/flow_editor.md' + + it('rewrites a relative .mdx source link to a canonical published URL', () => { + expect(sanitizeDocsMarkdownLinks('See [retries](./14_retries.mdx) for more.', PAGE)).toBe( + 'See [retries](https://www.windmill.dev/docs/flows/retries) for more.' + ) + }) + + it('strips numeric prefixes from same-directory links', () => { + expect(sanitizeDocsMarkdownLinks('[handling](./8_error_handling.mdx)', PAGE)).toBe( + '[handling](https://www.windmill.dev/docs/flows/error_handling)' + ) + }) + + it('preserves anchors when rewriting', () => { + expect(sanitizeDocsMarkdownLinks('[branch all](./13_flow_branches.mdx#branch-all)', PAGE)).toBe( + '[branch all](https://www.windmill.dev/docs/flows/flow_branches#branch-all)' + ) + }) + + it('leaves image and external links untouched', () => { + const input = + '![diagram](./assets/flow_example.png) and [site](https://example.com/page.md)' + expect(sanitizeDocsMarkdownLinks(input, PAGE)).toBe(input) + }) + + it('leaves bare anchor links untouched', () => { + expect(sanitizeDocsMarkdownLinks('[top](#introduction)', PAGE)).toBe('[top](#introduction)') + }) + + // `../` links are authored against the docusaurus source tree, whose directory + // depth differs from the published URL on slug-flattened pages, so resolving + // them against the page URL is unreliable (a single `../` can over-escape just + // as a double one does). All `../` links are left untouched and disambiguated + // by the canonical "Source page" header instead. + it('leaves single ../ cross-directory links untouched', () => { + const input = '[handling](../core_concepts/8_error_handling.mdx)' + expect(sanitizeDocsMarkdownLinks(input, PAGE)).toBe(input) + }) + + it('leaves double ../../ cross-directory links untouched', () => { + const input = '[retries](../../flows/14_retries.md)' + expect(sanitizeDocsMarkdownLinks(input, PAGE)).toBe(input) + }) +}) + +describe('canonicalDocsPageUrl', () => { + it('returns the published URL without the .md suffix', () => { + expect(canonicalDocsPageUrl('/docs/flows/flow_editor')).toBe( + 'https://www.windmill.dev/docs/flows/flow_editor' + ) + }) + + it('strips numeric prefixes so a source-style path maps to the published URL', () => { + expect(canonicalDocsPageUrl('/docs/flows/14_retries.md')).toBe( + 'https://www.windmill.dev/docs/flows/retries' + ) + }) +}) + +describe('renderDocsPageResult', () => { + it('returns the whole page when small and no section requested', () => { + expect(renderDocsPageResult(SAMPLE)).toBe(SAMPLE) + }) + + it('returns an outline for large pages with no section requested', () => { + const large = `# Big\n\n${'x'.repeat(25_000)}\n\n## Tail\n\nmore` + const result = renderDocsPageResult(large) + expect(result).toContain('This documentation page is large') + expect(result).toContain('- Big (~') + expect(result).toContain('- Tail (~') + }) + + it('returns the requested section content when found', () => { + const result = renderDocsPageResult(SAMPLE, 'Job kinds') + expect(result).toContain('## Job kinds') + expect(result).toContain('Some kinds.') + }) + + it('returns the outline with a note when the requested section is missing', () => { + const result = renderDocsPageResult(SAMPLE, 'Does not exist') + expect(result).toContain('No section matching "Does not exist" was found') + expect(result).toContain('- Jobs (~') + }) +}) + +// Mirrors the llms-full.txt layout: a corpus preamble, then per-page blocks each +// introduced by a `---` + `## ` lead-in followed by a `Source:` line. +const SAMPLE_FULL = `# Windmill + +> Preamble blurb that precedes the first Source line and must be ignored. + +## Browser automation + +Source: https://www.windmill.dev/docs/advanced/browser_automation + +# Browser automation + +By default, a worker group named \`reports\` handles jobs with the \`chromium\` tag. +The chromium binary will be available on these workers at /usr/bin/chromium. +You can disable the sandbox by passing the --no-sandbox flag. + +--- + +## Worker groups + +Source: https://www.windmill.dev/docs/core_concepts/worker_groups + +# Worker groups + +Worker groups let you assign tags to workers. +Set the chromium tag on a worker so it can run browser jobs. + +--- + +## Scheduling + +Source: https://www.windmill.dev/docs/core_concepts/scheduling + +# Scheduling + +Use cron expressions to schedule scripts and flows. +` + +describe('parseDocsFullText', () => { + it('splits the corpus into pages keyed by Source URL, dropping the preamble', () => { + const pages = parseDocsFullText(SAMPLE_FULL) + expect(pages.map((p) => p.url)).toEqual([ + 'https://www.windmill.dev/docs/advanced/browser_automation', + 'https://www.windmill.dev/docs/core_concepts/worker_groups', + 'https://www.windmill.dev/docs/core_concepts/scheduling' + ]) + }) + + it('uses each page first heading as its title', () => { + const pages = parseDocsFullText(SAMPLE_FULL) + expect(pages.map((p) => p.title)).toEqual([ + 'Browser automation', + 'Worker groups', + 'Scheduling' + ]) + }) + + it('strips the trailing category lead-in so it is not mis-attributed to the previous page', () => { + const pages = parseDocsFullText(SAMPLE_FULL) + const browser = pages.find((p) => p.url.endsWith('/browser_automation')) + // "## Worker groups" introduces the *next* page and must not leak into this body. + expect(browser?.body).not.toContain('Worker groups') + expect(browser?.body).not.toContain('---') + }) +}) + +describe('searchDocsPages', () => { + const pages = parseDocsFullText(SAMPLE_FULL) + + it('ranks the page with more occurrences of the term first', () => { + const results = searchDocsPages(pages, 'chromium') + expect(results.map((r) => r.url)).toEqual([ + 'https://www.windmill.dev/docs/advanced/browser_automation', + 'https://www.windmill.dev/docs/core_concepts/worker_groups' + ]) + expect(results[0].snippets.length).toBeGreaterThan(0) + expect(results[0].snippets.join('\n')).toContain('chromium') + }) + + it('prefers pages that cover every query term over partial matches', () => { + // Only browser_automation mentions both "chromium" and "sandbox". + const results = searchDocsPages(pages, 'chromium sandbox') + expect(results.map((r) => r.url)).toEqual([ + 'https://www.windmill.dev/docs/advanced/browser_automation' + ]) + }) + + it('returns nothing when no term matches', () => { + expect(searchDocsPages(pages, 'kubernetes helm chart')).toEqual([]) + }) + + it('respects the maxPages cap', () => { + const results = searchDocsPages(pages, 'worker', { maxPages: 1 }) + expect(results.length).toBe(1) + }) +}) + +describe('makeSnippet', () => { + it('returns short lines unchanged after collapsing whitespace', () => { + expect(makeSnippet(' hello world ', ['world'], 200)).toBe('hello world') + }) + + it('windows a long line around the first matched term with ellipses', () => { + const line = `${'a '.repeat(200)}NEEDLE${' b'.repeat(200)}` + const snippet = makeSnippet(line, ['needle'], 60) + expect(snippet.length).toBeLessThanOrEqual(62) // 60 + two ellipsis chars + expect(snippet.toLowerCase()).toContain('needle') + expect(snippet.startsWith('…')).toBe(true) + expect(snippet.endsWith('…')).toBe(true) + }) +}) + +describe('formatDocsSearchResults', () => { + it('renders Source URLs, snippet bullets and a citation instruction', () => { + const results = searchDocsPages(parseDocsFullText(SAMPLE_FULL), 'chromium') + const rendered = formatDocsSearchResults('chromium', results) + expect(rendered).toContain('Source: https://www.windmill.dev/docs/advanced/browser_automation') + expect(rendered).toContain(' - ') + expect(rendered).toContain('Cite the exact "Source" URL') + }) + + it('returns a no-match message when there are no results', () => { + expect(formatDocsSearchResults('zzz', [])).toContain('No documentation pages matched "zzz"') + }) +}) + +const SAMPLE_INDEX = `# Windmill + +> Blurb. + +## Documentation structure + +### Core concepts +- [AI agents](https://www.windmill.dev/docs/core_concepts/ai_agents.md): How do I build AI agents in Windmill? Add agent steps to flows. Connect to OpenAI, Anthropic and more. +- [Retries](https://www.windmill.dev/docs/flows/retries.md): How do I retry a failing flow step automatically with exponential backoff? +- [Persistent storage](https://www.windmill.dev/docs/core_concepts/persistent_storage/within_windmill.md): How do I persist state between runs in Windmill? +` + +describe('parseDocsIndex', () => { + it('parses index entries into title, url and description', () => { + const entries = parseDocsIndex(SAMPLE_INDEX) + expect(entries).toHaveLength(3) + expect(entries[0]).toEqual({ + title: 'AI agents', + url: 'https://www.windmill.dev/docs/core_concepts/ai_agents.md', + description: + 'How do I build AI agents in Windmill? Add agent steps to flows. Connect to OpenAI, Anthropic and more.' + }) + }) + + it('ignores lines that are not docs links', () => { + expect(parseDocsIndex('## Heading\n> blurb\nplain text')).toEqual([]) + }) +}) + +describe('searchDocsIndex', () => { + const entries = parseDocsIndex(SAMPLE_INDEX) + + it('surfaces a named feature from its title/description when body grep would miss it', () => { + // The branch-centric phrasing a model used that failed body search; the + // index entry still matches on "agent"/"LLM"-adjacent terms. + const results = searchDocsIndex(entries, 'AI agent step decide') + expect(results[0].url).toBe('https://www.windmill.dev/docs/core_concepts/ai_agents.md') + expect(results[0].snippets[0]).toContain('agent steps') + }) + + it('ranks title matches above description-only matches', () => { + const results = searchDocsIndex(entries, 'retries') + expect(results[0].url).toBe('https://www.windmill.dev/docs/flows/retries.md') + }) + + it('returns nothing when no term matches', () => { + expect(searchDocsIndex(entries, 'kubernetes helm')).toEqual([]) + }) +}) + +describe('mergeDocsSearchResults', () => { + const body: ReturnType = [ + { url: 'https://www.windmill.dev/docs/openflow', title: 'OpenFlow', score: 10, snippets: ['x'] } + ] + const index: ReturnType = [ + // Same page as a body hit but as the index `.md` URL — must dedupe. + { + url: 'https://www.windmill.dev/docs/openflow.md', + title: 'OpenFlow', + score: 5, + snippets: ['desc'] + }, + { url: 'https://www.windmill.dev/docs/flows/retries.md', title: 'Retries', score: 4, snippets: ['desc'] } + ] + + it('keeps body results first and appends index-only matches, deduping by canonical URL', () => { + const merged = mergeDocsSearchResults(body, index) + expect(merged.map((r) => r.url)).toEqual([ + 'https://www.windmill.dev/docs/openflow', + 'https://www.windmill.dev/docs/flows/retries.md' + ]) + }) + + it('respects the maxPages cap', () => { + expect(mergeDocsSearchResults(body, index, 1)).toHaveLength(1) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/docs/core.ts b/frontend/src/lib/components/copilot/chat/docs/core.ts new file mode 100644 index 0000000000..0329d2025d --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/docs/core.ts @@ -0,0 +1,863 @@ +import type { Tool } from '../shared' +import type { ChatCompletionTool } from 'openai/resources/index.mjs' + +const DOCS_ORIGIN = 'https://www.windmill.dev' +const LLMS_TXT_URL = `${DOCS_ORIGIN}/llms.txt` +const LLMS_FULL_TXT_URL = `${DOCS_ORIGIN}/llms-full.txt` +const CACHE_TTL_MS = 15 * 60 * 1000 +// Above this size, return an outline of the page's headings instead of the full +// content, prompting the model to request a specific section. +const FULL_PAGE_CHAR_LIMIT = 20_000 + +// search_docs result caps — keep the returned payload small (the whole point of +// search vs. dumping the index or full pages is token economy). +const SEARCH_MAX_PAGES = 8 +const SEARCH_MAX_SNIPPETS_PER_PAGE = 3 +const SEARCH_MAX_SNIPPET_CHARS = 200 + +interface CacheEntry { + expiresAt: number + promise: Promise +} + +let llmsTxtCache: CacheEntry | undefined +let llmsFullTxtCache: CacheEntry | undefined +const pageCache = new Map() + +/** + * Fetches the docs index (llms.txt) listing every documentation page. Cached at + * module level with a TTL so repeated tool calls within a session reuse it. + */ +export async function fetchDocsIndex(): Promise { + const now = Date.now() + if (llmsTxtCache && llmsTxtCache.expiresAt > now) { + return llmsTxtCache.promise + } + + const promise = fetchText(LLMS_TXT_URL).catch((error) => { + // Drop the failed promise from the cache so the next call retries. + if (llmsTxtCache?.promise === promise) { + llmsTxtCache = undefined + } + throw error + }) + llmsTxtCache = { expiresAt: now + CACHE_TTL_MS, promise } + return promise +} + +/** + * Fetches the full documentation corpus (llms-full.txt): every page concatenated + * into one document, each delimited by a `Source: ` line. ~2 MB. Cached at + * module level with a TTL. Mirrors fetchDocsIndex; used by search_docs to grep + * the whole corpus in a single fetch. + */ +export async function fetchDocsFullText(): Promise { + const now = Date.now() + if (llmsFullTxtCache && llmsFullTxtCache.expiresAt > now) { + return llmsFullTxtCache.promise + } + + const promise = fetchText(LLMS_FULL_TXT_URL).catch((error) => { + if (llmsFullTxtCache?.promise === promise) { + llmsFullTxtCache = undefined + } + throw error + }) + llmsFullTxtCache = { expiresAt: now + CACHE_TTL_MS, promise } + return promise +} + +/** + * Fetches a single docs page as raw markdown. `path` may be a full URL or a + * /docs/... path; it is normalized to a `.md` URL. Cached per resolved URL. + */ +export async function fetchDocsPage(path: string): Promise { + const url = normalizeDocsUrl(path) + const now = Date.now() + const cached = pageCache.get(url) + if (cached && cached.expiresAt > now) { + return cached.promise + } + + const promise = fetchText(url) + .then((content) => sanitizeDocsMarkdownLinks(content, url)) + .catch((error) => { + if (pageCache.get(url)?.promise === promise) { + pageCache.delete(url) + } + throw error + }) + pageCache.set(url, { expiresAt: now + CACHE_TTL_MS, promise }) + return promise +} + +async function fetchText(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Request to ${url} failed with status ${response.status}`) + } + return await response.text() +} + +/** + * Normalizes a user/model-supplied docs reference to a fully-qualified `.md` + * URL on the docs origin. Accepts: + * - `https://www.windmill.dev/docs/core_concepts/jobs` + * - `/docs/core_concepts/jobs.md` + * - `docs/core_concepts/jobs` + */ +export function normalizeDocsUrl(input: string): string { + let value = input.trim() + + if (/^https?:\/\//i.test(value)) { + // Strip the origin so we can re-anchor to DOCS_ORIGIN and normalize the path. + try { + const parsed = new URL(value) + value = parsed.pathname + } catch { + // Fall through and treat as a path. + } + } + + // Drop any query string or hash fragment. + value = value.split('#')[0].split('?')[0] + + if (!value.startsWith('/')) { + value = `/${value}` + } + + // Strip a trailing slash (but keep the leading one). + if (value.length > 1 && value.endsWith('/')) { + value = value.slice(0, -1) + } + + // Relative links inside the raw markdown reference docusaurus source files + // (e.g. `13_flow_branches.mdx`), but the published routes drop the numeric + // ordering prefixes and use `.md`. + value = stripDocsPathPrefixes(value) + if (value.endsWith('.mdx')) { + value = value.slice(0, -1) + } + + if (!value.endsWith('.md')) { + value = `${value}.md` + } + + return `${DOCS_ORIGIN}${value}` +} + +/** + * The canonical published URL a model should cite for a docs page (the `.md` + * fetch URL without the suffix), e.g. `https://www.windmill.dev/docs/flows/retries`. + */ +export function canonicalDocsPageUrl(path: string): string { + return normalizeDocsUrl(path).replace(/\.md$/i, '') +} + +/** + * Strips docusaurus numeric ordering prefixes (`13_`, `8-`) from each segment of + * a docs path so it matches the published route. Operates on the path only. + */ +function stripDocsPathPrefixes(path: string): string { + return path + .split('/') + .map((segment) => segment.replace(/^\d+[_-]/, '')) + .join('/') +} + +/** + * Rewrites relative/source-file doc links inside raw page markdown to canonical + * published URLs, so the model never echoes a docusaurus source path (e.g. + * `./13_flow_branches.mdx`) into its answer as a broken link. Resolves each link + * relative to the page it came from, strips numeric ordering prefixes, and drops + * the `.md`/`.mdx` extension. Non-doc links (external, images, anchors) are left + * untouched. + */ +export function sanitizeDocsMarkdownLinks(content: string, pageUrl: string): string { + return content.replace(/\]\(([^)\s]+?)(\s+"[^"]*")?\)/g, (match, target: string, title) => { + if (!/\.mdx?($|[#?])/i.test(target)) { + // Only rewrite links to docusaurus source files (.md/.mdx); leave + // images, external URLs and bare anchors untouched. + return match + } + if (/(^|\/)\.\.\//.test(target)) { + // `../` cross-directory links are authored against the docusaurus + // source tree, whose depth differs from the published URL, so strict + // resolution is unreliable. Leave them for the canonical-URL header to + // disambiguate rather than risk rewriting to a wrong path. + return match + } + let resolved: URL + try { + resolved = new URL(target, pageUrl) + } catch { + return match + } + if (resolved.origin !== DOCS_ORIGIN || !resolved.pathname.startsWith('/docs/')) { + return match + } + const pathname = stripDocsPathPrefixes(resolved.pathname).replace(/\.mdx?$/i, '') + return `](${DOCS_ORIGIN}${pathname}${resolved.hash}${title ?? ''})` + }) +} + +export interface DocsHeading { + level: number + title: string + /** Character offset of the start of the heading line within the document. */ + startIndex: number +} + +/** + * Parses the markdown headings (`#`–`####`) of a docs page, ignoring any + * heading-like lines that appear inside fenced code blocks (``` fences), which + * are common in docs pages (e.g. `# comment` inside a python sample). + */ +export function parseDocsHeadings(content: string): DocsHeading[] { + const headings: DocsHeading[] = [] + let offset = 0 + let inFence = false + let fenceMarker = '' + + const lines = content.split('\n') + for (const line of lines) { + const fence = matchFence(line) + if (fence) { + if (!inFence) { + inFence = true + fenceMarker = fence + } else if (line.trimStart().startsWith(fenceMarker)) { + inFence = false + fenceMarker = '' + } + offset += line.length + 1 + continue + } + + if (!inFence) { + const match = /^(#{1,4})\s+(.*\S)\s*$/.exec(line) + if (match) { + headings.push({ + level: match[1].length, + title: match[2].trim(), + startIndex: offset + }) + } + } + + offset += line.length + 1 + } + + return headings +} + +function matchFence(line: string): string | undefined { + const trimmed = line.trimStart() + const match = /^(`{3,}|~{3,})/.exec(trimmed) + return match ? match[1] : undefined +} + +/** + * Builds a human-readable outline of a page's headings, including an approximate + * character size for each section. Used when a page is too large to return whole. + */ +export function buildDocsOutline(content: string): string { + const headings = parseDocsHeadings(content) + if (headings.length === 0) { + return '(no markdown headings found on this page)' + } + + const lines = headings.map((heading, index) => { + const sectionEnd = sectionEndIndex(content, headings, index) + const approxChars = sectionEnd - heading.startIndex + const indent = ' '.repeat(Math.max(0, heading.level - 1)) + return `${indent}- ${heading.title} (~${approxChars} chars)` + }) + + return lines.join('\n') +} + +function sectionEndIndex(content: string, headings: DocsHeading[], index: number): number { + const heading = headings[index] + // A section ends at the next heading of the same or higher (shallower) level. + for (let i = index + 1; i < headings.length; i++) { + if (headings[i].level <= heading.level) { + return headings[i].startIndex + } + } + return content.length +} + +/** Normalizes a heading title for tolerant, case/punctuation-insensitive matching. */ +function normalizeHeadingTitle(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +/** + * Extracts the content of the section whose heading matches `section`, from the + * matching heading up to the next heading of the same or higher level. Matching + * is case-insensitive and tolerant of minor punctuation differences. Returns + * `undefined` when no heading matches. + */ +export function extractDocsSection(content: string, section: string): string | undefined { + const headings = parseDocsHeadings(content) + const target = normalizeHeadingTitle(section) + if (target.length === 0) { + return undefined + } + + let matchIndex = headings.findIndex( + (heading) => normalizeHeadingTitle(heading.title) === target + ) + if (matchIndex === -1) { + // Fall back to a contains match so "Result streaming" matches "Result". + matchIndex = headings.findIndex((heading) => + normalizeHeadingTitle(heading.title).includes(target) + ) + } + if (matchIndex === -1) { + return undefined + } + + const start = headings[matchIndex].startIndex + const end = sectionEndIndex(content, headings, matchIndex) + return content.slice(start, end).trim() +} + +const READ_DOCS_PAGE_TOOL: ChatCompletionTool = { + type: 'function', + function: { + name: 'read_docs_page', + description: + 'Fetch the raw markdown of a single Windmill documentation page. Provide the `path` (or full URL) of a page found via search_docs. If the page is large, this returns its list of section headings instead of the full content; call again with the `section` argument set to one of those headings to read that section.', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'The docs page to read, as a path (e.g. /docs/core_concepts/jobs) or full URL (e.g. https://www.windmill.dev/docs/core_concepts/jobs).' + }, + section: { + type: 'string', + description: + 'Optional. A heading title from the page outline to read just that section instead of the full page.' + } + }, + required: ['path'] + } + } +} + +export const readDocsPageTool: Tool<{}> = { + def: READ_DOCS_PAGE_TOOL, + fn: async ({ args, toolId, toolCallbacks }) => { + const path = typeof args?.path === 'string' ? args.path : '' + const section = typeof args?.section === 'string' && args.section.trim() ? args.section : undefined + toolCallbacks.setToolStatus(toolId, { + content: section ? `Reading docs section "${section}"...` : 'Reading documentation page...' + }) + try { + if (!path.trim()) { + return 'No documentation page path was provided. Provide a `path` — e.g. a `Source` URL returned by search_docs.' + } + const content = await fetchDocsPage(path) + toolCallbacks.setToolStatus(toolId, { content: 'Read documentation page' }) + const canonicalUrl = canonicalDocsPageUrl(path) + const header = `Source page — cite this URL when referencing this page: ${canonicalUrl}\n\n` + return header + renderDocsPageResult(content, section) + } catch (error) { + toolCallbacks.setToolStatus(toolId, { + content: 'Error reading documentation page', + error: 'Error reading documentation page' + }) + console.error('Error reading documentation page:', error) + const errorMessage = + error instanceof Error ? error.message : 'An error occurred while reading the documentation page' + return `Failed to read documentation page: ${errorMessage}, pursuing with the user request...` + } + } +} + +/** + * Decides what to return for read_docs_page: a requested section, the full page, + * or an outline asking the model to pick a section. + */ +export function renderDocsPageResult(content: string, section?: string): string { + if (section) { + const extracted = extractDocsSection(content, section) + if (extracted !== undefined) { + return extracted + } + return [ + `No section matching "${section}" was found on this page. Available sections:`, + '', + buildDocsOutline(content) + ].join('\n') + } + + // Gate on the page body only; the caller may prepend a short "Source page" + // header, so the returned payload can exceed this limit by that header's + // length. This threshold only decides whole-page vs. outline, so the small + // overshoot is immaterial. + if (content.length <= FULL_PAGE_CHAR_LIMIT) { + return content + } + + return [ + 'This documentation page is large. Below is its list of sections with approximate sizes.', + 'Call read_docs_page again with the same path and a `section` set to one of these headings to read that section.', + '', + buildDocsOutline(content) + ].join('\n') +} + +// --------------------------------------------------------------------------- +// Full-text docs search (search_docs) +// +// Discovery primitive for the `search` ask variant: instead of dumping the whole +// llms.txt index, grep the full corpus (llms-full.txt) for the user's keywords +// and return only small matching snippets plus each page's `Source:` URL. The +// model then cites that URL directly or passes it to read_docs_page for more. +// --------------------------------------------------------------------------- + +const SOURCE_LINE_RE = /^Source:\s*(\S+)\s*$/ +// In llms-full.txt every page's `Source:` line is preceded by a category-header +// lead-in: `...page body...\n\n---\n\n## \n\nSource: `. Splitting +// on `Source:` lines leaves that lead-in on the *previous* page, so strip a +// trailing `---` + level-2-heading block to avoid mis-attributing the next +// page's category title to the previous page. +const TRAILING_LEAD_IN_RE = /\n+-{3,}[ \t]*\n+#{2}[ \t]+.*[ \t]*\n*$/ + +export interface DocsFullPage { + url: string + title: string + body: string +} + +export interface DocsSearchResult { + url: string + title: string + /** Higher = more relevant. Distinct query terms matched dominate raw occurrences. */ + score: number + snippets: string[] +} + +/** + * Splits the llms-full.txt corpus into per-page records keyed by the `Source:` + * URL. Content before the first `Source:` line (the corpus preamble) is dropped. + */ +export function parseDocsFullText(fullText: string): DocsFullPage[] { + const pages: DocsFullPage[] = [] + let url: string | undefined + let buffer: string[] = [] + + const flush = () => { + if (url === undefined) { + return + } + const body = buffer.join('\n').replace(TRAILING_LEAD_IN_RE, '').trim() + if (body.length > 0) { + pages.push({ url, title: firstHeading(body) ?? url, body }) + } + } + + for (const line of fullText.split('\n')) { + const match = SOURCE_LINE_RE.exec(line) + if (match) { + flush() + url = match[1] + buffer = [] + continue + } + if (url !== undefined) { + buffer.push(line) + } + } + flush() + return pages +} + +function firstHeading(body: string): string | undefined { + for (const line of body.split('\n')) { + const match = /^#{1,6}\s+(.*\S)\s*$/.exec(line) + if (match) { + return match[1].trim() + } + } + return undefined +} + +/** + * Ranks docs pages for a keyword query. The query is split into distinct terms; + * a page's score is `distinctTermsMatched` (dominant) then total occurrences. + * Pages covering every term are preferred over partial matches. Each result + * carries up to `maxSnippetsPerPage` of its most term-dense lines. + */ +export function searchDocsPages( + pages: DocsFullPage[], + query: string, + opts: { maxPages?: number; maxSnippetsPerPage?: number; maxSnippetChars?: number } = {} +): DocsSearchResult[] { + const maxPages = opts.maxPages ?? SEARCH_MAX_PAGES + const maxSnippetsPerPage = opts.maxSnippetsPerPage ?? SEARCH_MAX_SNIPPETS_PER_PAGE + const maxSnippetChars = opts.maxSnippetChars ?? SEARCH_MAX_SNIPPET_CHARS + + const terms = tokenizeQuery(query) + if (terms.length === 0) { + return [] + } + + interface Scored extends DocsSearchResult { + distinctTerms: number + order: number + } + const scored: Scored[] = [] + + pages.forEach((page, order) => { + const lowerBody = page.body.toLowerCase() + let distinctTerms = 0 + let occurrences = 0 + for (const term of terms) { + const count = countOccurrences(lowerBody, term) + if (count > 0) { + distinctTerms += 1 + occurrences += count + } + } + if (distinctTerms === 0) { + return + } + scored.push({ + url: page.url, + title: page.title, + // distinctTerms dominates so a page matching all terms always outranks + // one matching fewer, regardless of raw occurrence counts. + score: distinctTerms * 1_000_000 + occurrences, + distinctTerms, + order, + snippets: selectSnippets(page.body, terms, maxSnippetsPerPage, maxSnippetChars) + }) + }) + + // Prefer pages that cover every query term; fall back to partial matches only + // when nothing covers all of them. + const fullCoverage = scored.filter((entry) => entry.distinctTerms === terms.length) + const pool = fullCoverage.length > 0 ? fullCoverage : scored + + pool.sort((a, b) => b.score - a.score || a.order - b.order) + + return pool + .slice(0, maxPages) + .map(({ url, title, score, snippets }) => ({ url, title, score, snippets })) +} + +/** Splits a query into distinct, lowercased, non-empty terms. */ +function tokenizeQuery(query: string): string[] { + return Array.from( + new Set( + query + .toLowerCase() + .split(/\s+/) + .map((term) => term.trim()) + .filter((term) => term.length > 0) + ) + ) +} + +function countOccurrences(haystack: string, needle: string): number { + if (needle.length === 0) { + return 0 + } + let count = 0 + let index = haystack.indexOf(needle) + while (index !== -1) { + count += 1 + index = haystack.indexOf(needle, index + needle.length) + } + return count +} + +/** + * Picks the most term-dense lines of a page body as snippets, in document order, + * deduped, each trimmed to `maxChars` around the first matched term. + */ +function selectSnippets( + body: string, + terms: string[], + maxSnippets: number, + maxChars: number +): string[] { + interface LineHit { + text: string + distinct: number + order: number + } + const hits: LineHit[] = [] + + body.split('\n').forEach((line, order) => { + const lower = line.toLowerCase() + let distinct = 0 + for (const term of terms) { + if (lower.includes(term)) { + distinct += 1 + } + } + if (distinct === 0) { + return + } + const text = makeSnippet(line, terms, maxChars) + if (text.length > 0) { + hits.push({ text, distinct, order }) + } + }) + + hits.sort((a, b) => b.distinct - a.distinct || a.order - b.order) + + const seen = new Set() + const result: string[] = [] + for (const hit of hits) { + if (seen.has(hit.text)) { + continue + } + seen.add(hit.text) + result.push(hit.text) + if (result.length >= maxSnippets) { + break + } + } + return result +} + +/** + * Collapses a matched line to a single-line snippet of at most `maxChars`, + * windowed around the first matched term (with ellipses) when the line is long. + */ +export function makeSnippet(line: string, terms: string[], maxChars: number): string { + const collapsed = line.replace(/\s+/g, ' ').trim() + if (collapsed.length <= maxChars) { + return collapsed + } + + const lower = collapsed.toLowerCase() + let firstIndex = -1 + for (const term of terms) { + const index = lower.indexOf(term) + if (index !== -1 && (firstIndex === -1 || index < firstIndex)) { + firstIndex = index + } + } + if (firstIndex === -1) { + return `${collapsed.slice(0, maxChars).trimEnd()}…` + } + + const start = Math.max(0, firstIndex - Math.floor(maxChars / 3)) + const end = Math.min(collapsed.length, start + maxChars) + const prefix = start > 0 ? '…' : '' + const suffix = end < collapsed.length ? '…' : '' + return `${prefix}${collapsed.slice(start, end).trim()}${suffix}` +} + +export interface DocsIndexEntry { + title: string + url: string + description: string +} + +// A line in llms.txt: `- [Title](https://.../page.md): question-phrased description`. +const INDEX_ENTRY_RE = /^\s*-\s*\[([^\]]+)\]\(([^)\s]+)\)\s*:?\s*(.*)$/ + +/** Parses the llms.txt index into per-page entries (title, URL, description). */ +export function parseDocsIndex(indexText: string): DocsIndexEntry[] { + const entries: DocsIndexEntry[] = [] + for (const line of indexText.split('\n')) { + const match = INDEX_ENTRY_RE.exec(line) + if (!match) { + continue + } + const [, title, url, description] = match + if (!url.includes('/docs/')) { + continue + } + entries.push({ title: title.trim(), url: url.trim(), description: description.trim() }) + } + return entries +} + +/** + * Ranks index entries for a query by matching its terms against each entry's + * title and description. Title matches weigh more than description matches. + * The description becomes the result's single snippet. This recovers the + * "named feature" discovery that full-text grep misses when the model searches + * the wrong keywords (e.g. finding "AI agents" for "LLM decides which script"). + */ +export function searchDocsIndex( + entries: DocsIndexEntry[], + query: string, + opts: { maxPages?: number } = {} +): DocsSearchResult[] { + const maxPages = opts.maxPages ?? SEARCH_MAX_PAGES + const terms = tokenizeQuery(query) + if (terms.length === 0) { + return [] + } + + interface Scored extends DocsSearchResult { + distinctTerms: number + order: number + } + const scored: Scored[] = [] + + entries.forEach((entry, order) => { + const title = entry.title.toLowerCase() + const description = entry.description.toLowerCase() + let distinctTerms = 0 + let score = 0 + for (const term of terms) { + const inTitle = title.includes(term) + const inDescription = description.includes(term) + if (inTitle || inDescription) { + distinctTerms += 1 + score += (inTitle ? 5 : 0) + (inDescription ? 1 : 0) + } + } + if (distinctTerms === 0) { + return + } + scored.push({ + url: entry.url, + title: entry.title, + score: distinctTerms * 1_000_000 + score, + distinctTerms, + order, + snippets: entry.description ? [entry.description] : [] + }) + }) + + const fullCoverage = scored.filter((entry) => entry.distinctTerms === terms.length) + const pool = fullCoverage.length > 0 ? fullCoverage : scored + pool.sort((a, b) => b.score - a.score || a.order - b.order) + + return pool + .slice(0, maxPages) + .map(({ url, title, score, snippets }) => ({ url, title, score, snippets })) +} + +/** Strips the `.md` suffix and trailing slash so index/body URLs dedupe. */ +function canonicalSearchUrl(url: string): string { + return url.replace(/\.md$/i, '').replace(/\/$/, '') +} + +/** + * Merges full-text (body) results with index-description results. Body matches + * come first (concrete content hits), then index-only matches fill remaining + * slots — so a named feature surfaced only by its index entry still appears even + * when body grep landed on the wrong pages. + */ +export function mergeDocsSearchResults( + bodyResults: DocsSearchResult[], + indexResults: DocsSearchResult[], + maxPages = SEARCH_MAX_PAGES +): DocsSearchResult[] { + const seen = new Set(bodyResults.map((result) => canonicalSearchUrl(result.url))) + const merged = [...bodyResults] + for (const entry of indexResults) { + const key = canonicalSearchUrl(entry.url) + if (seen.has(key)) { + continue + } + seen.add(key) + merged.push(entry) + } + return merged.slice(0, maxPages) +} + +/** Renders search results as the string returned to the model. */ +export function formatDocsSearchResults(query: string, results: DocsSearchResult[]): string { + if (results.length === 0) { + return `No documentation pages matched "${query}". Try fewer or more general keywords (a single distinctive term often works best).` + } + + const blocks = results.map((result) => { + const lines = [`## ${result.title}`, `Source: ${result.url}`] + for (const snippet of result.snippets) { + lines.push(` - ${snippet}`) + } + return lines.join('\n') + }) + + return [ + `Found ${results.length} documentation page(s) matching "${query}", most relevant first:`, + '', + blocks.join('\n\n'), + '', + 'Cite the exact "Source" URL when referencing a page. If these snippets are not enough, call read_docs_page with a Source URL to read the full page or a section.' + ].join('\n') +} + +const SEARCH_DOCS_TOOL: ChatCompletionTool = { + type: 'function', + function: { + name: 'search_docs', + description: + 'Full-text search across the entire Windmill documentation. Provide one or more keywords; returns the most relevant docs pages, each with its Source URL and short matching snippets. Use this FIRST to find relevant pages by their content (a flag, function, error message, config key or concept). If the snippets answer the question, answer directly; otherwise call read_docs_page with a returned Source URL to read more.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Keywords to search for in the documentation body, e.g. "chromium worker tag" or "retry exponential backoff". Fewer, more distinctive words match better.' + } + }, + required: ['query'] + } + } +} + +export const searchDocsTool: Tool<{}> = { + def: SEARCH_DOCS_TOOL, + fn: async ({ args, toolId, toolCallbacks }) => { + const query = typeof args?.query === 'string' ? args.query.trim() : '' + toolCallbacks.setToolStatus(toolId, { + content: query ? `Searching documentation for "${query}"...` : 'Searching documentation...' + }) + try { + if (!query) { + return 'No search query was provided. Provide a `query` of one or more keywords.' + } + const bodyResults = searchDocsPages(parseDocsFullText(await fetchDocsFullText()), query, { + maxPages: 5 + }) + // Also match the (small) index titles/descriptions to surface named + // features that body grep misses. Best-effort: a failed index fetch + // still leaves full-text results. + let indexResults: DocsSearchResult[] = [] + try { + indexResults = searchDocsIndex(parseDocsIndex(await fetchDocsIndex()), query, { + maxPages: 4 + }) + } catch (indexError) { + console.error('Error searching documentation index:', indexError) + } + const results = mergeDocsSearchResults(bodyResults, indexResults) + toolCallbacks.setToolStatus(toolId, { + content: + results.length > 0 ? `Found ${results.length} matching page(s)` : 'No matching pages found' + }) + return formatDocsSearchResults(query, results) + } catch (error) { + toolCallbacks.setToolStatus(toolId, { + content: 'Error searching documentation', + error: 'Error searching documentation' + }) + console.error('Error searching documentation:', error) + const errorMessage = + error instanceof Error ? error.message : 'An error occurred while searching the documentation' + return `Failed to search documentation: ${errorMessage}, pursuing with the user request...` + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 724af63e42..284e7aafda 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -72,6 +72,7 @@ import { type ToolCallbacks, type ToolDisplayAction } from '../shared' +import { searchDocsTool, readDocsPageTool } from '../docs/core' import type { ContextElement } from '../context' import { getDatatableTools } from '../datatableTools' import { UserDraft } from '$lib/userDraft.svelte' @@ -677,6 +678,13 @@ Rules: : '' } +Documentation: +- Use search_docs to look up how a Windmill feature works in the official documentation (a flag, concept, function, or "does Windmill support X") instead of guessing about product behavior. It returns matching doc snippets with their Source URL; call read_docs_page with a Source URL to read the full page (or a section, if it returns headings). Cite the Source URL when you rely on it. +- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible. +- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team. +- If the first search returns nothing useful, retry with different or broader keywords before giving up. +- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team. + Flows: - read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.". - Use read_flow_module_code and set_flow_module_code for inline script bodies. @@ -1494,6 +1502,8 @@ export const globalTools: Tool<{}>[] = [ } }, createSearchHubScriptsTool(false), + searchDocsTool, + readDocsPageTool, { def: createToolDef( askUserQuestionSchema, diff --git a/frontend/src/lib/components/copilot/chat/navigator/core.ts b/frontend/src/lib/components/copilot/chat/navigator/core.ts index 6da158e6de..945f994862 100644 --- a/frontend/src/lib/components/copilot/chat/navigator/core.ts +++ b/frontend/src/lib/components/copilot/chat/navigator/core.ts @@ -4,6 +4,7 @@ import type { ChatCompletionUserMessageParam } from 'openai/resources/index.mjs' import { createSearchWorkspaceTool, createGetRunnableDetailsTool, type Tool } from '../shared' +import { readDocsPageTool, searchDocsTool } from '../docs/core' import { ResourceService } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { get } from 'svelte/store' @@ -16,13 +17,14 @@ Windmill is an open-source developer platform for building internal tools, API i You have access to these tools: 1. View current buttons and inputs on the page (get_triggerable_components) 2. Execute buttons and inputs (trigger_component) -3. Get documentation for user requests (get_documentation) -4. Change the AI mode to the one specified (change_mode) -5. Search for scripts and flows in the workspace (search_workspace) -6. Get detailed information about a specific script or flow (get_runnable_details) +3. Search the documentation (search_docs) +4. Read a documentation page (read_docs_page) +5. Change the AI mode to the one specified (change_mode) +6. Search for scripts and flows in the workspace (search_workspace) +7. Get detailed information about a specific script or flow (get_runnable_details) INSTRUCTIONS: -- When users ask about application features or concepts, first use get_documentation internally to retrieve accurate information about how to fulfill the user's request. +- When users ask about application features or concepts, first use search_docs (with a few keywords) and, when a snippet is not enough, read_docs_page on a returned Source URL to retrieve accurate information about how to fulfill the user's request. - Then immediately use the available tools to guide the user through the application. Do not wait for the user's confirmation before taking action. - If you detect a confirmation modal that needs user confirmation, stop the navigation and let the user know that the action is pending confirmation. - Use get_triggerable_components to understand available options, and then trigger the components using trigger_component. Then wait a moment before rescanning the current page, and then continue with the next step. Do this 5 times max. @@ -59,30 +61,12 @@ When you complete the user's request, do not say "I created..." or "I updated... Example of good behavior: - User: "How can I set my AI providers?" -- You: +- You: - You: - You: - You: "" ` -const GET_DOCUMENTATION_TOOL: ChatCompletionTool = { - type: 'function', - function: { - name: 'get_documentation', - description: 'Get the documentation for the user request', - parameters: { - type: 'object', - properties: { - request: { - type: 'string', - description: 'The user request' - } - }, - required: ['request'] - } - } -} - // Tool definitions const GET_TRIGGERABLE_COMPONENTS_TOOL: ChatCompletionTool = { type: 'function', @@ -234,47 +218,6 @@ function triggerComponent(args: { id: string; value: string }): string { } } -async function getDocumentation(args: { request: string }): Promise { - const retrieval = await fetch('/api/inkeep', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: args.request - }) - }) - - if (!retrieval.ok) { - const errorText = await retrieval.text() - throw new Error(errorText) - } - - const data = await retrieval.json() - if (!data.choices?.[0]?.message?.content) { - return 'No documentation found for this request' - } - - // Parse the raw response - const raw = data.choices[0].message.content - const parsed = JSON.parse(raw) - - // Clean up the response to include only essential information - if (parsed.content && Array.isArray(parsed.content)) { - const cleanedContent = parsed.content.map((item: any) => ({ - title: item.title, - url: item.url, - content: item.source?.content.map((c: any) => c.text).join('\n') || [] - })) - // Limit the response to 30000 characters max - const stringified = JSON.stringify({ content: cleanedContent }).slice(0, 30000) - - return stringified - } - - return data.choices[0].message.content -} - async function getAvailableResources(args: { resource_type: string }): Promise { const resources = await ResourceService.listResource({ workspace: get(workspaceStore) as string, @@ -318,27 +261,6 @@ const getCurrentPageNameTool: Tool<{}> = { } } -export const getDocumentationTool: Tool<{}> = { - def: GET_DOCUMENTATION_TOOL, - fn: async ({ args, toolId, toolCallbacks }) => { - toolCallbacks.setToolStatus(toolId, { content: 'Getting documentation...' }) - try { - const docResult = await getDocumentation(args) - toolCallbacks.setToolStatus(toolId, { content: 'Retrieved documentation' }) - return docResult - } catch (error) { - toolCallbacks.setToolStatus(toolId, { - content: 'Error getting documentation', - error: 'Error getting documentation' - }) - console.error('Error getting documentation:', error) - const errorMessage = - error instanceof Error ? error.message : 'An error occurred while getting documentation' - return `Failed to get documentation: ${errorMessage}, pursuing with the user request...` - } - } -} - const getAvailableResourcesTool: Tool<{}> = { def: GET_AVAILABLE_RESOURCES_TOOL, fn: async ({ args, toolId, toolCallbacks }) => { @@ -361,7 +283,8 @@ const getAvailableResourcesTool: Tool<{}> = { export const navigatorTools: Tool<{}>[] = [ getTriggerableComponentsTool, triggerComponentTool, - getDocumentationTool, + searchDocsTool, + readDocsPageTool, getCurrentPageNameTool, getAvailableResourcesTool, createSearchWorkspaceTool(), From b67c8cf42b477575fc1bc448058ec0d3b7e54fee Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:32:26 +0200 Subject: [PATCH 03/15] fix(frontend): render Modal2 dialogs above the AI chat panel (#9636) Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/common/modal/Modal2.svelte | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index fcce008ad7..c00bf758d0 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -8,6 +8,8 @@ import { X } from 'lucide-svelte' import List from '$lib/components/common/layout/List.svelte' import { fade } from 'svelte/transition' + import { zIndexes } from '$lib/zIndexes' + import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte' interface Props { title: string @@ -85,6 +87,11 @@ function fadeFast(node: HTMLElement) { return fade(node, { duration: 200 }) } + + // Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so + // the dialog isn't hidden behind it; otherwise keep the default modal + // stacking just above disposables (zIndexes.disposables). + const overlayZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10) @@ -92,7 +99,8 @@ {#if isOpen}
From e09cd5862cb636e143027fe8d9a5be9c7097b031 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 17 Jun 2026 15:49:36 +0200 Subject: [PATCH 04/15] feat: per-user draft review & deploy page (gating, badges, rename, raw-app deploy fixes) (#9625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: per-user draft gating, badges and rename display on deploy page Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): don't strike the path when a draft adds a summary to a summary-less item Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): don't strike draft-only items' auto-generated path against the pretty path Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): deploy raw-app drafts from top-level files so the bundle isn't dropped Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(frontend): share raw-app source→draft-value projection across chat and deploy page Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): deploy renamed/new flow, app and raw-app drafts at draft_path, not the temp storage path Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(frontend): add a design-system Checkbox and use it for deploy-page row/select-all checkboxes Co-Authored-By: Claude Opus 4.8 (1M context) * feat: "Show all drafts" toggle on the deploy-drafts page Replace the deploy-drafts page's legacy-hiding "Only my drafts" toggle with a "Show all drafts" toggle that switches the listing scope between the current user's own drafts (+ legacy no-owner rows) and every user's drafts in the workspace. Backend (`drafts.rs`, `openapi.yaml`): - `/drafts/list` gains an `all_users` query param that drops the owner filter, and a per-row `mine` flag (own draft or legacy no-owner row). `DISTINCT ON` now prefers the user's own row, then the legacy row, then another user's, so `mine`/`legacy_draft` describe the kept row. Frontend (`CompareDrafts.svelte`, `workspaceDrafts.svelte.ts`): - "Show all drafts" toggle (default off). The all-users superset is fetched lazily via the shared resource only while the toggle is on, so the page's fork draft-count (own drafts) is unaffected. - Other users' drafts are view-only: disabled checkbox + Discard with a "belongs to another user" tooltip; Show diff stays enabled. Selection, select-all and the deploy count only ever include the user's own drafts. The multi-user warning triangle shows on owned rows only. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(backend): gate all_users draft listing by read permission Addresses the PR review on the per-user deploy-drafts page: - `/drafts/list?all_users=true` previously had only `WHERE workspace_id = $1` with no read-permission check, so any non-operator could enumerate every draft's path, summary and authors — including items they can't read. Now rows the caller doesn't own (`mine = false`) are gated through `require_can_read_path` (the same gate `/drafts/get` uses) and dropped when unreadable; both its `NotFound` and `NotAuthorized` denials are treated as "not visible". - Skip the per-row `require_can_write_path` probe on those non-owned rows (they're never selectable — `isSelectable` requires `mine`): set `can_write = false` directly, removing a redundant N RLS write-probes when `all_users` is on. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): only confirm destructive draft discards on the deploy page Discarding a draft is non-destructive in every case except removing the last draft of a never-deployed item (`draft_only` with no other user's draft), which permanently deletes it. Confirm only that case; reverting a draft over a deployed item, or discarding your copy while another user still holds a draft, now runs immediately (the ⚠️ already signals the multi-user case). Drops the redundant "other users still have a draft" / "deployed version unaffected" confirmation branches. Harden the destructive check: it keyed off `otherDraftUsers()`, which subtracts `currentUsername`; while `$userStore.username` is unhydrated, your own draft looked like another user's, flipping a draft-only item to "non-destructive" and deleting it with no confirmation. Now: deployed counterpart → never destructive; `draft_only` with unknown `currentUsername` → treated as destructive (confirm). The delete modal also shows the friendly `draft_path` instead of the raw `draft_{uuid}` storage path. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): deploy low-code app drafts (value + summary persistence) A visual (low-code) app draft is autosaved as the *bare* App value (grid/theme/... plus a draft-only `draft_path`), not wrapped in { value, summary, policy } like script/flow drafts. The Review & Deploy page read `requestBody.value = d.value` — undefined for that shape — so deploying any low-code app draft (created or edited) sent no value and failed. Read the value from the draft object itself, strip the draft-only `draft_path` from it, and use that as the deploy path. Also persist the app summary, which was dropped entirely: the autosave stores the bare App value (the summary normally lives only in the `app` table column, set on deploy), so a draft never carried it — reopening a draft or deploying it lost the summary. Mirror the summary onto the autosaved App (like `draft_path`), read it back when loading a draft, and on deploy send it as the summary column while stripping it (and `draft_path`) from the deployed value so the value stays clean. Verified end-to-end: a new low-code app with a summary deploys at its pretty path with the summary set, content intact, and no draft_path/summary leaked into the deployed value; the draft is cleaned up. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-api/openapi.yaml | 25 +- backend/windmill-api/src/drafts.rs | 108 +++++++- .../src/lib/components/CompareDrafts.svelte | 250 +++++++++++++----- frontend/src/lib/components/DraftBadge.svelte | 17 +- .../components/WorkspaceDeployLayout.svelte | 67 +++-- .../apps/editor/AppEditorHeader.svelte | 14 + frontend/src/lib/components/apps/types.ts | 9 + .../common/checkbox/Checkbox.svelte | 38 +++ .../lib/components/common/table/Row.svelte | 24 +- .../components/copilot/chat/global/core.ts | 33 +-- .../components/raw_apps/rawAppDraftValue.ts | 51 ++++ frontend/src/lib/rawAppDeploy.ts | 39 +-- frontend/src/lib/utils_draft_deploy.ts | 52 ++-- frontend/src/lib/workspaceDrafts.svelte.ts | 34 ++- .../(logged)/apps/edit/[...path]/+page.svelte | 12 +- 15 files changed, 583 insertions(+), 190 deletions(-) create mode 100644 frontend/src/lib/components/common/checkbox/Checkbox.svelte create mode 100644 frontend/src/lib/components/raw_apps/rawAppDraftValue.ts diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 784a5be78a..6bc1b560f2 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7898,6 +7898,11 @@ paths: - draft parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: all_users + in: query + description: List every draft in the workspace (all users), not just the current user's own + legacy rows. Other users' rows come back with `mine=false` (view-only). + schema: + type: boolean responses: "200": description: the user's drafts @@ -7927,7 +7932,25 @@ paths: created_at: type: string format: date-time - required: [kind, path, draft_only, legacy_draft, created_at] + can_write: + type: boolean + description: Whether the current user may deploy/discard this draft (same check the deploy/discard endpoints enforce). + mine: + type: boolean + description: The row belongs to the current user (own draft or the legacy no-owner row) and is therefore actionable. Always true in the default listing; with `all_users=true`, other users' rows are false (view-only). + draft_users: + description: | + Draft authors at this (path, kind) — the legacy NULL-email row surfaced as a null username. + Populated only for the shared full-page-editor kinds (script/flow/app/raw_app); omitted for + drawer kinds, which keep their drafts private. Feeds the Draft badge's owner-avatar circles. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + required: [kind, path, draft_only, legacy_draft, created_at, can_write, mine] /w/{workspace}/drafts/get/{kind}/{path}: get: diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index c6146fb47a..ffa78b1358 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -9,7 +9,7 @@ use crate::db::{ApiAuthed, DB}; use axum::{ - extract::{Extension, Path}, + extract::{Extension, Path, Query}, routing::{get, post}, Json, Router, }; @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use windmill_common::{ db::UserDB, error::{Error, Result}, - user_drafts::{UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, + user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, variables::{build_crypt, encrypt}, }; @@ -50,6 +50,30 @@ pub struct DraftListItem { /// row exists at this (path, kind) — the DISTINCT ON prefers an owned row. pub legacy_draft: bool, pub created_at: chrono::DateTime, + /// All draft authors at this `(path, kind)`, for the shared full-page-editor + /// kinds (script/flow/app/raw_app) only — feeds the home-page-style owner + /// circles on the review page. `None` for drawer kinds, which keep their + /// drafts private. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, + /// Whether the authed user may deploy/discard this draft — the same check + /// the deploy/discard endpoints enforce. Computed per row after the query, + /// so it defaults to `false` when read from the row. + #[sqlx(default)] + pub can_write: bool, + /// The listed row belongs to the authed user (own draft or the legacy + /// no-owner row) and is therefore actionable by them. Always `true` in the + /// default (own-drafts) listing; only meaningful with `all_users=true`, + /// where other users' rows surface as `false` (view-only — you can't deploy + /// someone else's draft). + pub mine: bool, +} + +#[derive(Deserialize)] +pub struct ListDraftsQuery { + /// List every draft in the workspace (all users), not just the authed + /// user's own + legacy rows. Other users' rows come back with `mine=false`. + pub all_users: Option, } /// Every draft the authed user has in this workspace, across all kinds — the @@ -59,7 +83,9 @@ pub struct DraftListItem { async fn list_drafts( authed: ApiAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path(w_id): Path, + Query(query): Query, ) -> Result>> { // Operators have no drafts of their own (they can't write any, see // `require_can_write_path`), so this list is always empty for them. They @@ -67,20 +93,58 @@ async fn list_drafts( if authed.is_operator { return Ok(Json(vec![])); } - let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query()) + let all_users = query.all_users.unwrap_or(false); + let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query(all_users)) .bind(&w_id) .bind(&authed.email) .fetch_all(&db) .await?; - Ok(Json(rows)) + // Per-row permission gating: + // - own drafts (incl. legacy no-owner rows, `mine = true`): the actionable + // gate is write permission — run the exact check deploy/discard enforce so + // the UI never offers an action that would 403. + // - other users' drafts (only present with `all_users`, `mine = false`): the + // UI never lets you act on them (`isSelectable` requires `mine`), so skip + // the write probe (`can_write = false`) and instead require READ access — + // otherwise the broadened listing would disclose the path/summary/authors + // of items the caller can't see. Unreadable rows are dropped, mirroring the + // `require_can_read_path` gate on `/drafts/get`. + let mut out = Vec::with_capacity(rows.len()); + for mut row in rows { + if row.mine { + row.can_write = + match require_can_write_path(&authed, &db, &user_db, &w_id, row.kind, &row.path) + .await + { + Ok(()) => true, + Err(Error::NotAuthorized(_)) => false, + Err(e) => return Err(e), + }; + out.push(row); + } else { + // `require_can_read_path` denies with `NotFound` (it hides existence) + // and, for some paths, `NotAuthorized` — both mean "not visible to the + // caller", so drop the row. Any other error is a real failure. + match require_can_read_path(&authed, &user_db, &w_id, row.kind, &row.path).await { + Ok(()) => { + row.can_write = false; + out.push(row); + } + Err(Error::NotFound(_)) | Err(Error::NotAuthorized(_)) => {} + Err(e) => return Err(e), + } + } + } + Ok(Json(out)) } /// Build the `list_drafts` SQL, generating the `draft_only` CASE from /// `deployed_table()` (shared single source — can't drift from the access /// check). Table names come from the closed enum, never user input. Kinds /// with no path-keyed table get no arm and fall to `ELSE true`. -/// `$1` = workspace_id, `$2` = email. -fn list_drafts_query() -> String { +/// `$1` = workspace_id, `$2` = email. With `all_users` the owner filter is +/// dropped so every workspace draft is listed (others' rows get `mine=false`). +fn list_drafts_query(all_users: bool) -> String { let mut case = String::from("CASE d.typ::text\n"); for kind in UserDraftItemKind::ALL { let Some(table) = kind.deployed_table() else { @@ -101,15 +165,35 @@ fn list_drafts_query() -> String { )); } case.push_str(" ELSE true\nEND"); - // `(d.email = $2 OR d.email IS NULL)` lists the user's own drafts AND the - // legacy NULL-email rows; `DISTINCT ON (d.path, d.typ)` with `email IS NULL` - // last collapses a (path, kind) that has both to the owned row. + // Owner circles, mirroring the home-page list subquery (see apps.rs): every + // draft author at this (path, kind), legacy NULL-email row surfaced as a + // null username. Restricted to the shared full-page-editor kinds — drawer + // kinds keep their drafts private, so we never reveal their authors. + let draft_users = r#"CASE WHEN d.typ::text IN ('script', 'flow', 'app', 'raw_app') THEN ( + SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN du.workspace_id = 'admins' THEN du.email END)) + ORDER BY COALESCE(u.username, CASE WHEN du.workspace_id = 'admins' THEN du.email END) NULLS LAST) + FROM draft du + LEFT JOIN usr u ON u.workspace_id = du.workspace_id AND u.email = du.email + WHERE du.workspace_id = d.workspace_id AND du.path = d.path AND du.typ = d.typ + ) ELSE NULL END"#; + // Default lists the user's own drafts AND the legacy NULL-email rows; with + // `all_users` the filter is dropped to list every workspace draft. + let owner_filter = if all_users { + "" + } else { + " AND (d.email = $2 OR d.email IS NULL)" + }; + // `DISTINCT ON (d.path, d.typ)` keeps one row per item; the ORDER BY + // priority below picks the user's own row first, then the legacy NULL row, + // then (only with `all_users`) another user's row. `mine`/`legacy_draft` + // describe that kept row. format!( r#"SELECT DISTINCT ON (d.path, d.typ) d.path, d.typ AS kind, d.created_at, d.value ->> 'summary' AS summary, + {draft_users} AS draft_users, -- Friendly typed path, by kind (mirrors the home-page list -- endpoints): scripts bind the Path widget to `script.path`, -- so it round-trips through the draft JSON's own `path`; @@ -124,10 +208,12 @@ fn list_drafts_query() -> String { d.path ) AS draft_path, (d.email IS NULL) AS legacy_draft, + (d.email = $2 OR d.email IS NULL) AS mine, {case} AS draft_only FROM draft d - WHERE d.workspace_id = $1 AND (d.email = $2 OR d.email IS NULL) - ORDER BY d.path, d.typ, (d.email IS NULL)"# + WHERE d.workspace_id = $1{owner_filter} + ORDER BY d.path, d.typ, + CASE WHEN d.email = $2 THEN 0 WHEN d.email IS NULL THEN 1 ELSE 2 END"# ) } diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 7269e54a3b..24e554ee65 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -2,19 +2,22 @@ import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte' import DiffDrawer from './DiffDrawer.svelte' import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte' + import DraftBadge from './DraftBadge.svelte' + import Toggle from './Toggle.svelte' + import Popover from './meltComponents/Popover.svelte' import { Badge } from './common' - import Tooltip from './meltComponents/Tooltip.svelte' import Button from './common/button/Button.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' - import { ArrowRight, DiffIcon, GitFork, Pencil, Undo2 } from 'lucide-svelte' + import { AlertTriangle, ArrowRight, DiffIcon, GitFork, Pencil, Undo2 } from 'lucide-svelte' import { untrack } from 'svelte' import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte' import { editUrlFor } from './sessions/forkEditUrl' import { AppService, FlowService, ScriptService, type WorkspaceItemDiff } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { getDraftDiffValues, deployDraft, discardDraft } from '$lib/utils_draft_deploy' - import { type DraftItem } from '$lib/workspaceDrafts.svelte' + import { type DraftItem, useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import type { Kind as LayoutKind } from '$lib/utils_deployable' + import { userStore } from '$lib/stores' interface Props { currentWorkspaceId: string @@ -68,6 +71,12 @@ legacy_draft: boolean raw_app: boolean key: string + can_write: boolean + draft_users?: { username?: string | null }[] + /** The row is my own draft (or the legacy no-owner row) — only then is it + * actionable. Other users' rows (shown when "Show all drafts" is on) are + * view-only: you can't deploy/discard someone else's draft. */ + mine: boolean } function getItemKey(kind: string, path: string): string { return `${kind}:${path}` @@ -98,12 +107,25 @@ return kind as LayoutKind } - // The list (and the Draft Count) come from the shared Workspace Drafts module, - // owned by the page and passed in via `draftItems`; deploy/discard invalidate - // that resource, so the list refetches and deployed items drop off without a - // manual reload here. + // "Show all drafts" widens the list from my own (+ legacy) to every user's + // drafts in the workspace. Off by default. The default view reuses the page's + // shared Workspace Drafts resource (passed in via `draftItems`); the "all + // users" superset is fetched lazily here via its own resource — only while the + // toggle is on (workspace() is undefined otherwise, so no fetch) — and shares + // the same invalidation, so a deploy/discard refetches both. + let showAll = $state(false) + const allDrafts = useWorkspaceDrafts( + () => (showAll ? currentWorkspaceId : undefined), + () => true + ) + const sourceItems = $derived(showAll ? allDrafts.items : draftItems) + const loading = $derived(showAll ? allDrafts.loading : draftsLoading) + + // The list (and, in the default view, the Draft Count) come from the Workspace + // Drafts module; deploy/discard invalidate the resource, so the list refetches + // and deployed items drop off without a manual reload here. const items: Row[] = $derived( - draftItems.map((d) => ({ + sourceItems.map((d) => ({ ...d, key: getItemKey(d.kind, d.path), kind: toLayoutKind(d.kind), @@ -113,13 +135,46 @@ })) ) + const currentUsername = $derived($userStore?.username) + + // Other real users (not me, not the legacy NULL-email row) who also drafted + // this path. Only the shared full-page-editor kinds carry draft_users, so this + // is naturally empty for drawer kinds. Deploying only deploys my own draft, so + // a non-empty list warrants the triangle warning. + function otherDraftUsers(row: Row): string[] { + return (row.draft_users ?? []) + .map((u) => u.username) + .filter((u): u is string => !!u && u !== currentUsername) + } + + // The backend already returns exactly the rows for the current view (own + + // legacy, or every user's with "Show all drafts"), so there's no client-side + // filtering — `visibleItems` is just the mapped list. + const visibleItems = $derived(items) + + // A row is actionable when it isn't already deployed this session, the user has + // write permission, AND it's their own draft (you can't deploy someone else's + // draft — those show view-only in the "all drafts" view). The server enforces + // the same; this keeps the UI honest. + function isSelectable(item: Row): boolean { + return deploymentStatus[item.key]?.status !== 'deployed' && item.can_write && item.mine + } + + // Why a row can't be deployed/discarded (drives the disabled-checkbox tooltip + // and the Discard button's title). `undefined` ⇒ actionable. + function blockedReason(item: Row): string | undefined { + if (!item.mine) return 'This draft belongs to another user' + if (!item.can_write) return "You don't have write permission on this path" + return undefined + } + // The Draft Items list only carries the *deployed* summary, so the draft's // (new) display name isn't known yet. Fetch each item's draft blob once and // cache both names — mirrors CompareWorkspaces' fetchSummaries (eager on load, // keyed by row key) so the rename rendering is shared and consistent. Only // non-`draft_only` items can show a rename: a `draft_only` item has no deployed - // side to diff the name against. Raw apps live on a separate route and aren't - // fetchable here, so they're skipped (no rename shown, same as before). + // side to diff the name against. Raw apps are fetched via the apps endpoint too + // (it auto-detects raw from the deployed row and overlays the raw_app draft). const summaryCache = $state< Record >({}) @@ -161,9 +216,9 @@ untrack(() => { for (const item of current) { if ( + item.mine && !item.draft_only && - !item.raw_app && - ['script', 'flow', 'app'].includes(item.draftKind) && + (['script', 'flow', 'app'].includes(item.draftKind) || item.raw_app) && !summaryCache[item.key] ) { void fetchDraftSummary(item) @@ -197,29 +252,23 @@ }) $effect(() => { - if (!hasAutoSelected && items.length > 0) { - selectedItems = items - .filter((i) => deploymentStatus[i.key]?.status !== 'deployed') - .map((i) => i.key) + if (!hasAutoSelected && visibleItems.length > 0) { + selectedItems = visibleItems.filter(isSelectable).map((i) => i.key) hasAutoSelected = true } }) - // Selected items still in the live list and deployable. Derived (not a pruning - // effect) so the "Deploy N drafts" button stays reactive to the Workspace - // Drafts resource: deploy/discard drop items, and stale keys left in + // Selected items still in the visible list and deployable. Derived (not a + // pruning effect) so the "Deploy N drafts" button stays reactive to the + // Workspace Drafts resource: deploy/discard drop items, and stale keys left in // selectedItems are simply ignored here (and by deploySelected). let selectedCount = $derived( - items.filter( - (i) => selectedItems.includes(i.key) && deploymentStatus[i.key]?.status !== 'deployed' - ).length + visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i)).length ) let allSelected = $derived( - items.length > 0 && - items - .filter((i) => deploymentStatus[i.key]?.status !== 'deployed') - .every((i) => selectedItems.includes(i.key)) + visibleItems.filter(isSelectable).length > 0 && + visibleItems.filter(isSelectable).every((i) => selectedItems.includes(i.key)) ) function toggleItem(item: { key: string }) { @@ -231,9 +280,7 @@ } function selectAll() { - selectedItems = items - .filter((i) => deploymentStatus[i.key]?.status !== 'deployed') - .map((i) => i.key) + selectedItems = visibleItems.filter(isSelectable).map((i) => i.key) } function deselectAll() { @@ -272,8 +319,9 @@ async function deploySelected() { deploying = true // Snapshot the items to deploy: deployDraft invalidates the Workspace Drafts - // resource, so `items` can change mid-loop — iterate a stable copy. - const toDeploy = items.filter((i) => selectedItems.includes(i.key)) + // resource, so `items` can change mid-loop — iterate a stable copy. Guard on + // isSelectable so a non-writable row can never be deployed via a stale key. + const toDeploy = visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i)) let deployedAny = false for (const item of toDeploy) { deploymentStatus[item.key] = { status: 'loading' } @@ -301,12 +349,34 @@ } // --- Discard --- + // Only one discard is destructive: removing the last draft of a never-deployed + // item (draft_only, and no other user still holds a draft) permanently deletes + // the item, so it gets a confirmation. Every other discard just reverts to the + // deployed version or removes your own copy while another draft remains — those + // run immediately (the row already carries the ⚠️ for the multi-user case). let discardTarget = $state(undefined) - async function confirmDiscard() { - const item = discardTarget - discardTarget = undefined - if (!item) return + function isDestructiveDiscard(item: Row): boolean { + // A deployed counterpart exists → discard just reverts, never deletes. + if (!item.draft_only) return false + // draft_only → discarding deletes the item, UNLESS another real user still + // holds a draft of it. Guard on `currentUsername`: if we don't yet know who + // "me" is, `otherDraftUsers` would count my own row as someone else's, so + // fall back to treating it as a delete (confirm) rather than risk a silent + // deletion. + if (!currentUsername) return true + return otherDraftUsers(item).length === 0 + } + + function onDiscardClick(item: Row) { + if (isDestructiveDiscard(item)) { + discardTarget = item + } else { + void doDiscard(item) + } + } + + async function doDiscard(item: Row) { const res = await discardDraft( item.draftKind, item.path, @@ -323,6 +393,12 @@ } } + function confirmDiscard() { + const item = discardTarget + discardTarget = undefined + if (item) void doDiscard(item) + } + // Editor URL for a draft item, scoped to the current workspace. Raw apps live // under a different editor route, so map their kind accordingly. Kinds whose // editor is a drawer on a list page (variables, resources, schedules, @@ -388,16 +464,33 @@
deploymentStatus[item.key]?.status !== 'deployed'} + selectablePredicate={(item) => isSelectable(item as unknown as Row)} + selectBlockedReason={(item) => blockedReason(item as unknown as Row)} onToggleItem={toggleItem} onSelectAll={selectAll} onDeselectAll={deselectAll} - emptyMessage={draftsLoading ? 'Loading drafts…' : 'No drafts in this workspace'} + emptyMessage={loading + ? 'Loading drafts…' + : showAll + ? 'No drafts in this workspace' + : 'No drafts you authored in this workspace'} > + {#snippet selectAllActions()} + + {/snippet} + {#snippet header()} {#if isFork}
@@ -443,28 +536,58 @@ {oldSummary} {newSummary} renamed={!draftItem.draft_only && - oldSummary != null && - newSummary != null && + !!oldSummary && + !!newSummary && oldSummary !== newSummary} /> {/snippet} + {#snippet itemPath(item)} + {@const draftItem = item as unknown as Row} + {#if draftItem.kind === 'resource' || draftItem.kind === 'variable' || draftItem.kind === 'resource_type'} + + {:else if !draftItem.draft_only && draftItem.draft_path && draftItem.draft_path !== draftItem.path} + + {draftItem.path} + {draftItem.draft_path} + {:else} + {draftItem.draft_path ?? draftItem.path} + {/if} + {/snippet} + {#snippet itemActions(item)} {@const draftItem = item as unknown as Row} + {@const others = otherDraftUsers(draftItem)} {kindLabel(draftItem.draftKind)} - {#if draftItem.draft_only} - New - {/if} - {#if draftItem.legacy_draft} - - Legacy draft - {#snippet text()} - A legacy draft predates the per-user drafts migration: it isn't tied to any user - (workspace-level, email NULL), so everyone with access to this path sees it. + + {#if draftItem.mine && others.length > 0} + + {#snippet trigger()} + {/snippet} - + {#snippet content()} +
+ {others.length} other {others.length === 1 ? 'user' : 'users'} ({others.join(', ')}) + {others.length === 1 ? 'has' : 'have'} a draft of this item. Deploying only deploys your + draft; theirs are left untouched. +
+ {/snippet} + {/if} {#if deploymentStatus[draftItem.key]?.status !== 'deployed'} + {@const discardBlock = blockedReason(draftItem)} @@ -503,23 +628,18 @@
+ (discardTarget = undefined)} > - {#if discardTarget?.draft_only} -

- {discardTarget?.path} exists only as a - draft. Discarding it will permanently delete the item. This cannot be undone. -

- {:else} -

- Discard the draft of - {discardTarget?.path}? The deployed - version is unaffected. -

- {/if} +

+ {discardTarget?.draft_path ?? discardTarget?.path} exists only as a draft. Discarding it will permanently delete the item. This cannot be undone. +

diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 46c24578a4..a3e6d5d048 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -29,6 +29,9 @@ workspace?: string itemKind?: UserDraftItemKind path?: string + /** Offer "Fork" alongside "View JSON" on other users' rows. The deploy + * page sets this false: forking a new item is meaningless there. */ + allowFork?: boolean } let { @@ -38,7 +41,8 @@ currentUsername = undefined, workspace = undefined, itemKind = undefined, - path = undefined + path = undefined, + allowFork = true }: Props = $props() // Authed user lands first; everyone else keeps the backend's ordering. @@ -163,7 +167,14 @@ {#if showBadge} - + + {#snippet trigger()} {#if orderedUsers.length > 0} @@ -244,7 +255,7 @@ View JSON - {#if !$userStore?.operator} + {#if allowFork && !$userStore?.operator} + + {#if draftItem.mine} + + {/if} @@ -749,16 +951,10 @@ @@ -867,6 +1063,14 @@
{/if} + {#if registryCcCapable()} + + {/if} {#key resourceTypeInfo} Create a resource backed by an OAuth connection, whose token is fetched from the external services and refreshed automatically if needed before expiration.
- + {#if ccBringYourOwn} + + {/if}
{#if resourceTypeInfo?.description} @@ -909,26 +1118,40 @@ {#if supportsClientCredentials} -
-

Authentication Method

-
- - - - Server-to-server authentication without user interaction. -

- Provide your own OAuth client credentials for this resource. -
-
+
+

Authentication

+ {#if ccOnly || ccBringYourOwn} +
+ {#if useSharedInstanceCreds} + {resourceType} connects server-to-server using the credentials configured for this + instance. The token is acquired and refreshed automatically. + {:else} + {resourceType} connects server-to-server. Enter a client ID and secret; the token is + acquired and refreshed automatically. + {/if} +
+ {:else} +
+ + enableClientCredentials()} + /> +
+ {/if} - {#if useClientCredentials} + {#if useClientCredentials && !useSharedInstanceCreds}
- + {#if ccInstanceMeta} + + {/if}
{/if}
diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index 335b64277c..2ad6ab917a 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -18,6 +18,8 @@ import { capitalize, type Item } from '$lib/utils' import ClipboardPanel from './details/ClipboardPanel.svelte' import Toggle from './Toggle.svelte' + import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import DropdownV2 from './DropdownV2.svelte' import { APP_TO_ICON_COMPONENT } from './icons' import { ExternalLink, Plus, Circle, X } from 'lucide-svelte' @@ -100,6 +102,10 @@ // carry a `connect_config_template`. Derived from the registry so adding a // new one needs only a JSON entry — they get a builtin tile + the generic // instance-name input below, with no frontend change. + // Every per-instance templated provider gets a settings tile + instance input: + // authorization-code ones (ServiceNow) provide an `auth_url`, client-credentials-only + // ones (Coupa) provide only a `token_url`. The admin enters their instance host so + // the shared credentials point at the right endpoint. const connectConfigTemplates: Record = Object.fromEntries( Object.entries(oauthConnectRegistry) .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg) @@ -112,6 +118,55 @@ ...windmillBuiltinsTemplated ] + /** Resolve a `_sandbox` key to its parent registry entry (sandbox + * variants inherit the parent's grant_types), matching the connect dialog. */ + function canonicalRegistryKey(name: string): string { + return name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name + } + + /** The static registry declares client credentials for this provider */ + function registryCcCapable(name: string): boolean { + return ( + (oauthConnectRegistry as Record)[ + canonicalRegistryKey(name) + ]?.grant_types?.includes('client_credentials') ?? false + ) + } + + /** The static registry supports authorization code for this provider. A + * provider with no explicit grant_types defaults to authorization code. */ + function registryAuthCodeCapable(name: string): boolean { + const reg = (oauthConnectRegistry as Record)[canonicalRegistryKey(name)] + if (!reg) return false + return reg.grant_types ? reg.grant_types.includes('authorization_code') : true + } + + /** Built-in provider that only supports client credentials (e.g. Coupa): no + * authorization-code flow to choose, so the grant is fixed. */ + function registryCcOnly(name: string): boolean { + return registryCcCapable(name) && !registryAuthCodeCapable(name) + } + + /** Map the entry's grant_types to the single-select choice (so the segmented + * control always has exactly one selected and can never be empty) */ + function grantChoice(name: string): string { + const gts = oauths?.[name]?.['grant_types'] ?? ['authorization_code'] + const cc = gts.includes('client_credentials') + const ac = gts.includes('authorization_code') + if (cc && ac) return 'both' + if (cc) return 'client_credentials' + return 'authorization_code' + } + + /** Set the grant types from the segmented choice. The instance credentials are + * then used for every selected grant — authorization-code popup and/or + * server-to-server. */ + function setGrantChoice(name: string, choice: string) { + if (!oauths || !oauths[name]) return + oauths[name]['grant_types'] = + choice === 'both' ? ['authorization_code', 'client_credentials'] : [choice] + } + let showCustomOAuthForm = $state(false) let customOAuthName = $state('') let customNameInput = $state() @@ -125,7 +180,11 @@ if (oauths && name) { // Create a new object to ensure the new item is added at the end const newOauths = { ...oauths } - newOauths[name] = { id: '', secret: '', grant_types: ['authorization_code'] } + newOauths[name] = { + id: '', + secret: '', + grant_types: registryCcOnly(name) ? ['client_credentials'] : ['authorization_code'] + } oauths = newOauths dropdownOpen = false } @@ -463,49 +522,51 @@ bind:password={oauths[k]['secret']} /> - {#if k === 'visma' || !windmillBuiltins.includes(k)} -
-
- { - const target = e.target as HTMLInputElement - if (oauths && oauths[k]) { - if (!oauths[k]['grant_types']) { - oauths[k]['grant_types'] = ['authorization_code'] - } - if (target.checked) { - if (!oauths[k]['grant_types'].includes('client_credentials')) { - oauths[k]['grant_types'] = [ - ...oauths[k]['grant_types'], - 'client_credentials' - ] - } - } else { - oauths[k]['grant_types'] = oauths[k]['grant_types'].filter( - (gt: string) => gt !== 'client_credentials' - ) - } - } - }} - /> - Support Client Credentials Flow + These credentials are for + {#if !windmillBuiltins.includes(k) || (registryCcCapable(k) && registryAuthCodeCapable(k))} + setGrantChoice(k, v)} + > + {#snippet children({ item })} + + + + {/snippet} + + {:else if registryCcCapable(k)} + + Client credentials (server-to-server) + Fill Client ID and Secret to share one service account, or leave them empty + so each user brings their own. - - Enables server-to-server authentication without user interaction. Use for - automated scripts and background jobs. -

- When enabled, users can provide their own client credentials at the resource - level. The Client ID and Secret configured above are only used for the traditional - OAuth flow (popup window). -
-
-
- {/if} + + {:else} + Authorization code (browser sign-in) + {/if} +
{#if k === 'azure_oauth'} {:else if !windmillBuiltins.includes(k) && k != 'slack'} diff --git a/frontend/src/lib/components/CustomOauth.svelte b/frontend/src/lib/components/CustomOauth.svelte index 432a02152b..942dc553d7 100644 --- a/frontend/src/lib/components/CustomOauth.svelte +++ b/frontend/src/lib/components/CustomOauth.svelte @@ -1,12 +1,12 @@