mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: let AI agents answer in Slack
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8f28a58605
commit
82cd933ad4
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT username, email FROM usr\n WHERE workspace_id = $1 AND lower(email) = lower($2) AND NOT disabled",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "34db62cf16e12ab9720e724b1f4b0031934f2b04f03162254f4f6df30e393085"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT result AS \"result: sqlx::types::Json<serde_json::Value>\",\n status = 'success' AS \"success!\"\n FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "result: sqlx::types::Json<serde_json::Value>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "success!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "4e14b95c57c6f987f020376f76ad4dd0ff0d043b3eff62863c8f8db89ad34a4e"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path FROM resource\n WHERE workspace_id = $1 AND resource_type = 'ai_agent'\n AND (path = $2 OR right(path, length($2) + 1) = '/' || $2)\n ORDER BY path LIMIT 6",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "53736152fcb882b62cb1c117b61b031ca6b839023dbaa18dc601927962e6cd57"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM slack_channel_agent WHERE workspace_id = $1 AND channel_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "538001cb77b4fe44269bff8da944b281961ac0dee681d93b8ec84d41a083b004"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path FROM resource WHERE workspace_id = $1 AND resource_type = 'ai_agent'\n ORDER BY path LIMIT 11",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "62cf55f694fcf8f87ecf037cc3850e005838e5d02067460335a9ba02ac9b1898"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO slack_channel_agent\n (slack_team_id, channel_id, channel_name, workspace_id, agent_path)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (slack_team_id, channel_id) DO UPDATE\n SET channel_name = EXCLUDED.channel_name, agent_path = EXCLUDED.agent_path\n WHERE slack_channel_agent.workspace_id = EXCLUDED.workspace_id\n RETURNING channel_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "channel_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7c13828708fd11748db936bd8b8219ecdcd3fa38e1f3bc33b6f5c93a4d6fd58b"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT channel_id, channel_name, agent_path FROM slack_channel_agent\n WHERE workspace_id = $1 AND agent_path = $2 ORDER BY channel_name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "channel_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "channel_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "80ef811675f39e6ab14071c5370dd9fde961177a086a931ecb4c6bb72cf70120"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT channel_id, channel_name, agent_path FROM slack_channel_agent\n WHERE workspace_id = $1 ORDER BY channel_name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "channel_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "channel_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "agent_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8133bd7a4f8d7e81ff3420b4c7784192f5f8f5d2bfa4581e1075378f061982a8"
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, agent_path FROM slack_thread_agent\n WHERE slack_team_id = $1 AND channel_id = $2 AND thread_ts = $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "agent_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "94cdc3cb0127c53d3fd255bc6b11879bb510b4e91fed87a875d1930082f94577"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM slack_channel_agent WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a4cc1e5add2876756c464e448a2b47323a96f46102be72dcc72ae6bf8bab89f5"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, agent_path FROM slack_channel_agent\n WHERE slack_team_id = $1 AND channel_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "agent_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b57ef57031250f3ae74965f081cf99e9acee76a0e55522e2ee128fe75bfb4170"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO slack_thread_agent (slack_team_id, channel_id, thread_ts, workspace_id, agent_path)\n VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c9a8aca5b40f9393ccab04edd10cb507b5e8dc0cca2ddd424e09d784e3e22be5"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM workspace_settings\n WHERE slack_team_id = $1 AND slack_command_script IS NOT NULL)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "de3f99442e6f95013089f4c764b5981af2dd4c3ded4b1e167456e819fa0234eb"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM resource\n WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e9397e8f75e0108ab208de40d7c0fd674b457415389d5d4ef90472badc93dd35"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COALESCE(slack_name, slack_team_id) FROM workspace_settings\n WHERE workspace_id = $1 AND slack_team_id IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "coalesce",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f14b48a16ca770a062043a034a9d174c959438b5c6886935a2895879dbf72e3e"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "slack_team_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f4726ac9311b2206088e5cfce32b47532839f91d2ecc462a5971f8de2194a9bf"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, slack_command_script IS NOT NULL AS \"has_script!\"\n FROM workspace_settings WHERE slack_team_id = $1\n ORDER BY slack_command_script IS NOT NULL DESC LIMIT 2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "has_script!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f91c3e12549a3e037e8e784ad6ed439d762d17acb70793e9b26454572a156397"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
ab8476e5feedc98b727f7d83acffa1807b3b9c5b
|
||||
c65ce0d02a906acf9c76dbe1c785e82a1cb65a46
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS slack_thread_agent;
|
||||
DROP TABLE IF EXISTS slack_channel_agent;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- The agent that answers in a Slack channel when a message names none. Keyed by the Slack team
|
||||
-- rather than the workspace: one channel has one default, whichever workspace set it. The agent
|
||||
-- key cascades, so a renamed agent keeps its channels and a deleted one releases them.
|
||||
CREATE TABLE slack_channel_agent (
|
||||
slack_team_id VARCHAR(255) NOT NULL,
|
||||
channel_id VARCHAR(255) NOT NULL,
|
||||
channel_name VARCHAR(255) NOT NULL,
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
agent_path VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (slack_team_id, channel_id),
|
||||
FOREIGN KEY (workspace_id, agent_path) REFERENCES resource (workspace_id, path)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX index_slack_channel_agent_workspace ON slack_channel_agent (workspace_id, agent_path);
|
||||
|
||||
-- The agent a Slack thread was started with, so a follow-up that names no agent goes to it.
|
||||
CREATE TABLE slack_thread_agent (
|
||||
slack_team_id VARCHAR(255) NOT NULL,
|
||||
channel_id VARCHAR(255) NOT NULL,
|
||||
thread_ts VARCHAR(255) NOT NULL,
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
agent_path VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (slack_team_id, channel_id, thread_ts),
|
||||
FOREIGN KEY (workspace_id, agent_path) REFERENCES resource (workspace_id, path)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
GRANT ALL ON slack_channel_agent TO windmill_admin;
|
||||
GRANT ALL ON slack_channel_agent TO windmill_user;
|
||||
GRANT ALL ON slack_thread_agent TO windmill_admin;
|
||||
GRANT ALL ON slack_thread_agent TO windmill_user;
|
||||
@@ -186,6 +186,10 @@ schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedu
|
||||
script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text), job_token_scopes(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
skip_workspace_diff_tally: workspace_id(char), added_at(ts)
|
||||
slack_channel_agent: slack_team_id(char), channel_id(char), channel_name(char), workspace_id(char), agent_path(char)
|
||||
FK: (workspace_id, agent_path) -> resource(workspace_id, path)
|
||||
slack_thread_agent: slack_team_id(char), channel_id(char), thread_ts(char), workspace_id(char), agent_path(char), created_at(timestamptz)
|
||||
FK: (workspace_id, agent_path) -> resource(workspace_id, path)
|
||||
sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
trashbin: id(bigint), workspace_id(char), item_kind(char), item_path(char), item_data(jsonb), deleted_by(char), deleted_at(ts), expires_at(ts)
|
||||
|
||||
@@ -13351,6 +13351,130 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/FlowConversation"
|
||||
|
||||
/w/{workspace}/slack_agents/channels:
|
||||
get:
|
||||
summary: list the Slack channels whose default agent this workspace set
|
||||
operationId: listSlackChannelAgents
|
||||
tags:
|
||||
- slack_agents
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: channels and their default agent
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SlackChannelAgent"
|
||||
post:
|
||||
summary: make an agent the default in a Slack channel (admin only)
|
||||
operationId: setSlackChannelAgent
|
||||
tags:
|
||||
- slack_agents
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SlackChannelAgent"
|
||||
responses:
|
||||
"200":
|
||||
description: default agent set
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/slack_agents/channels/{channel_id}:
|
||||
delete:
|
||||
summary: remove a Slack channel's default agent (admin only)
|
||||
operationId: removeSlackChannelAgent
|
||||
tags:
|
||||
- slack_agents
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: channel_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: default agent removed
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/slack_agents/available_channels:
|
||||
get:
|
||||
summary: list the connected Slack workspace's public channels (admin only)
|
||||
operationId: listAvailableSlackChannels
|
||||
tags:
|
||||
- slack_agents
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: cursor
|
||||
description: the next_cursor of the previous page
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: a page of public channels
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
channels:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
next_cursor:
|
||||
type: string
|
||||
required:
|
||||
- channels
|
||||
|
||||
/w/{workspace}/slack_agents/agent_channels/{path}:
|
||||
get:
|
||||
summary: the Slack channels where an agent is the default, and whether Slack is connected
|
||||
operationId: getAgentSlackChannels
|
||||
tags:
|
||||
- slack_agents
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: the agent's Slack channels
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
slack_team_name:
|
||||
type: string
|
||||
description: the connected Slack workspace; absent when Slack is not connected
|
||||
channels:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SlackChannelAgent"
|
||||
required:
|
||||
- channels
|
||||
|
||||
/w/{workspace}/flow_conversations/update/{conversation_id}:
|
||||
post:
|
||||
summary: rename flow conversation
|
||||
@@ -29355,6 +29479,20 @@ components:
|
||||
definition_changed:
|
||||
type: boolean
|
||||
|
||||
SlackChannelAgent:
|
||||
type: object
|
||||
properties:
|
||||
channel_id:
|
||||
type: string
|
||||
channel_name:
|
||||
type: string
|
||||
agent_path:
|
||||
type: string
|
||||
required:
|
||||
- channel_id
|
||||
- channel_name
|
||||
- agent_path
|
||||
|
||||
FlowConversation:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -48,8 +48,22 @@ pub(crate) async fn run_agent(
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("jobs:run:agents:{path}"))?;
|
||||
|
||||
let uuid = run_agent_as(&authed, &db, &user_db, &w_id, path, &run_query, &args).await?;
|
||||
Ok((StatusCode::CREATED, uuid.to_string()))
|
||||
}
|
||||
|
||||
/// [`run_agent`] for a caller already authenticated, such as a Slack user matched to a member.
|
||||
pub(crate) async fn run_agent_as(
|
||||
authed: &ApiAuthed,
|
||||
db: &DB,
|
||||
user_db: &UserDB,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
run_query: &RunJobQuery,
|
||||
args: &HashMap<String, Box<RawValue>>,
|
||||
) -> Result<uuid::Uuid> {
|
||||
// Read through the caller's own permissions: reading the agent is what allows running it.
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let value = sqlx::query_scalar!(
|
||||
"SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\"
|
||||
FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
|
||||
@@ -84,17 +98,17 @@ pub(crate) async fn run_agent(
|
||||
)?;
|
||||
|
||||
let push_authed = authed.clone().into();
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
db,
|
||||
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
|
||||
&w_id,
|
||||
w_id,
|
||||
JobPayload::RawFlow {
|
||||
value: flow_value,
|
||||
path: Some(run_path.clone()),
|
||||
restarted_from: None,
|
||||
},
|
||||
PushArgs::from(&args),
|
||||
PushArgs::from(args),
|
||||
authed.display_username(),
|
||||
&authed.email,
|
||||
username_to_permissioned_as(&authed.username),
|
||||
@@ -123,27 +137,27 @@ pub(crate) async fn run_agent(
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(memory_id) = run_query.memory_key(&w_id, &run_path) {
|
||||
if let Some(memory_id) = run_query.memory_key(w_id, &run_path) {
|
||||
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
|
||||
}
|
||||
if chat {
|
||||
// A real conversation, not a test one: this is the agent as deployed, not a draft.
|
||||
handle_chat_conversation_messages(
|
||||
&mut tx,
|
||||
&authed,
|
||||
&w_id,
|
||||
authed,
|
||||
w_id,
|
||||
&run_path,
|
||||
&run_query,
|
||||
run_query,
|
||||
args.get("user_message"),
|
||||
uuid,
|
||||
false,
|
||||
&args,
|
||||
args,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((StatusCode::CREATED, uuid.to_string()))
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
/// The agent as a one-step flow, validated by deserializing through `FlowValue` rather than
|
||||
|
||||
@@ -161,6 +161,9 @@ mod scim_oss;
|
||||
mod scripts;
|
||||
mod secret_backend_ext;
|
||||
mod service_logs;
|
||||
// Answering in Slack is only reached from the EE Slack event handlers.
|
||||
#[cfg_attr(not(all(feature = "oauth2", feature = "private")), allow(dead_code))]
|
||||
mod slack_agents;
|
||||
mod slack_approvals;
|
||||
#[cfg(all(feature = "smtp", feature = "private"))]
|
||||
pub mod smtp_server_ee;
|
||||
@@ -251,7 +254,7 @@ lazy_static::lazy_static! {
|
||||
// Must be a subset of the bot scopes declared in the Slack app manifest. Default matches
|
||||
// Windmill's recommended manifest at docs.windmill.dev/docs/misc/setup_oauth.
|
||||
pub static ref SLACK_OAUTH_SCOPES: String = std::env::var("SLACK_OAUTH_SCOPES")
|
||||
.unwrap_or_else(|_| "commands,chat:write,chat:write.public,channels:join,files:write,app_mentions:read,im:history,im:read".to_string());
|
||||
.unwrap_or_else(|_| "commands,chat:write,chat:write.public,channels:join,files:write,app_mentions:read,im:history,im:read,users:read,users:read.email,channels:read".to_string());
|
||||
}
|
||||
|
||||
// Compliance with cloud events spec.
|
||||
@@ -678,6 +681,7 @@ pub async fn run_server(
|
||||
.nest("/embeddings", embeddings::workspaced_service())
|
||||
.nest("/favorites", favorite::workspaced_service())
|
||||
.nest("/flows", flows::workspaced_service())
|
||||
.nest("/slack_agents", slack_agents::workspaced_service())
|
||||
.nest("/runnables", runnables::workspaced_service())
|
||||
.nest(
|
||||
"/workspace_dependencies",
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
//! AI agents answering in Slack.
|
||||
//!
|
||||
//! A message reaches an agent when it names one (`+name` or `~name`), when its thread was started
|
||||
//! with one, or when its channel has a default agent. Anything else goes to the workspace's
|
||||
//! command script as before, or, where there is none, is answered with how to reach an agent.
|
||||
//! The agent runs as the workspace member whose email the Slack user
|
||||
//! has, so a Slack user who is not a member gets nothing, and what the agent may read or call is
|
||||
//! that member's. One Slack thread is one conversation per person: a run's tool results are its
|
||||
//! runner's to see, and a shared conversation would hand them to whoever replies next.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
routing::{delete, get},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH,
|
||||
utils::{require_admin, StripPath},
|
||||
variables::get_secret_value_as_admin,
|
||||
BASE_URL,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
agent_runs::run_agent_as,
|
||||
db::{ApiAuthed, DB},
|
||||
jobs::RunJobQuery,
|
||||
users::fetch_api_authed,
|
||||
HTTP_CLIENT,
|
||||
};
|
||||
|
||||
/// How long the reply waits for the agent before pointing at the run instead.
|
||||
const MAX_WAIT_SECS: u64 = 3600;
|
||||
/// Under the 12,000 characters Slack takes in a markdown block.
|
||||
const MAX_REPLY_CHARS: usize = 11_000;
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/channels",
|
||||
get(list_channel_agents).post(set_channel_agent),
|
||||
)
|
||||
.route("/channels/{channel_id}", delete(remove_channel_agent))
|
||||
.route("/available_channels", get(list_available_channels))
|
||||
.route("/agent_channels/{*path}", get(list_agent_channels))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChannelAgent {
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
agent_path: String,
|
||||
}
|
||||
|
||||
async fn list_channel_agents(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<ChannelAgent>> {
|
||||
let rows = sqlx::query_as!(
|
||||
ChannelAgent,
|
||||
"SELECT channel_id, channel_name, agent_path FROM slack_channel_agent
|
||||
WHERE workspace_id = $1 ORDER BY channel_name",
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AgentSlack {
|
||||
/// The connected Slack workspace's name; `None` when Slack is not connected.
|
||||
slack_team_name: Option<String>,
|
||||
channels: Vec<ChannelAgent>,
|
||||
}
|
||||
|
||||
async fn list_agent_channels(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> JsonResult<AgentSlack> {
|
||||
let slack_team_name = sqlx::query_scalar!(
|
||||
"SELECT COALESCE(slack_name, slack_team_id) FROM workspace_settings
|
||||
WHERE workspace_id = $1 AND slack_team_id IS NOT NULL",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
let channels = sqlx::query_as!(
|
||||
ChannelAgent,
|
||||
"SELECT channel_id, channel_name, agent_path FROM slack_channel_agent
|
||||
WHERE workspace_id = $1 AND agent_path = $2 ORDER BY channel_name",
|
||||
w_id,
|
||||
path.to_path()
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
Ok(Json(AgentSlack { slack_team_name, channels }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetChannelAgent {
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
agent_path: String,
|
||||
}
|
||||
|
||||
async fn set_channel_agent(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(body): Json<SetChannelAgent>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let team_id = connected_team(&db, &w_id).await?;
|
||||
|
||||
let is_agent = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM resource
|
||||
WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent')",
|
||||
w_id,
|
||||
body.agent_path
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !is_agent {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{} is not an agent",
|
||||
body.agent_path
|
||||
)));
|
||||
}
|
||||
|
||||
// A channel belongs to the Slack team, which several workspaces may share: one workspace
|
||||
// must not take over a channel another one set.
|
||||
let taken = sqlx::query!(
|
||||
"INSERT INTO slack_channel_agent
|
||||
(slack_team_id, channel_id, channel_name, workspace_id, agent_path)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (slack_team_id, channel_id) DO UPDATE
|
||||
SET channel_name = EXCLUDED.channel_name, agent_path = EXCLUDED.agent_path
|
||||
WHERE slack_channel_agent.workspace_id = EXCLUDED.workspace_id
|
||||
RETURNING channel_id",
|
||||
team_id,
|
||||
body.channel_id,
|
||||
body.channel_name,
|
||||
w_id,
|
||||
body.agent_path
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.is_none();
|
||||
if taken {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"#{} already has a default agent set from another workspace",
|
||||
body.channel_name
|
||||
)));
|
||||
}
|
||||
Ok(format!(
|
||||
"{} answers in #{}",
|
||||
body.agent_path, body.channel_name
|
||||
))
|
||||
}
|
||||
|
||||
async fn remove_channel_agent(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, channel_id)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM slack_channel_agent WHERE workspace_id = $1 AND channel_id = $2",
|
||||
w_id,
|
||||
channel_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
Ok("Removed".to_string())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SlackChannel {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChannelsQuery {
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SlackChannels {
|
||||
channels: Vec<SlackChannel>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// The team's public channels, a page at a time, for picking one.
|
||||
async fn list_available_channels(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(q): Query<ChannelsQuery>,
|
||||
) -> JsonResult<SlackChannels> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let token = bot_token(&db, &w_id).await?;
|
||||
let mut form = vec![
|
||||
("types", "public_channel".to_string()),
|
||||
("exclude_archived", "true".to_string()),
|
||||
("limit", "200".to_string()),
|
||||
];
|
||||
if let Some(cursor) = q.cursor.filter(|c| !c.is_empty()) {
|
||||
form.push(("cursor", cursor));
|
||||
}
|
||||
let res = slack_call(&token, "conversations.list", &form)
|
||||
.await
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
let channels = res["channels"]
|
||||
.as_array()
|
||||
.map(|cs| {
|
||||
cs.iter()
|
||||
.filter_map(|c| {
|
||||
Some(SlackChannel {
|
||||
id: c["id"].as_str()?.to_string(),
|
||||
name: c["name"].as_str()?.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let next_cursor = res["response_metadata"]["next_cursor"]
|
||||
.as_str()
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(str::to_string);
|
||||
Ok(Json(SlackChannels { channels, next_cursor }))
|
||||
}
|
||||
|
||||
async fn connected_team(db: &DB, w_id: &str) -> Result<String> {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten()
|
||||
.ok_or_else(|| Error::BadRequest("Slack is not connected to this workspace".to_string()))
|
||||
}
|
||||
|
||||
async fn bot_token(db: &DB, w_id: &str) -> Result<String> {
|
||||
get_secret_value_as_admin(db, w_id, WORKSPACE_SLACK_BOT_TOKEN_PATH).await
|
||||
}
|
||||
|
||||
/// A Slack Web API call, form-encoded since read methods such as `users.info` take no JSON.
|
||||
/// Slack answers 200 with `ok: false` on failure; that is returned as the error.
|
||||
async fn slack_call(
|
||||
token: &str,
|
||||
method: &str,
|
||||
form: &[(&str, String)],
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let res: serde_json::Value = HTTP_CLIENT
|
||||
.post(format!("https://slack.com/api/{method}"))
|
||||
.bearer_auth(token)
|
||||
.form(form)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
if res["ok"].as_bool() != Some(true) {
|
||||
let error = res["error"].as_str().unwrap_or("unknown_error");
|
||||
let needed = res["needed"].as_str().unwrap_or_default();
|
||||
anyhow::bail!("{method}: {error} {needed}");
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// A message addressed to Windmill, from a mention, a direct message or `/windmill`.
|
||||
pub struct SlackMessage {
|
||||
pub team_id: String,
|
||||
pub channel_id: String,
|
||||
pub user_id: String,
|
||||
/// With the bot mention stripped.
|
||||
pub text: String,
|
||||
/// The message's own ts; `None` for a slash command, which has no message to reply under.
|
||||
pub ts: Option<String>,
|
||||
pub thread_ts: Option<String>,
|
||||
/// Slack resent the event because the first delivery was not acknowledged in time.
|
||||
pub is_retry: bool,
|
||||
}
|
||||
|
||||
enum Route {
|
||||
Named(String),
|
||||
Agent(String),
|
||||
/// Nothing answers here: say how to reach an agent.
|
||||
Help,
|
||||
}
|
||||
|
||||
/// Whether an agent answers this message; if so, the answer is under way when this returns.
|
||||
/// `false` leaves the message to the workspace's command script.
|
||||
pub async fn try_answer(db: &DB, msg: SlackMessage) -> Result<bool> {
|
||||
let text = decode_entities(&msg.text);
|
||||
let (name, question) = split_agent_name(&text);
|
||||
let thread_root = msg.thread_ts.clone().or_else(|| msg.ts.clone());
|
||||
|
||||
let (w_id, route, source) = if let Some(name) = name {
|
||||
let Some(w_id) = workspace_for_team(db, &msg.team_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
(w_id, Route::Named(name.to_string()), "mention")
|
||||
} else if let Some(r) = match &thread_root {
|
||||
Some(root) => sqlx::query!(
|
||||
"SELECT workspace_id, agent_path FROM slack_thread_agent
|
||||
WHERE slack_team_id = $1 AND channel_id = $2 AND thread_ts = $3",
|
||||
msg.team_id,
|
||||
msg.channel_id,
|
||||
root
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.map(|r| (r.workspace_id, r.agent_path)),
|
||||
None => None,
|
||||
} {
|
||||
(r.0, Route::Agent(r.1), "thread")
|
||||
} else if let Some(r) = sqlx::query!(
|
||||
"SELECT workspace_id, agent_path FROM slack_channel_agent
|
||||
WHERE slack_team_id = $1 AND channel_id = $2",
|
||||
msg.team_id,
|
||||
msg.channel_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
{
|
||||
(
|
||||
r.workspace_id,
|
||||
Route::Agent(r.agent_path),
|
||||
"channel_default",
|
||||
)
|
||||
} else if let Some(w_id) = workspace_without_command_script(db, &msg.team_id).await? {
|
||||
(w_id, Route::Help, "help")
|
||||
} else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
// The first delivery is already being answered.
|
||||
if msg.is_retry {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if !matches!(route, Route::Help) {
|
||||
windmill_common::feature_usage::log_feature_usage("slack_agent", "invoked", source);
|
||||
}
|
||||
let db = db.clone();
|
||||
let question = question.to_string();
|
||||
// ponytail: the reply waits in this API process, so a restart mid-run loses the reply (not
|
||||
// the run, which still shows in Windmill); a job-completion hook would make it durable.
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = answer(&db, &w_id, route, &question, msg).await {
|
||||
tracing::error!("Slack agent answer failed: {e:#}");
|
||||
}
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `+name rest` or `~name rest` → (name, rest).
|
||||
fn split_agent_name(text: &str) -> (Option<&str>, &str) {
|
||||
let text = text.trim();
|
||||
let Some(after) = text.strip_prefix('+').or_else(|| text.strip_prefix('~')) else {
|
||||
return (None, text);
|
||||
};
|
||||
let (name, rest) = after.split_once(char::is_whitespace).unwrap_or((after, ""));
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '/' | '.'))
|
||||
{
|
||||
return (None, text);
|
||||
}
|
||||
(Some(name), rest.trim())
|
||||
}
|
||||
|
||||
/// Slack escapes these three in message text.
|
||||
fn decode_entities(text: &str) -> String {
|
||||
text.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
/// The workspace a message naming an agent goes to when neither its thread nor its channel says:
|
||||
/// the one whose command script the team's messages already reach, else the team's only one.
|
||||
async fn workspace_for_team(db: &DB, team_id: &str) -> Result<Option<String>> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT workspace_id, slack_command_script IS NOT NULL AS \"has_script!\"
|
||||
FROM workspace_settings WHERE slack_team_id = $1
|
||||
ORDER BY slack_command_script IS NOT NULL DESC LIMIT 2",
|
||||
team_id
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
Ok(match rows.as_slice() {
|
||||
[only] => Some(only.workspace_id.clone()),
|
||||
[first, ..] if first.has_script => Some(first.workspace_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The team's workspace when no workspace of the team has a command script to take the message.
|
||||
async fn workspace_without_command_script(db: &DB, team_id: &str) -> Result<Option<String>> {
|
||||
let has_script = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_settings
|
||||
WHERE slack_team_id = $1 AND slack_command_script IS NOT NULL)",
|
||||
team_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if has_script {
|
||||
return Ok(None);
|
||||
}
|
||||
workspace_for_team(db, team_id).await
|
||||
}
|
||||
|
||||
/// How to reach an agent, with the ones this member can use.
|
||||
async fn help_text(user_db: &UserDB, authed: &ApiAuthed, w_id: &str) -> Result<String> {
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let agents = sqlx::query_scalar!(
|
||||
"SELECT path FROM resource WHERE workspace_id = $1 AND resource_type = 'ai_agent'
|
||||
ORDER BY path LIMIT 11",
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
let Some(first) = agents.first() else {
|
||||
return Ok(format!(
|
||||
"No agent answers here by default, and there is no agent in the Windmill workspace *{w_id}* you can use yet."
|
||||
));
|
||||
};
|
||||
let handle = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string();
|
||||
let mut list = agents
|
||||
.iter()
|
||||
.take(10)
|
||||
.map(|p| format!("• `+{}` ({p})", handle(p)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if agents.len() > 10 {
|
||||
list.push_str("\n• …");
|
||||
}
|
||||
Ok(format!(
|
||||
"No agent answers here by default. Start your message with an agent's name, for example `+{} what can you do?`\n\nAgents you can use:\n{list}\n\nA workspace admin can make an agent the default for a channel from the agent's page in Windmill.",
|
||||
handle(first)
|
||||
))
|
||||
}
|
||||
|
||||
async fn answer(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
route: Route,
|
||||
question: &str,
|
||||
msg: SlackMessage,
|
||||
) -> anyhow::Result<()> {
|
||||
let token = bot_token(db, w_id).await?;
|
||||
let channel = msg.channel_id.clone();
|
||||
|
||||
// A slash command leaves no message to thread under, so the question is posted first.
|
||||
let thread_root = match msg.thread_ts.clone().or_else(|| msg.ts.clone()) {
|
||||
Some(ts) => ts,
|
||||
None => {
|
||||
let posted = slack_call(
|
||||
&token,
|
||||
"chat.postMessage",
|
||||
&[
|
||||
("channel", channel.clone()),
|
||||
("text", format!("<@{}>: {}", msg.user_id, question)),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
posted["ts"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
};
|
||||
let reply = Reply::start(&token, &channel, &thread_root).await?;
|
||||
|
||||
let authed = match member_for_slack_user(db, &token, w_id, &msg.user_id).await {
|
||||
Ok(Some(authed)) => authed,
|
||||
Ok(None) => {
|
||||
return reply.send(
|
||||
format!(
|
||||
"Your Slack email isn't a member of the Windmill workspace *{w_id}*. Ask an admin to invite you."
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) if e.to_string().contains("missing_scope") => {
|
||||
return reply.send(
|
||||
"Windmill can't read your Slack email. A workspace admin needs to reconnect Slack in the workspace settings to grant it.".to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let user_db = UserDB::new(db.clone());
|
||||
|
||||
let agent_path = match route {
|
||||
Route::Help => {
|
||||
return reply
|
||||
.send(help_text(&user_db, &authed, w_id).await?, None)
|
||||
.await;
|
||||
}
|
||||
Route::Agent(path) => path,
|
||||
Route::Named(name) => {
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let matches = sqlx::query_scalar!(
|
||||
"SELECT path FROM resource
|
||||
WHERE workspace_id = $1 AND resource_type = 'ai_agent'
|
||||
AND (path = $2 OR right(path, length($2) + 1) = '/' || $2)
|
||||
ORDER BY path LIMIT 6",
|
||||
w_id,
|
||||
name
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
match matches.as_slice() {
|
||||
[only] => only.clone(),
|
||||
[] => {
|
||||
return reply
|
||||
.send(
|
||||
format!("I can't find an agent named *{name}* that you can use."),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
several => {
|
||||
let list = several
|
||||
.iter()
|
||||
.map(|p| format!("`+{p}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return reply
|
||||
.send(
|
||||
format!("Several agents are named *{name}*, pick one: {list}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if question.is_empty() {
|
||||
return reply
|
||||
.send(format!("What would you like to ask `{agent_path}`?"), None)
|
||||
.await;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO slack_thread_agent (slack_team_id, channel_id, thread_ts, workspace_id, agent_path)
|
||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
|
||||
msg.team_id,
|
||||
channel,
|
||||
thread_root,
|
||||
w_id,
|
||||
agent_path
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
let run_query = RunJobQuery {
|
||||
memory_id: Some(format!(
|
||||
"slack:{}:{}:{}:{}",
|
||||
msg.team_id, channel, thread_root, msg.user_id
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let args: HashMap<String, Box<RawValue>> = [(
|
||||
"user_message".to_string(),
|
||||
windmill_common::worker::to_raw_value(&question),
|
||||
)]
|
||||
.into();
|
||||
let job_id =
|
||||
match run_agent_as(&authed, db, &user_db, w_id, &agent_path, &run_query, &args).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
return reply
|
||||
.send(format!("`{agent_path}` couldn't start: {e}"), None)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
|
||||
let run_link = format!(
|
||||
"<{}/run/{job_id}?workspace={w_id}|View run> · `{agent_path}`",
|
||||
BASE_URL.load()
|
||||
);
|
||||
let thinking = reply.keep_thinking();
|
||||
let result = wait_for_result(db, w_id, job_id).await;
|
||||
if let Some(thinking) = thinking {
|
||||
thinking.abort();
|
||||
}
|
||||
let text = match result? {
|
||||
Some((result, true)) => answer_text(&result),
|
||||
Some((result, false)) => format!("The agent failed: {}", error_text(&result)),
|
||||
None => "Still working, follow it in the run.".to_string(),
|
||||
};
|
||||
reply.send(text, Some(run_link)).await
|
||||
}
|
||||
|
||||
async fn member_for_slack_user(
|
||||
db: &DB,
|
||||
token: &str,
|
||||
w_id: &str,
|
||||
slack_user: &str,
|
||||
) -> anyhow::Result<Option<ApiAuthed>> {
|
||||
let info = slack_call(token, "users.info", &[("user", slack_user.to_string())]).await?;
|
||||
let Some(email) = info["user"]["profile"]["email"].as_str() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let member = sqlx::query!(
|
||||
"SELECT username, email FROM usr
|
||||
WHERE workspace_id = $1 AND lower(email) = lower($2) AND NOT disabled",
|
||||
w_id,
|
||||
email
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
let Some(member) = member else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(
|
||||
fetch_api_authed(member.username, member.email, w_id, db, None).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn wait_for_result(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
job_id: Uuid,
|
||||
) -> Result<Option<(serde_json::Value, bool)>> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(MAX_WAIT_SECS);
|
||||
while std::time::Instant::now() < deadline {
|
||||
let row = sqlx::query!(
|
||||
"SELECT result AS \"result: sqlx::types::Json<serde_json::Value>\",
|
||||
status = 'success' AS \"success!\"
|
||||
FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
|
||||
job_id,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
if let Some(row) = row {
|
||||
return Ok(Some((
|
||||
row.result.map(|r| r.0).unwrap_or_default(),
|
||||
row.success,
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The agent step's `output`: its text, or a structured output as a JSON block.
|
||||
fn answer_text(result: &serde_json::Value) -> String {
|
||||
match &result["output"] {
|
||||
serde_json::Value::String(s) if !s.trim().is_empty() => s.clone(),
|
||||
serde_json::Value::Null => "The agent returned no answer.".to_string(),
|
||||
other => format!(
|
||||
"```\n{}\n```",
|
||||
serde_json::to_string_pretty(other).unwrap_or_default()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_text(result: &serde_json::Value) -> String {
|
||||
result["error"]["message"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| result.to_string())
|
||||
}
|
||||
|
||||
/// The bot's reply in a thread. While it is being worked out the thread shows the bot as thinking,
|
||||
/// or, where Slack refuses that status, a placeholder message the reply then replaces.
|
||||
#[derive(Clone)]
|
||||
struct Reply {
|
||||
token: String,
|
||||
channel: String,
|
||||
thread_ts: String,
|
||||
placeholder: Option<String>,
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
async fn start(token: &str, channel: &str, thread_ts: &str) -> anyhow::Result<Self> {
|
||||
let mut reply = Reply {
|
||||
token: token.to_string(),
|
||||
channel: channel.to_string(),
|
||||
thread_ts: thread_ts.to_string(),
|
||||
placeholder: None,
|
||||
};
|
||||
if let Err(e) = reply.set_status("is thinking…").await {
|
||||
tracing::debug!("Slack refused the thinking status, posting a placeholder: {e:#}");
|
||||
let posted = slack_call(
|
||||
token,
|
||||
"chat.postMessage",
|
||||
&[
|
||||
("channel", channel.to_string()),
|
||||
("thread_ts", thread_ts.to_string()),
|
||||
("text", "Thinking…".to_string()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
reply.placeholder = posted["ts"].as_str().map(str::to_string);
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
async fn set_status(&self, status: &str) -> anyhow::Result<()> {
|
||||
slack_call(
|
||||
&self.token,
|
||||
"assistant.threads.setStatus",
|
||||
&[
|
||||
("channel_id", self.channel.clone()),
|
||||
("thread_ts", self.thread_ts.clone()),
|
||||
("status", status.to_string()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Slack drops the status after two minutes without a reply, so a long run renews it.
|
||||
fn keep_thinking(&self) -> Option<tokio::task::JoinHandle<()>> {
|
||||
if self.placeholder.is_some() {
|
||||
return None;
|
||||
}
|
||||
let reply = self.clone();
|
||||
Some(tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(90)).await;
|
||||
let _ = reply.set_status("is thinking…").await;
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn send(&self, text: String, footer: Option<String>) -> anyhow::Result<()> {
|
||||
let (text, blocks) = reply_blocks(&text, footer)?;
|
||||
let mut form = vec![
|
||||
("channel", self.channel.clone()),
|
||||
("text", text),
|
||||
("blocks", blocks),
|
||||
];
|
||||
match &self.placeholder {
|
||||
Some(ts) => {
|
||||
form.push(("ts", ts.clone()));
|
||||
slack_call(&self.token, "chat.update", &form).await?;
|
||||
}
|
||||
None => {
|
||||
form.push(("thread_ts", self.thread_ts.clone()));
|
||||
slack_call(&self.token, "chat.postMessage", &form).await?;
|
||||
let _ = self.set_status("").await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply's notification text and its blocks: the answer as markdown, and an optional footer.
|
||||
fn reply_blocks(text: &str, footer: Option<String>) -> anyhow::Result<(String, String)> {
|
||||
let text = if text.chars().count() > MAX_REPLY_CHARS {
|
||||
let cut: String = text.chars().take(MAX_REPLY_CHARS).collect();
|
||||
format!("{cut}\n\n_The answer is cut short here; the run has all of it._")
|
||||
} else {
|
||||
text.to_string()
|
||||
};
|
||||
let mut blocks = vec![serde_json::json!({ "type": "markdown", "text": text })];
|
||||
if let Some(footer) = footer {
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "context",
|
||||
"elements": [{ "type": "mrkdwn", "text": footer }]
|
||||
}));
|
||||
}
|
||||
let fallback: String = text.chars().take(300).collect();
|
||||
Ok((fallback, serde_json::to_string(&blocks)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::split_agent_name;
|
||||
|
||||
#[test]
|
||||
fn agent_name_prefix() {
|
||||
assert_eq!(
|
||||
split_agent_name("+sql_analyst how many?"),
|
||||
(Some("sql_analyst"), "how many?")
|
||||
);
|
||||
assert_eq!(
|
||||
split_agent_name("~f/data/sql_analyst hi"),
|
||||
(Some("f/data/sql_analyst"), "hi")
|
||||
);
|
||||
assert_eq!(split_agent_name("+helper"), (Some("helper"), ""));
|
||||
assert_eq!(split_agent_name("what is +x"), (None, "what is +x"));
|
||||
assert_eq!(split_agent_name("+ spaced"), (None, "+ spaced"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
import { shell } from 'svelte-highlight/languages'
|
||||
import { base } from '$lib/base'
|
||||
import { Alert, Button, CopyButton, Tab, TabContent, Tabs } from '$lib/components/common'
|
||||
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import { SlackAgentsService, type SlackChannelAgent } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
/**
|
||||
* 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,
|
||||
slackTeamName,
|
||||
channels,
|
||||
onChanged
|
||||
}: {
|
||||
agentPath: string
|
||||
workspace: string
|
||||
isAdmin: boolean
|
||||
/** Unset when Slack is not connected to the workspace. */
|
||||
slackTeamName: string | undefined
|
||||
channels: SlackChannelAgent[]
|
||||
onChanged: () => void
|
||||
} = $props()
|
||||
|
||||
let tab = $state('slack')
|
||||
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 && slackTeamName ? 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 apiBase = $derived(`${location.origin}${base}/api/w/${workspace}`)
|
||||
let runSnippet = $derived(
|
||||
`curl -X POST "${apiBase}/jobs/run/agent/${agentPath}?memory_id=my-conversation-1" \\
|
||||
-H "Authorization: Bearer $WM_TOKEN" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"user_message": "Hello"}'`
|
||||
)
|
||||
let resultSnippet = $derived(
|
||||
`curl "${apiBase}/jobs_u/completed/get_result_maybe/$JOB_ID" \\
|
||||
-H "Authorization: Bearer $WM_TOKEN"`
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs bind:selected={tab} wrapperClass="flex-none w-full">
|
||||
<Tab value="slack" label="Slack" />
|
||||
<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 !slackTeamName}
|
||||
<span class="text-xs text-secondary">
|
||||
Connect Slack to the workspace 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"
|
||||
>
|
||||
Connect Slack
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-2xs text-hint">A workspace admin can connect it.</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<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 Windmill bot and start your message with the agent's
|
||||
name, in a channel or in a direct message. Follow-ups in the same thread 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"
|
||||
>{handle} your question</code
|
||||
>
|
||||
<CopyButton value={handle} title="Copy {handle}" />
|
||||
</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 Windmill 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">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">Send a message</span>
|
||||
<span class="text-xs text-secondary">
|
||||
Returns the id of the run. Runs that share a <code>memory_id</code> are one conversation,
|
||||
so reuse it for follow-ups and pick a new one to start over. The run is the token owner's,
|
||||
with their permissions.
|
||||
</span>
|
||||
<CopyableCodeBlock code={runSnippet} language={shell} wrap />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">Read the answer</span>
|
||||
<span class="text-xs text-secondary">
|
||||
Poll until <code>completed</code> is true; the answer is the result's
|
||||
<code>output</code>.
|
||||
</span>
|
||||
<CopyableCodeBlock code={resultSnippet} language={shell} wrap />
|
||||
</div>
|
||||
<span class="text-2xs text-hint">
|
||||
Create a token in your <a href="#user-settings">account settings</a>.
|
||||
</span>
|
||||
</TabContent>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { resource } from 'runed'
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import AgentEvalsModal from '$lib/components/flows/content/AgentEvalsModal.svelte'
|
||||
import AgentUsePanel from '$lib/components/flows/content/AgentUsePanel.svelte'
|
||||
import { SlackAgentsService } from '$lib/gen'
|
||||
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
@@ -41,6 +44,13 @@
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
|
||||
let deleteOpen = $state(false)
|
||||
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
|
||||
let clientWidth = $state(window.innerWidth)
|
||||
|
||||
const slack = resource(
|
||||
() => ({ workspace: ws, path }),
|
||||
async ({ workspace, path }) =>
|
||||
workspace ? await SlackAgentsService.getAgentSlackChannels({ workspace, path }) : undefined
|
||||
)
|
||||
|
||||
async function remove() {
|
||||
if (ws && (await deleteAgent(ws, path))) await goto(`${base}/?kind=agent`)
|
||||
@@ -169,29 +179,47 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DetailPageHeader>
|
||||
<div class="flex-1 min-h-0">
|
||||
{#key `${ws}:${path}:${reloaded}`}
|
||||
<AgentEditorHost
|
||||
bind:this={host}
|
||||
{path}
|
||||
workspace={ws}
|
||||
enableAi={$copilotInfo.enabled}
|
||||
view
|
||||
onOpenConfig={() => configModal?.open()}
|
||||
>
|
||||
{#snippet viewForm({ schema, run, loading, actions })}
|
||||
<RunForm
|
||||
runnable={{ schema, path }}
|
||||
runAction={run}
|
||||
schedulable={false}
|
||||
detailed={false}
|
||||
autofocus
|
||||
{loading}
|
||||
{actions}
|
||||
<div class="flex-1 min-h-0" bind:clientWidth>
|
||||
<Splitpanes>
|
||||
<Pane size={65} minSize={50}>
|
||||
{#key `${ws}:${path}:${reloaded}`}
|
||||
<AgentEditorHost
|
||||
bind:this={host}
|
||||
{path}
|
||||
workspace={ws}
|
||||
enableAi={$copilotInfo.enabled}
|
||||
view
|
||||
onOpenConfig={() => configModal?.open()}
|
||||
>
|
||||
{#snippet viewForm({ schema, run, loading, actions })}
|
||||
<RunForm
|
||||
runnable={{ schema, path }}
|
||||
runAction={run}
|
||||
schedulable={false}
|
||||
detailed={false}
|
||||
autofocus
|
||||
{loading}
|
||||
{actions}
|
||||
/>
|
||||
{/snippet}
|
||||
</AgentEditorHost>
|
||||
{/key}
|
||||
</Pane>
|
||||
<!-- ponytail: no panel below `lg`, where the split would squeeze the chat; give it a tab
|
||||
there, as a script's page does, once people use it from small screens. -->
|
||||
{#if ws && config && clientWidth >= 1024}
|
||||
<Pane size={35} minSize={15}>
|
||||
<AgentUsePanel
|
||||
agentPath={path}
|
||||
workspace={ws}
|
||||
isAdmin={!!($userStore?.is_admin || $userStore?.is_super_admin)}
|
||||
slackTeamName={slack.current?.slack_team_name}
|
||||
channels={slack.current?.channels ?? []}
|
||||
onChanged={() => slack.refetch()}
|
||||
/>
|
||||
{/snippet}
|
||||
</AgentEditorHost>
|
||||
{/key}
|
||||
</Pane>
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user