mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 08:02:44 +00:00
feat: chat SDK, streaming examples and a copyable integration prompt on the agent API tab
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f103f3e9a3
commit
41b166b90b
@@ -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<string | undefined>(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));
|
||||
}
|
||||
}`)
|
||||
</script>
|
||||
|
||||
<UserSettings
|
||||
@@ -224,7 +200,8 @@ async function waitForAnswer(UUID) {
|
||||
{/if}
|
||||
{#if !botName}
|
||||
<Alert type="warning" title="Windmill can't reach Slack" size="xs">
|
||||
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}
|
||||
<a href="{base}/workspace_settings?tab=slack">Reconnect Slack</a> to fix it.
|
||||
{:else}
|
||||
@@ -310,11 +287,28 @@ async function waitForAnswer(UUID) {
|
||||
</TabContent>
|
||||
|
||||
<TabContent value="api" class="flex flex-col gap-6 p-4">
|
||||
<span class="text-xs text-secondary">
|
||||
Each call runs the agent as the token's owner and returns the id of the run.{#if chat}{' '}Calls
|
||||
that share a <code>memory_id</code> are one conversation: reuse it for follow-ups and pick
|
||||
a new one to start over.{/if}
|
||||
</span>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs text-secondary">
|
||||
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
|
||||
<code>memory_id</code>, any string you choose, are one conversation.{/if}
|
||||
</span>
|
||||
<div>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Sparkles }}
|
||||
onClick={() => copyToClipboard(integrationPrompt(target))}
|
||||
>
|
||||
Copy a prompt for your AI assistant
|
||||
<Tooltip light>
|
||||
Everything a coding assistant needs to integrate this agent: the endpoints,
|
||||
streaming{chat ? ', conversations and the chat library' : ''}. The token is left
|
||||
as a placeholder.
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Label label="Token">
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextInput
|
||||
@@ -337,30 +331,39 @@ async function waitForAnswer(UUID) {
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
<Tabs selected="rest">
|
||||
<Tabs bind:selected={() => shownApiTab, (t) => (apiTab = t)}>
|
||||
{#if chat}
|
||||
<Tab value="sdk" label="Chat SDK" />
|
||||
{/if}
|
||||
<Tab value="rest" label="REST" />
|
||||
<Tab value="curl" label="Curl" />
|
||||
<Tab value="fetch" label="Fetch" />
|
||||
{#snippet content()}
|
||||
<TabContent value="rest" class="flex flex-col gap-6 mt-2">
|
||||
<Label label="Url"><ClipboardPanel content={url} /></Label>
|
||||
<Label label="Body">
|
||||
<ClipboardPanel content={JSON.stringify(JSON.parse(body), null, 2)} />
|
||||
</Label>
|
||||
<Label label="Headers">
|
||||
<ClipboardPanel content={JSON.stringify(headers, null, 2)} />
|
||||
</Label>
|
||||
<TabContent value="sdk" class="flex flex-col gap-2 mt-2">
|
||||
<span class="text-xs text-secondary">
|
||||
Then poll <code class="break-all">{resultUrl}/{id}</code> until
|
||||
<code>completed</code>
|
||||
is true; the answer is the result's <code>output</code>.
|
||||
React with <code>windmill-chat</code>: streaming, tool calls and conversations
|
||||
handled. It also plugs into the Vercel AI SDK and assistant-ui.
|
||||
</span>
|
||||
<CopyableCodeBlock code={sdkExample(target)} language={typescript} />
|
||||
</TabContent>
|
||||
<TabContent value="rest" class="flex flex-col gap-6 mt-2">
|
||||
<Label label="Url">
|
||||
<ClipboardPanel
|
||||
content={runUrl(target) + (chat ? '?memory_id=<conversation id>' : '')}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Body"><ClipboardPanel content={body} /></Label>
|
||||
<Label label="Headers"><ClipboardPanel content={headers} /></Label>
|
||||
<span class="text-xs text-secondary">
|
||||
Then follow the run: the Fetch example streams it, the Curl one waits for the
|
||||
answer.
|
||||
</span>
|
||||
</TabContent>
|
||||
<TabContent value="curl" class="mt-2">
|
||||
<CopyableCodeBlock code={curlCode} language={bash} wrap />
|
||||
<CopyableCodeBlock code={curlExample(target)} language={bash} />
|
||||
</TabContent>
|
||||
<TabContent value="fetch" class="mt-2">
|
||||
<CopyableCodeBlock code={fetchCode} language={typescript} wrap />
|
||||
<CopyableCodeBlock code={fetchExample(target)} language={typescript} />
|
||||
</TabContent>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
|
||||
@@ -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/<workspace>`, 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 <WINDMILL_TOKEN>\`. 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=<last value>\` 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": "..."}\`.
|
||||
`
|
||||
}
|
||||
Reference in New Issue
Block a user