From 41b166b90b3c0c5798fd3771dc8149c3ce046f74 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 9 Oct 2026 11:22:26 +0200 Subject: [PATCH] feat: chat SDK, streaming examples and a copyable integration prompt on the agent API tab Co-Authored-By: Claude Opus 5.5 --- .../flows/content/AgentUsePanel.svelte | 141 ++++++------- .../flows/content/agentApiExamples.ts | 191 ++++++++++++++++++ 2 files changed, 263 insertions(+), 69 deletions(-) create mode 100644 frontend/src/lib/components/flows/content/agentApiExamples.ts diff --git a/frontend/src/lib/components/flows/content/AgentUsePanel.svelte b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte index 49f33974b5..4250dd673d 100644 --- a/frontend/src/lib/components/flows/content/AgentUsePanel.svelte +++ b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte @@ -14,7 +14,15 @@ import { SlackAgentsService, type SlackChannelAgent } from '$lib/gen' import { userStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' - import { generateRandomString } from '$lib/utils' + import { copyToClipboard, generateRandomString } from '$lib/utils' + import { Sparkles } from 'lucide-svelte' + import { + curlExample, + fetchExample, + integrationPrompt, + runUrl, + sdkExample + } from './agentApiExamples' /** * The ways to use an agent outside its page: in Slack, anywhere by its `+name` and without one @@ -127,56 +135,24 @@ let userSettings: UserSettings | undefined = $state(undefined) let token = $state('') - let url = $derived( - `${location.origin}${base}/api/w/${workspace}/jobs/run/agent/${agentPath}` + - (chat ? '?memory_id=my-conversation-1' : '') + let target = $derived({ + api: `${location.origin}${base}/api/w/${workspace}`, + agentPath, + chat, + token + }) + let body = JSON.stringify({ user_message: 'Hello' }, null, 2) + let headers = $derived( + JSON.stringify( + { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + null, + 2 + ) ) - let resultUrl = $derived( - `${location.origin}${base}/api/w/${workspace}/jobs_u/completed/get_result_maybe` + let apiTab = $state(undefined) + let shownApiTab = $derived( + apiTab === 'sdk' && !chat ? 'rest' : (apiTab ?? (chat ? 'sdk' : 'rest')) ) - const body = JSON.stringify({ user_message: 'Hello' }) - let headers = $derived({ 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }) - - let curlCode = $derived(`TOKEN='${token}' -BODY='${body}' -URL='${url}' -UUID=$(curl -s -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" -X POST -d "$BODY" "$URL") - -URL="${resultUrl}/$UUID" -while true; do - curl -s -H "Authorization: Bearer $TOKEN" "$URL" -o res.json - COMPLETED=$(cat res.json | jq .completed) - if [ "$COMPLETED" = "true" ]; then - cat res.json | jq .result.output - break - else - sleep 1 - fi -done`) - - let fetchCode = $derived(`export async function main() { - const UUID = await (await sendMessage()).text(); - return await waitForAnswer(UUID); -} - -async function sendMessage() { - return await fetch(\`${url}\`, { - method: 'POST', - headers: ${JSON.stringify(headers, null, 2).replaceAll('\n', '\n ')}, - body: JSON.stringify(${body}) - }); -} - -async function waitForAnswer(UUID) { - while (true) { - const res = await fetch(\`${resultUrl}/\${UUID}\`, { - headers: { Authorization: 'Bearer ${token}' } - }); - const data = await res.json(); - if (data.completed) return data.result?.output; - await new Promise((r) => setTimeout(r, 1000)); - } -}`) - Slack refused the workspace's bot token or couldn't be reached, so agents can't answer there. + Slack refused the workspace's bot token or couldn't be reached, so agents can't + answer there. {#if isAdmin} Reconnect Slack to fix it. {:else} @@ -310,11 +287,28 @@ async function waitForAnswer(UUID) { - - Each call runs the agent as the token's owner and returns the id of the run.{#if chat}{' '}Calls - that share a memory_id are one conversation: reuse it for follow-ups and pick - a new one to start over.{/if} - +
+ + Each call runs the agent as the token's owner and returns the id of the run, whose + answer streams as it is written.{#if chat}{' '}Calls that share a + memory_id, any string you choose, are one conversation.{/if} + +
+ +
+
- + shownApiTab, (t) => (apiTab = t)}> + {#if chat} + + {/if} {#snippet content()} - - - - + - Then poll {resultUrl}/{id} until - completed - is true; the answer is the result's output. + React with windmill-chat: streaming, tool calls and conversations + handled. It also plugs into the Vercel AI SDK and assistant-ui. + + + + + + + + + Then follow the run: the Fetch example streams it, the Curl one waits for the + answer. - + - + {/snippet} diff --git a/frontend/src/lib/components/flows/content/agentApiExamples.ts b/frontend/src/lib/components/flows/content/agentApiExamples.ts new file mode 100644 index 0000000000..7dbc92f3d8 --- /dev/null +++ b/frontend/src/lib/components/flows/content/agentApiExamples.ts @@ -0,0 +1,191 @@ +/** + * The code an agent's API tab offers to copy. Every example is meant to run as copied once the + * token is filled in, so each one is exercised against a live agent before it changes here. + */ +export type AgentApiTarget = { + /** `https://host/api/w/`, base path included. */ + api: string + agentPath: string + /** Whether the agent keeps the conversation: only then is `memory_id` worth sending. */ + chat: boolean + token: string +} + +export function runUrl({ api, agentPath }: AgentApiTarget): string { + return `${api}/jobs/run/agent/${agentPath}` +} + +export function curlExample(t: AgentApiTarget): string { + const conversation = t.chat + ? `# Any string names a conversation: reuse it for follow-ups, pick a new one to start over. +CONVERSATION=$(uuidgen) +` + : '' + return `TOKEN='${t.token}' +${conversation}JOB=$(curl -s -X POST "${runUrl(t)}${t.chat ? '?memory_id=$CONVERSATION' : ''}" \\ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\ + -d '{"user_message": "Hello"}') + +while true; do + RES=$(curl -s "${t.api}/jobs_u/completed/get_result_maybe/$JOB" -H "Authorization: Bearer $TOKEN") + if [ "$(echo "$RES" | jq .completed)" = "true" ]; then + echo "$RES" | jq -r '.result.output // .result.error.message' + break + fi + sleep 1 +done` +} + +export function fetchExample(t: AgentApiTarget): string { + const usage = t.chat + ? `export async function main() { + // Any string names a conversation: reuse it for follow-ups, pick a new one to start over. + const conversation = crypto.randomUUID(); + await ask('Hello', conversation, (text) => console.log(text)); + return await ask('What did I just say?', conversation, (text) => console.log(text)); +}` + : `export async function main() { + return await ask('Hello', (text) => console.log(text)); +}` + return `const API = '${t.api}'; +const HEADERS = { Authorization: 'Bearer ${t.token}' }; + +${usage} + +/** Runs the agent and streams its answer to \`onText\` as it is written; resolves to the full answer. */ +async function ask(message${t.chat ? ', conversation' : ''}, onText) { + const run = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}${t.chat ? '?memory_id=${conversation}' : ''}\`, { + method: 'POST', + headers: { ...HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_message: message }) + }); + if (!run.ok) throw new Error(await run.text()); + const job = (await run.text()).trim(); + + // The server closes the stream now and then: reconnect from the last offset, never rerun. + let offset = 0; + let pending = ''; + for (;;) { + const res = await fetch( + \`\${API}/jobs_u/getupdate_sse/\${job}?fast=true&only_result=true&stream_offset=\${offset}\`, + { headers: HEADERS } + ); + let buffer = ''; + for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) { + buffer += chunk; + let end; + while ((end = buffer.indexOf('\\n\\n')) >= 0) { + const frame = buffer.slice(0, end); + buffer = buffer.slice(end + 2); + if (!frame.startsWith('data:')) continue; + const update = JSON.parse(frame.slice(5)); + if (update.new_result_stream) { + // One JSON event per line; a chunk can stop mid-line. + pending += update.new_result_stream; + offset = update.stream_offset; + const lines = pending.split('\\n'); + pending = lines.pop(); + for (const line of lines) { + const event = JSON.parse(line); + if (event.type === 'token_delta') onText(event.content); + // Also: reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result + } + } + if (update.completed) { + const result = update.only_result; + if (result?.error) throw new Error(result.error.message); + return result?.output; + } + } + } + } +}` +} + +export function sdkExample(t: AgentApiTarget): string { + return `import { useWindmillChat } from 'windmill-chat/react' + +const API = '${t.api}' +const TOKEN = '${t.token}' + +export function AgentChat() { + const chat = useWindmillChat({ + baseUrl: '${new URL(t.api).origin}', + workspace: '${t.api.split('/').pop()}', + token: TOKEN, + // What the agent's conversations are filed under. With a token, history stays in this + // browser; history: 'server' also needs the token's flow_conversations:write scope. + flowPath: '${t.agentPath}.chat', + // Runs the agent for each turn; the conversation id is its memory. + run: async (args, { conversationId, signal }) => { + const res = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}?memory_id=\${conversationId}\`, { + method: 'POST', + headers: { Authorization: \`Bearer \${TOKEN}\`, 'Content-Type': 'application/json' }, + body: JSON.stringify(args), + signal + }) + if (!res.ok) throw new Error(await res.text()) + return (await res.text()).trim() + } + }) + // chat.messages (streamed as they are written), chat.status, chat.sendMessage(text), chat.stop(), + // and the conversations: chat.conversations, chat.selectConversation(id), chat.newConversation() +}` +} + +/** A brief for a coding assistant asked to integrate the agent. The token stays a placeholder. */ +export function integrationPrompt(t: AgentApiTarget): string { + const conversation = t.chat + ? ` +## Conversations + +The agent keeps the conversation. Pass \`memory_id\` as a query parameter on every run: any +string names a conversation, a UUID is best. Reuse it for follow-ups; a new one starts over. +Without it each message is answered on its own. + +Windmill also keeps each conversation, as the token's owner's. One token shared by all your +users makes every conversation theirs, so keep your users' conversation ids in your own app. +Reading them back needs a token with the \`flow_conversations:read\` scope (\`:write\` to delete): +- \`GET ${t.api}/flow_conversations/list?flow_path=${t.agentPath}.chat\` (\`page\`, \`per_page\`). + A conversation's \`id\` is the \`memory_id\` when that was a UUID. +- \`GET ${t.api}/flow_conversations/{id}/messages\`: its latest messages, oldest first. +- \`DELETE ${t.api}/flow_conversations/delete/{id}\`. + +For a chat UI in React, the \`windmill-chat\` library (https://github.com/windmill-labs/windmill/tree/main/chat-sdk) +does all of this: \`useWindmillChat\` from \`windmill-chat/react\`, or the Vercel AI SDK transport +(\`windmill-chat/ai-sdk\`) and assistant-ui runtime (\`windmill-chat/assistant-ui\`). Point it at the +agent with \`flowPath: '${t.agentPath}.chat'\` and a \`run(args, { conversationId, signal })\` option +that POSTs \`args\` to the run endpoint with \`memory_id=conversationId\` and returns the job id. +` + : '' + return `Integrate the Windmill AI agent \`${t.agentPath}\` into this project. + +## Authentication + +Every request sends \`Authorization: Bearer \`. Read the token from configuration, +never from code shipped to a browser: the agent runs as the token's owner, with their permissions. + +## Sending a message + +\`POST ${runUrl(t)}\` with the JSON body \`{"user_message": "..."}\` starts a run and returns its +job id as plain text. An error is a non-2xx status with the reason as text. +${conversation} +## Streaming the answer + +\`GET ${t.api}/jobs_u/getupdate_sse/{job_id}?fast=true&only_result=true\` is a server-sent events +stream of JSON objects: +- \`new_result_stream\`: text to append to a buffer holding one JSON event per line. A chunk can + stop mid-line, so parse complete lines only. Event types: \`token_delta\` (\`content\`: answer text), + \`reasoning_token_delta\`, \`tool_call\`, \`tool_call_arguments\`, \`tool_execution\`, \`tool_result\` + (\`call_id\`, \`function_name\`, \`result\`, \`success\`). +- \`stream_offset\`: how far the stream has been read. The server closes the stream now and then; + reconnect with \`&stream_offset=\` and never run the agent again for the same message. +- \`completed: true\` ends it. \`only_result.output\` is the full answer, or \`only_result.error.message\` + when the run failed. + +Without streaming, poll \`GET ${t.api}/jobs_u/completed/get_result_maybe/{job_id}\` until +\`completed\` is true; the answer is \`result.output\`. + +To stop a run: \`POST ${t.api}/jobs_u/queue/cancel/{job_id}\` with \`{"reason": "..."}\`. +` +}