mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 16:03:06 +00:00
384 lines
13 KiB
Svelte
384 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { X } from 'lucide-svelte'
|
|
import { resource } from 'runed'
|
|
import { bash, typescript } from 'svelte-highlight/languages'
|
|
import { base } from '$lib/base'
|
|
import { Alert, Button, CopyButton, Tab, TabContent, Tabs } from '$lib/components/common'
|
|
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
|
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
|
|
import Label from '$lib/components/Label.svelte'
|
|
import Select from '$lib/components/select/Select.svelte'
|
|
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
|
import Tooltip from '$lib/components/Tooltip.svelte'
|
|
import UserSettings from '$lib/components/UserSettings.svelte'
|
|
import { SlackAgentsService, type SlackChannelAgent } from '$lib/gen'
|
|
import { userStore } from '$lib/stores'
|
|
import { sendUserToast } from '$lib/toast'
|
|
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
|
|
* in the channels it is the default of, and over the API. Only admins change the channels, as
|
|
* only they change the workspace's Slack settings.
|
|
*/
|
|
let {
|
|
agentPath,
|
|
workspace,
|
|
isAdmin,
|
|
chat,
|
|
slackLoading,
|
|
slackTeamName,
|
|
botName,
|
|
signingSecretSet,
|
|
channels,
|
|
onChanged
|
|
}: {
|
|
agentPath: string
|
|
workspace: string
|
|
isAdmin: boolean
|
|
/** Whether the agent keeps the conversation. Slack is offered only then: a thread is one. */
|
|
chat: boolean
|
|
/** Whether the workspace's Slack connection is still being read. */
|
|
slackLoading: boolean
|
|
/** Unset when Slack is not connected to the workspace. */
|
|
slackTeamName: string | undefined
|
|
/** Unset when Slack can't be reached with the workspace's bot token. */
|
|
botName: string | undefined
|
|
/** Agents answer in Slack only when the instance verifies Slack's request signatures. */
|
|
signingSecretSet: boolean
|
|
channels: SlackChannelAgent[]
|
|
onChanged: () => void
|
|
} = $props()
|
|
|
|
let pickedTab = $state('slack')
|
|
let tab = $derived(chat ? pickedTab : 'api')
|
|
let handle = $derived(`+${agentPath.split('/').pop()}`)
|
|
|
|
type Choice = { label: string; value: string; name: string }
|
|
let picked = $state<string | undefined>(undefined)
|
|
let saving = $state(false)
|
|
|
|
// Read again whenever the agent's channels change, so a channel just added or removed moves
|
|
// between the list and the picker.
|
|
const available = resource(
|
|
() => ({ ws: isAdmin && chat && slackTeamName && botName ? workspace : undefined, channels }),
|
|
async ({ ws }) => (ws ? await listChoices(ws) : [])
|
|
)
|
|
|
|
async function listChoices(ws: string): Promise<Choice[]> {
|
|
try {
|
|
// Which agent each channel already has, so picking one says what it replaces.
|
|
const taken = new Map(
|
|
(await SlackAgentsService.listSlackChannelAgents({ workspace: ws })).map((c) => [
|
|
c.channel_id,
|
|
c.agent_path
|
|
])
|
|
)
|
|
const all: Choice[] = []
|
|
let cursor: string | undefined = undefined
|
|
// ponytail: stops at 10 pages of 200 channels; search on the server if a team has more.
|
|
for (let page = 0; page < 10; page++) {
|
|
const res = await SlackAgentsService.listAvailableSlackChannels({ workspace: ws, cursor })
|
|
for (const c of res.channels) {
|
|
const current = taken.get(c.id)
|
|
if (current === agentPath) continue
|
|
all.push({
|
|
value: c.id,
|
|
name: c.name,
|
|
label: current ? `#${c.name} (replaces ${current})` : `#${c.name}`
|
|
})
|
|
}
|
|
cursor = res.next_cursor
|
|
if (!cursor) break
|
|
}
|
|
return all
|
|
} catch (e) {
|
|
sendUserToast(`Couldn't list the Slack channels: ${e.body ?? e.message}`, true)
|
|
return []
|
|
}
|
|
}
|
|
|
|
async function add() {
|
|
const choice = available.current?.find((c) => c.value === picked)
|
|
if (!choice) return
|
|
saving = true
|
|
try {
|
|
await SlackAgentsService.setSlackChannelAgent({
|
|
workspace,
|
|
requestBody: { channel_id: choice.value, channel_name: choice.name, agent_path: agentPath }
|
|
})
|
|
picked = undefined
|
|
onChanged()
|
|
} catch (e) {
|
|
sendUserToast(e.body ?? e.message, true)
|
|
} finally {
|
|
saving = false
|
|
}
|
|
}
|
|
|
|
async function remove(channel: SlackChannelAgent) {
|
|
try {
|
|
await SlackAgentsService.removeSlackChannelAgent({
|
|
workspace,
|
|
channelId: channel.channel_id
|
|
})
|
|
onChanged()
|
|
} catch (e) {
|
|
sendUserToast(e.body ?? e.message, true)
|
|
}
|
|
}
|
|
|
|
let userSettings: UserSettings | undefined = $state(undefined)
|
|
let token = $state('')
|
|
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 apiTab = $state<string | undefined>(undefined)
|
|
let shownApiTab = $derived(
|
|
apiTab === 'sdk' && !chat ? 'rest' : (apiTab ?? (chat ? 'sdk' : 'rest'))
|
|
)
|
|
</script>
|
|
|
|
<UserSettings
|
|
bind:this={userSettings}
|
|
on:tokenCreated={(e) => (token = e.detail)}
|
|
newTokenWorkspace={workspace}
|
|
newTokenLabel={`agent-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
|
|
scopes={[`jobs:run:agents:${agentPath}`]}
|
|
/>
|
|
|
|
<div class="flex flex-col h-full">
|
|
<Tabs bind:selected={() => tab, (t) => (pickedTab = t)} wrapperClass="flex-none w-full">
|
|
{#if chat}
|
|
<Tab value="slack" label="Slack" />
|
|
{/if}
|
|
<Tab value="api" label="API" />
|
|
|
|
{#snippet content()}
|
|
<div class="min-h-0 grow overflow-y-auto">
|
|
<TabContent value="slack" class="flex flex-col gap-6 p-4">
|
|
{#if slackLoading}
|
|
<span class="text-xs text-hint">Loading…</span>
|
|
{:else if !slackTeamName}
|
|
<span class="text-xs text-secondary">
|
|
Connect Slack to the workspace, with AI agents enabled, to ask this agent from Slack,
|
|
by its name or as a channel's default.
|
|
</span>
|
|
{#if isAdmin}
|
|
<div>
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="md"
|
|
href="{base}/workspace_settings?tab=slack&agents=true"
|
|
>
|
|
Connect Slack
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
<span class="text-2xs text-hint">A workspace admin can connect it.</span>
|
|
{/if}
|
|
{:else}
|
|
{#if !signingSecretSet}
|
|
<Alert type="warning" title="Agents can't answer in Slack yet" size="xs">
|
|
The instance needs the Slack app's signing secret, so Windmill can tell who sent a
|
|
message. A superadmin sets it in the instance settings, under OAuth, Slack.
|
|
</Alert>
|
|
{/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.
|
|
{#if isAdmin}
|
|
<a href="{base}/workspace_settings?tab=slack&agents=true">Reconnect Slack</a> to fix
|
|
it.
|
|
{:else}
|
|
A workspace admin can reconnect Slack to fix it.
|
|
{/if}
|
|
</Alert>
|
|
{/if}
|
|
<div class="flex flex-col gap-1">
|
|
<span class="text-xs font-semibold text-emphasis">Ask it anywhere</span>
|
|
<span class="text-xs text-secondary">
|
|
In {slackTeamName}, mention the bot and start your message with the agent's name, in
|
|
a channel or in a direct message. Follow-ups in the same thread that mention the bot
|
|
go to the same agent.
|
|
</span>
|
|
<div class="flex items-center gap-2 mt-1">
|
|
<code class="text-xs bg-surface-secondary rounded px-2 py-1"
|
|
>{botName ? `@${botName} ` : ''}{handle} your question</code
|
|
>
|
|
<CopyButton value={handle} title="Copy {handle}" />
|
|
<Tooltip>
|
|
{handle} matches the last part of the agent's path. Use the whole path,
|
|
<code>+{agentPath}</code>, when two agents share that name.
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<span class="text-xs font-semibold text-emphasis">Default in these channels</span>
|
|
<span class="text-xs text-secondary">
|
|
Mentions of the bot in these channels go to this agent without its name.
|
|
</span>
|
|
{#if channels.length === 0}
|
|
<span class="text-xs text-hint">No channel yet.</span>
|
|
{:else}
|
|
<div class="flex flex-col gap-1">
|
|
{#each channels as channel (channel.channel_id)}
|
|
<div class="flex items-center justify-between rounded border px-2 py-1">
|
|
<span class="text-xs text-primary">#{channel.channel_name}</span>
|
|
{#if isAdmin}
|
|
<Button
|
|
variant="subtle"
|
|
unifiedSize="xs"
|
|
iconOnly
|
|
startIcon={{ icon: X }}
|
|
title="Remove #{channel.channel_name}"
|
|
onClick={() => remove(channel)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if isAdmin}
|
|
<div class="flex items-center gap-2">
|
|
<Select
|
|
class="grow"
|
|
items={available.current ?? []}
|
|
bind:value={picked}
|
|
loading={available.loading}
|
|
placeholder="Pick a public channel"
|
|
noItemsMsg="No other public channel"
|
|
/>
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="md"
|
|
disabled={!picked || saving}
|
|
loading={saving}
|
|
onClick={add}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
<span class="text-2xs text-hint">Only workspace admins can change these.</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<Alert type="info" title="Each person runs the agent as themselves" size="xs">
|
|
Their Slack email must belong to a member of this workspace, and the agent can use
|
|
only what that member can.
|
|
</Alert>
|
|
{/if}
|
|
</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, 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 class="flex items-center gap-4 rounded-md border p-3">
|
|
<div class="flex flex-col gap-1 grow">
|
|
<span class="text-xs font-semibold text-emphasis">Integrate with an AI assistant</span
|
|
>
|
|
<span class="text-2xs text-secondary">
|
|
A prompt with the endpoints and streaming{chat
|
|
? ', conversations and the chat SDK'
|
|
: ''}, for your coding assistant to write the integration. The token stays a
|
|
placeholder.
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="accent-secondary"
|
|
unifiedSize="sm"
|
|
startIcon={{ icon: Sparkles }}
|
|
onClick={() => copyToClipboard(integrationPrompt(target))}
|
|
>
|
|
Copy prompt
|
|
</Button>
|
|
</div>
|
|
<Label label="Token">
|
|
<div class="flex flex-col gap-2">
|
|
<TextInput
|
|
bind:value={token}
|
|
inputProps={{ placeholder: 'Paste your token here once created' }}
|
|
class="!text-xs !font-normal"
|
|
/>
|
|
<div>
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="sm"
|
|
onClick={() => userSettings?.openDrawer()}
|
|
>
|
|
Create an agent-specific token
|
|
<Tooltip light>
|
|
The token can only run this agent. It is safe to share as it cannot be used to
|
|
impersonate you.
|
|
</Tooltip>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Label>
|
|
<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="sdk" class="flex flex-col gap-2 mt-2">
|
|
<span class="text-xs text-secondary">
|
|
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={curlExample(target)} language={bash} />
|
|
</TabContent>
|
|
<TabContent value="fetch" class="mt-2">
|
|
<CopyableCodeBlock code={fetchExample(target)} language={typescript} />
|
|
</TabContent>
|
|
{/snippet}
|
|
</Tabs>
|
|
</TabContent>
|
|
</div>
|
|
{/snippet}
|
|
</Tabs>
|
|
</div>
|