feat(ai-chat): make reusable skills ai_skill resources you select per workspace (#10914)

* feat(ai-chat): make reusable skills ai_skill resources you select per workspace

* chore: pin the ee ref to the skill telemetry counters

* fix: address review findings on skill authoring, import and migration

* fix: enforce skill selection in read_skill and stop imports clobbering resources

* feat: carry format_extension from the hub into synced resource types

* fix: let an edit set or clear a resource type's format_extension

* fix: regenerate the sqlx cache and close the review round findings

* fix: close the round-2 findings on folder ACLs, cached sync and truncation

* refactor: make the skills migration non-destructive and use design-system inputs

* fix: close the round-4 findings on folder owners, startup sync and truncation

* fix: clear obsolete extensions, guard folder owners, and report skipped skills

* fix: honor explicit-null extensions and report same-type migration conflicts

* fix: scope skill actions to the committed workspace and paginate the listing

* fix: keep the drawer scoped to the live workspace and surface truncation

* fix: discard a skills refresh for a workspace the chat has left

* chore: update ee-repo-ref to 6efe7a73c745c2e1377a34498523c00d89010a3d

This commit updates the EE repository reference after PR #764 was merged in windmill-ee-private.

Previous ee-repo-ref: 55998c142bc72edd08532748af1974b16035658d

New ee-repo-ref: 6efe7a73c745c2e1377a34498523c00d89010a3d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-09-01 12:51:27 +00:00
committed by GitHub
co-authored by windmill-internal-app[bot]
parent 870f67121d
commit cfcfe298dd
44 changed files with 1813 additions and 1920 deletions
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*)::bigint AS \"total!\",\n COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS \"replacing!\"\n FROM ai_skill\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "total!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "replacing!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": [
null,
null
]
},
"hash": "002a606e71364b0581dbc496bf4337f276861dc71d2e277a7aef711543eb14d7"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)\n VALUES ('admins', $1, $2, $3, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "4b93550c7836fd3643180ade3548faa875e471d3f9ca37fc669f359e7a1818bb"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)\n VALUES ($1, $2, $3, $4, now(), $5)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET description = EXCLUDED.description,\n instructions = EXCLUDED.instructions,\n edited_at = now(),\n edited_by = EXCLUDED.edited_by",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "734781e8e55e95c55f72e094e96297aa852e20a0f0d20db4b993947792f6b0a8"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)",
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
"describe": {
"columns": [
{
@@ -13,12 +13,14 @@
"Left": [
"Text",
"Jsonb",
"Text"
"Text",
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "1ea97f9085ec018f779e77e0fdbda3d4ecd67b3fbee9a58228ef577f846607ae"
"hash": "8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)\n VALUES ('admins', $1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n edited_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Jsonb",
"Text",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "c84087a0669d0b71829b0765c7274ca0a03fb823a781fb46d2b2b6cfc535a16b"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT is_fileset, format_extension FROM resource_type\n WHERE name = $1 AND workspace_id = $2 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_fileset",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "format_extension",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "df13e7bb9c14aa19604c40754509f66af26042464ba199586838e073c318c53a"
}
@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "instructions",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "e50afd5156b07e550202fb9b33354dce71b37f89f68d78577b250979daa1a87d"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "e99fe5cd3283f1701d3a361ef31869da89fd10099b76669b9526201c85f71f61"
}
+1 -1
View File
@@ -1 +1 @@
f2a31156ac08ecb02d89dbc66d72be58e9c877ff
6efe7a73c745c2e1377a34498523c00d89010a3d
@@ -0,0 +1,13 @@
-- The up migration only ever added: `ai_skill` still holds every skill it copied,
-- so there is nothing to restore and nothing to delete. Removing the resources
-- would destroy any a user has since edited or created, and removing a folder
-- would take whatever else was put in it.
--
-- The seeded resource type goes. `created_by` only distinguishes this migration's
-- row from one a user created by hand: a hub sync updates the schema in place and
-- leaves `created_by` alone, so a synced-over row is still removed here and the
-- next sync puts it back.
DELETE FROM resource_type
WHERE workspace_id = 'admins'
AND name = 'ai_skill'
AND created_by = 'system';
@@ -0,0 +1,88 @@
-- AI chat skills move from the `ai_skill` table onto ordinary resources, so they
-- gain folder ACLs, version history, workspace export and git-sync. An `ai_skill`
-- resource holds the SKILL.md body in `value.content`; its description lives in
-- the resource's own `description` column and its name is the path basename.
--
-- Nothing here is destructive. `ai_skill` is left in place, unread, for a later
-- release to drop once operators have confirmed the copy. That is what lets every
-- step below skip on conflict rather than resolve one: a skipped row is still in
-- the table, so it is not lost, and the migration needs no record of what it did
-- in order to be reversible.
-- `format_extension` makes the resource editor render `value.content` as a plain
-- .md file. Seeded under 'admins' so every workspace sees it.
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at)
VALUES (
'admins',
'ai_skill',
'{"type": "object", "properties": {"content": {"type": "string"}}}',
'A reusable instruction set for the AI chat, in the SKILL.md format. The resource description is what the assistant sees when deciding whether the skill applies; the file body is the instructions it follows.',
'system',
'md',
now()
)
ON CONFLICT (workspace_id, name) DO NOTHING;
-- Shared home matching the admin-only upload these skills had. A workspace that
-- already has a `skills` folder keeps it untouched, ACL and all: adopting one
-- would hand its own grants — possibly write for everyone — over a set of
-- instructions the assistant follows.
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
SELECT DISTINCT workspace_id, 'skills', 'Skills', ARRAY[]::TEXT[], '{"g/all": false}'::jsonb
FROM ai_skill
ON CONFLICT (workspace_id, name) DO NOTHING;
-- Copied only where the destination is free and the folder matches the one above
-- exactly, owners included: a pre-existing folder carrying the same ACL but an
-- owner would hand that owner update and delete over skills the removed API let
-- only workspace admins touch. Anything else stays in `ai_skill` for an operator
-- to place deliberately.
--
-- What was actually inserted is recorded rather than inferred. Inferring it from
-- "is there an ai_skill resource at the destination" reports nothing when the
-- blocker is itself an ai_skill with different instructions — the one case where
-- the skipped skill is least likely to be noticed.
CREATE TEMP TABLE ai_skill_copied AS
WITH inserted AS (
INSERT INTO resource (workspace_id, path, value, description, resource_type, created_by, edited_at)
SELECT
s.workspace_id,
'f/skills/' || s.name,
jsonb_build_object('content', s.instructions),
s.description,
'ai_skill',
s.edited_by,
s.edited_at
FROM ai_skill s
JOIN folder f
ON f.workspace_id = s.workspace_id
AND f.name = 'skills'
AND f.extra_perms = '{"g/all": false}'::jsonb
AND cardinality(f.owners) = 0
ON CONFLICT (workspace_id, path) DO NOTHING
RETURNING workspace_id, path
)
SELECT workspace_id, path FROM inserted;
-- Anything not copied is still in `ai_skill`, but nothing reads that table any
-- more, so from the app's side the skill is missing until an operator places it.
-- Name them rather than leaving that to be discovered.
DO $$
DECLARE
leftover RECORD;
BEGIN
FOR leftover IN
SELECT s.workspace_id, s.name
FROM ai_skill s
WHERE NOT EXISTS (
SELECT 1 FROM ai_skill_copied c
WHERE c.workspace_id = s.workspace_id
AND c.path = 'f/skills/' || s.name
)
LOOP
RAISE WARNING 'ai_skill %/% was not copied to a resource (its destination or the f/skills folder is already taken); it remains in the ai_skill table',
leftover.workspace_id, leftover.name;
END LOOP;
END $$;
DROP TABLE ai_skill_copied;
+68 -15
View File
@@ -7,14 +7,14 @@
*/
use anyhow::Context;
use monitor::{
load_base_url, load_otel, reload_critical_alerts_on_db_oversize,
reload_delete_logs_periodically_setting, reload_indexer_config,
reload_instance_python_version_setting, reload_maven_repos_setting,
flush_pending_log_files_to_object_store, load_base_url, load_otel,
reload_critical_alerts_on_db_oversize, reload_delete_logs_periodically_setting,
reload_indexer_config, reload_instance_python_version_setting, reload_maven_repos_setting,
reload_maven_settings_xml_setting, reload_no_default_maven_setting,
reload_nuget_config_setting, reload_powershell_repo_pat_setting,
reload_powershell_repo_url_setting, reload_ruby_repos_setting,
reload_timeout_wait_result_setting, reload_workspace_registries_setting,
flush_pending_log_files_to_object_store, send_logs_to_object_store, WORKERS_NAMES,
send_logs_to_object_store, WORKERS_NAMES,
};
use rand::Rng;
use sqlx::{Pool, Postgres};
@@ -406,8 +406,12 @@ struct HubResourceTypeRaw {
pub schema: Option<String>,
pub app: String,
pub description: Option<String>,
/// Absent from hubs predating the column, and from caches written before it.
#[serde(default)]
pub format_extension: Option<String>,
}
/// Processed resource type with parsed schema
#[derive(serde::Deserialize, serde::Serialize, Clone)]
pub struct HubResourceType {
@@ -416,6 +420,18 @@ pub struct HubResourceType {
pub schema: Option<serde_json::Value>,
pub app: String,
pub description: Option<String>,
/// Doubly optional on purpose. A cache written before this column has no key at
/// all (`None`) and must leave the stored extension alone; one written since
/// always writes the key, so an explicit null (`Some(None)`) is the hub genuinely
/// dropping it and must clear. A single `Option` conflates the two, and picking
/// either meaning breaks the other — as does plain serde, which folds `null`
/// into the outer `None`, hence the wrapping deserializer.
#[serde(
default,
deserialize_with = "windmill_common::more_serde::double_option",
skip_serializing_if = "Option::is_none"
)]
pub format_extension: Option<Option<String>>,
}
const HUB_RT_CACHE_FILE: &str = "resource_types.json";
@@ -462,6 +478,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
schema,
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
})
})
.collect();
@@ -503,9 +520,17 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
tracing::info!("Found {} cached resource types", cached_types.len());
// Get existing resource types in admins workspace
let existing_types: Vec<(String, Option<serde_json::Value>, Option<String>)> = sqlx::query_as(
"SELECT name, schema, description FROM resource_type WHERE workspace_id = 'admins'",
// Get existing resource types in admins workspace. `format_extension` is part of
// the comparison below, so a type whose only change is gaining or losing it is
// not mistaken for unchanged; `is_fileset` decides whether it may take one.
let existing_types: Vec<(
String,
Option<serde_json::Value>,
Option<String>,
Option<String>,
bool,
)> = sqlx::query_as(
"SELECT name, schema, description, format_extension, is_fileset FROM resource_type WHERE workspace_id = 'admins'",
)
.fetch_all(db)
.await
@@ -513,19 +538,42 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
let existing_map: std::collections::HashMap<
String,
(Option<serde_json::Value>, Option<String>),
(Option<serde_json::Value>, Option<String>, Option<String>, bool),
> = existing_types
.into_iter()
.map(|(name, schema, desc)| (name, (schema, desc)))
.map(|(name, schema, desc, format_extension, is_fileset)| {
(name, (schema, desc, format_extension, is_fileset))
})
.collect();
let mut synced_count = 0;
let mut skipped_count = 0;
for rt in cached_types {
// Check if resource type already exists with same schema and description
if let Some((existing_schema, existing_desc)) = existing_map.get(&rt.name) {
if existing_schema == &rt.schema && existing_desc == &rt.description {
let existing = existing_map.get(&rt.name);
let is_fileset = existing.map(|(_, _, _, f)| *f).unwrap_or(false);
let stored_extension = existing.and_then(|(_, _, e, _)| e.clone());
// A fileset is a set of files, so it cannot also be one file. Create, update
// and the manual sync all reject the pair; this writer would otherwise
// persist it onto a same-named local fileset.
//
// A cache with no key at all leaves the stored value alone, so the target is
// what is already there — which is also what makes the comparison below
// agree with the write instead of re-upserting the row on every boot.
let format_extension = if is_fileset {
None
} else {
match &rt.format_extension {
Some(from_cache) => from_cache.clone(),
None => stored_extension.clone(),
}
};
if let Some((existing_schema, existing_desc, _, _)) = existing {
if existing_schema == &rt.schema
&& existing_desc == &rt.description
&& stored_extension == format_extension
{
skipped_count += 1;
continue;
}
@@ -533,14 +581,19 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
// Insert or update resource type
sqlx::query(
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)
VALUES ('admins', $1, $2, $3, now())
// `format_extension` is resolved above rather than coalesced here: a
// COALESCE could never clear one, so a hub that dropped an extension
// would leave the stale value behind forever.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
format_extension = EXCLUDED.format_extension, edited_at = now()",
)
.bind(&rt.name)
.bind(&rt.schema)
.bind(&rt.description)
.bind(&format_extension)
.execute(db)
.await
.with_context(|| format!("Failed to upsert resource type {}", rt.name))?;
-49
View File
@@ -288,7 +288,6 @@ pub enum ScopeDomain {
Configs,
OAuth,
AI,
AiSkills,
AiEvals, // AI agent eval datasets
Indexer,
@@ -349,7 +348,6 @@ impl ScopeDomain {
Self::Configs => "configs",
Self::OAuth => "oauth",
Self::AI => "ai",
Self::AiSkills => "ai_skills",
Self::AiEvals => "ai_evals",
Self::Capture => "capture",
Self::Drafts => "drafts",
@@ -405,7 +403,6 @@ impl ScopeDomain {
"configs" => Some(Self::Configs),
"oauth" => Some(Self::OAuth),
"ai" => Some(Self::AI),
"ai_skills" => Some(Self::AiSkills),
"ai_evals" => Some(Self::AiEvals),
"indexer" | "srch" => Some(Self::Indexer),
"teams" => Some(Self::Teams),
@@ -1202,12 +1199,6 @@ mod tests {
assert_eq!(domain, ScopeDomain::FlowConversations);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("flow_conversations/list".to_string()));
let (domain, kind, route_suffix) =
extract_domain_from_route("/api/w/test_workspace/ai_skills/list").unwrap();
assert_eq!(domain, ScopeDomain::AiSkills);
assert_eq!(kind, None);
assert_eq!(route_suffix, Some("ai_skills/list".to_string()));
}
#[test]
@@ -1368,11 +1359,6 @@ mod tests {
ScopeDomain::from_str("flow_conversations"),
Some(ScopeDomain::FlowConversations)
);
assert_eq!(
ScopeDomain::from_str("ai_skills"),
Some(ScopeDomain::AiSkills)
);
// Test canonical string conversion
assert_eq!(ScopeDomain::Acls.as_str(), "acls");
assert_eq!(ScopeDomain::RawApps.as_str(), "raw_apps");
@@ -1381,41 +1367,6 @@ mod tests {
ScopeDomain::FlowConversations.as_str(),
"flow_conversations"
);
assert_eq!(ScopeDomain::AiSkills.as_str(), "ai_skills");
}
#[test]
fn test_ai_skills_scope_access() {
let read_scopes = vec!["ai_skills:read".to_string()];
assert!(
check_route_access(&read_scopes, "/api/w/test_workspace/ai_skills/list", "GET").is_ok()
);
assert!(check_route_access(
&read_scopes,
"/api/w/test_workspace/ai_skills/get/foo",
"GET"
)
.is_ok());
assert!(check_route_access(
&read_scopes,
"/api/w/test_workspace/ai_skills/upload",
"POST"
)
.is_err());
let write_scopes = vec!["ai_skills:write".to_string()];
assert!(check_route_access(
&write_scopes,
"/api/w/test_workspace/ai_skills/upload",
"POST"
)
.is_ok());
assert!(check_route_access(
&write_scopes,
"/api/w/test_workspace/ai_skills/delete/foo",
"DELETE"
)
.is_ok());
}
#[test]
+29 -4
View File
@@ -2004,6 +2004,12 @@ struct CachedResourceType {
#[allow(dead_code)]
app: String,
description: Option<String>,
/// Doubly optional, and read through a wrapping deserializer: this struct also
/// decodes the on-disk cache, where an absent key means "written before the
/// column, leave the stored extension alone" and an explicit null means the hub
/// dropped it. Plain serde folds both into `None`.
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
format_extension: Option<Option<String>>,
}
#[derive(serde::Deserialize)]
@@ -2013,6 +2019,8 @@ struct HubResourceTypeRaw {
schema: Option<String>,
app: String,
description: Option<String>,
#[serde(default)]
format_extension: Option<String>,
}
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
@@ -2054,6 +2062,7 @@ async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType
schema,
app: rt.app,
description: rt.description,
format_extension: Some(rt.format_extension),
})
})
.collect())
@@ -2107,10 +2116,12 @@ async fn sync_cached_resource_types(
for rt in &resource_types {
let exists: Option<bool> = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)",
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
)
.fetch_one(&db)
.await?;
@@ -2120,13 +2131,27 @@ async fn sync_cached_resource_types(
}
sqlx::query!(
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)
VALUES ('admins', $1, $2, $3, now())
// Whether the payload carried the key at all is what decides: present
// (even as null) is authoritative and may clear, absent means a cache
// written before the column and must leave the stored value alone.
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
VALUES ('admins', $1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
-- A fileset is a set of files, so it cannot also be one file.
-- Create and update reject the pair; this writer bypasses both, so
-- it declines the extension rather than persisting the forbidden
-- combination onto a same-named local fileset.
format_extension = CASE
WHEN resource_type.is_fileset THEN NULL
WHEN $5 THEN EXCLUDED.format_extension
ELSE resource_type.format_extension END,
edited_at = now()",
&rt.name,
rt.schema.as_ref(),
rt.description.as_deref(),
rt.format_extension.clone().flatten(),
rt.format_extension.is_some(),
)
.execute(&db)
.await?;
-196
View File
@@ -16532,202 +16532,6 @@
}
}
},
"/w/{workspace}/ai_skills/list": {
"get": {
"summary": "list the workspace AI chat skills (name + description only)",
"operationId": "listAiSkills",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"responses": {
"200": {
"description": "skill listing",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"required": [
"name",
"description"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/get/{name}": {
"get": {
"summary": "get a workspace AI chat skill including its instructions",
"operationId": "getAiSkill",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "skill",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"description",
"instructions"
],
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"instructions": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/upload": {
"post": {
"summary": "upsert workspace AI chat skills (admin only)",
"operationId": "uploadAiSkills",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"skills"
],
"properties": {
"skills": {
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"required": [
"name",
"description",
"instructions"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z0-9-]+$"
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"instructions": {
"type": "string",
"minLength": 1,
"maxLength": 65536
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "uploaded",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/ai_skills/delete/{name}": {
"delete": {
"summary": "delete a workspace AI chat skill (admin only)",
"operationId": "deleteAiSkill",
"tags": [
"workspace"
],
"parameters": [
{
"$ref": "#/components/parameters/WorkspaceId"
},
{
"name": "name",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "deleted",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/w/{workspace}/apps/get_data/v/{secretWithExtension}": {
"get": {
"summary": "get raw app data by",
-135
View File
@@ -17063,141 +17063,6 @@ paths:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/list:
get:
summary: list the workspace AI chat skills (name + description only)
operationId: listAiSkills
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
responses:
'200':
description: skill listing
content:
application/json:
schema:
type: array
items:
type: object
required:
- name
- description
properties:
name:
type: string
description:
type: string
/w/{workspace}/ai_skills/get/{name}:
get:
summary: get a workspace AI chat skill including its instructions
operationId: getAiSkill
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: name
in: path
required: true
schema:
type: string
responses:
'200':
description: skill
content:
application/json:
schema:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
description:
type: string
instructions:
type: string
/w/{workspace}/ai_skills/upload:
post:
summary: upsert workspace AI chat skills (admin only)
operationId: uploadAiSkills
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- skills
properties:
skills:
type: array
maxItems: 50
items:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
minLength: 1
maxLength: 64
pattern: ^[a-z0-9-]+$
description:
type: string
minLength: 1
maxLength: 1024
instructions:
type: string
minLength: 1
maxLength: 65536
responses:
'200':
description: uploaded
content:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/delete/{name}:
delete:
summary: delete a workspace AI chat skill (admin only)
operationId: deleteAiSkill
tags:
- workspace
parameters:
- name: workspace
in: path
required: true
schema: *ref_4
- name: name
in: path
required: true
schema:
type: string
responses:
'200':
description: deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
+6 -127
View File
@@ -12534,133 +12534,6 @@ paths:
type: boolean
description: more buckets matched than were returned, so summing them under-reports
/w/{workspace}/ai_skills/list:
get:
summary: list the workspace AI chat skills (name + description only)
operationId: listAiSkills
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: skill listing
content:
application/json:
schema:
type: array
items:
type: object
required:
- name
- description
properties:
name:
type: string
description:
type: string
/w/{workspace}/ai_skills/get/{name}:
get:
summary: get a workspace AI chat skill including its instructions
operationId: getAiSkill
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: skill
content:
application/json:
schema:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
description:
type: string
instructions:
type: string
/w/{workspace}/ai_skills/upload:
post:
summary: upsert workspace AI chat skills (admin only)
operationId: uploadAiSkills
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- skills
properties:
skills:
type: array
maxItems: 50
items:
type: object
required:
- name
- description
- instructions
properties:
name:
type: string
minLength: 1
maxLength: 64
pattern: "^[a-z0-9-]+$"
description:
type: string
minLength: 1
maxLength: 1024
instructions:
type: string
minLength: 1
maxLength: 65536
responses:
"200":
description: uploaded
content:
text/plain:
schema:
type: string
/w/{workspace}/ai_skills/delete/{name}:
delete:
summary: delete a workspace AI chat skill (admin only)
operationId: deleteAiSkill
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: deleted
content:
text/plain:
schema:
type: string
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
get:
summary: get raw app data by
@@ -29612,6 +29485,12 @@ components:
type: string
is_fileset:
type: boolean
format_extension:
type: string
nullable: true
description: >-
File extension for a type whose value is one file rather than a set
of fields. Omit to leave it unchanged; send null to clear it.
TriggerHistoryEntry:
type: object
-394
View File
@@ -1,394 +0,0 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use crate::db::{ApiAuthed, DB};
use axum::{
extract::{Extension, Json, Path},
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::require_admin,
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_skills))
.route("/get/{name}", get(get_skill))
.route("/upload", post(upload_skills))
.route("/delete/{name}", delete(delete_skill))
}
/// Cheap listing surfaced in the AI chat system prompt — no `instructions` body.
#[derive(Serialize)]
pub struct SkillListItem {
pub name: String,
pub description: String,
}
/// Full skill, including the SKILL.md body, fetched on demand by `read_skill`.
#[derive(Serialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub instructions: String,
}
#[derive(Deserialize)]
pub struct UploadSkills {
pub skills: Vec<SkillUpload>,
}
#[derive(Deserialize)]
pub struct SkillUpload {
pub name: String,
pub description: String,
pub instructions: String,
}
const MAX_SKILLS_PER_UPLOAD: usize = 50;
// Every stored skill's name + description is advertised in the global AI chat
// system prompt, so bound the total a workspace can accumulate across uploads.
const MAX_SKILLS_PER_WORKSPACE: usize = 100;
// `name` and `description` follow the Claude SKILL.md spec
// (https://platform.claude.com/docs/en/agents-and-tools/agent-skills): both are
// loaded into the AI chat system prompt and `name` is the model-facing skill id,
// so matching the upstream limits keeps skills portable with Claude Code.
const MAX_SKILL_NAME_CHARS: usize = 64;
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
// Not a spec field — a payload bound on the SKILL.md body, so measured in bytes.
const MAX_SKILL_INSTRUCTIONS_BYTES: usize = 64 * 1024;
fn validate_skill(skill: &SkillUpload) -> Result<()> {
let name = skill.name.trim();
if name.is_empty() || name.chars().count() > MAX_SKILL_NAME_CHARS {
return Err(Error::BadRequest(format!(
"skill name must be between 1 and {MAX_SKILL_NAME_CHARS} characters, got {:?}",
skill.name
)));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(Error::BadRequest(format!(
"skill name {name:?} must only contain lowercase letters, digits or '-'"
)));
}
if skill.description.trim().is_empty() {
return Err(Error::BadRequest(format!(
"skill {name:?} is missing a description (the SKILL.md frontmatter `description`)"
)));
}
if skill.description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS {
return Err(Error::BadRequest(format!(
"skill {name:?} description must be at most {MAX_SKILL_DESCRIPTION_CHARS} characters"
)));
}
if skill.instructions.trim().is_empty() {
return Err(Error::BadRequest(format!(
"skill {name:?} has an empty SKILL.md body"
)));
}
if skill.instructions.len() > MAX_SKILL_INSTRUCTIONS_BYTES {
return Err(Error::BadRequest(format!(
"skill {name:?} instructions must be at most {MAX_SKILL_INSTRUCTIONS_BYTES} bytes"
)));
}
Ok(())
}
/// Collect the trimmed skill names, rejecting duplicates within a single upload.
/// The insert upserts by name, so a duplicate would silently keep only the last
/// and make the reported/audited count wrong.
fn collect_upload_names(skills: &[SkillUpload]) -> Result<Vec<String>> {
let mut names = Vec::with_capacity(skills.len());
let mut seen = HashSet::with_capacity(skills.len());
for skill in skills {
let name = skill.name.trim().to_string();
if !seen.insert(name.clone()) {
return Err(Error::BadRequest(format!(
"duplicate skill name {name:?} in upload"
)));
}
names.push(name);
}
Ok(names)
}
/// Reject an upload that would push the workspace past `MAX_SKILLS_PER_WORKSPACE`.
/// Uploads upsert, so names already present (`replacing`) don't count as new.
fn check_workspace_skill_capacity(
existing_total: i64,
replacing: i64,
upload_count: usize,
) -> Result<()> {
let new_count = upload_count as i64 - replacing;
if existing_total + new_count > MAX_SKILLS_PER_WORKSPACE as i64 {
return Err(Error::BadRequest(format!(
"workspace cannot store more than {MAX_SKILLS_PER_WORKSPACE} skills"
)));
}
Ok(())
}
async fn list_skills(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<SkillListItem>> {
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query!(
"SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
&w_id
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(
rows.into_iter()
.map(|r| SkillListItem { name: r.name, description: r.description })
.collect(),
))
}
async fn get_skill(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, name)): Path<(String, String)>,
) -> JsonResult<Skill> {
let mut tx = user_db.begin(&authed).await?;
let row = sqlx::query!(
"SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
&w_id,
&name
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
row.map(|r| {
Json(Skill { name: r.name, description: r.description, instructions: r.instructions })
})
.ok_or_else(|| Error::NotFound(format!("no skill named {name:?} in workspace {w_id}")))
}
/// Bulk upsert the uploaded skills by name. Existing skills not in the payload
/// are left untouched — removal goes through `delete_skill`.
async fn upload_skills(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(payload): Json<UploadSkills>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
if payload.skills.is_empty() {
return Err(Error::BadRequest("no skills to upload".to_string()));
}
if payload.skills.len() > MAX_SKILLS_PER_UPLOAD {
return Err(Error::BadRequest(format!(
"cannot upload more than {MAX_SKILLS_PER_UPLOAD} skills at a time"
)));
}
for skill in &payload.skills {
validate_skill(skill)?;
}
let names = collect_upload_names(&payload.skills)?;
let mut tx = db.begin().await?;
let counts = sqlx::query!(
r#"SELECT
COUNT(*)::bigint AS "total!",
COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS "replacing!"
FROM ai_skill
WHERE workspace_id = $1"#,
&w_id,
&names
)
.fetch_one(&mut *tx)
.await?;
check_workspace_skill_capacity(counts.total, counts.replacing, names.len())?;
for (skill, name) in payload.skills.iter().zip(names.iter()) {
sqlx::query!(
r#"INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (workspace_id, name) DO UPDATE
SET description = EXCLUDED.description,
instructions = EXCLUDED.instructions,
edited_at = now(),
edited_by = EXCLUDED.edited_by"#,
&w_id,
name,
skill.description,
skill.instructions,
&authed.username,
)
.execute(&mut *tx)
.await?;
}
let audit_resource = names.join(",");
audit_log(
&mut *tx,
&authed,
"ai_skills.upload",
ActionKind::Update,
&w_id,
Some(&audit_resource),
Some([("skill_count", &names.len().to_string()[..])].into()),
)
.await?;
tx.commit().await?;
Ok(format!(
"Uploaded {} skill(s) to workspace {}",
payload.skills.len(),
&w_id
))
}
async fn delete_skill(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
let deleted = sqlx::query_scalar!(
"DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
&w_id,
&name
)
.fetch_optional(&mut *tx)
.await?;
if deleted.is_none() {
tx.commit().await?;
return Err(Error::NotFound(format!(
"no skill named {name:?} in workspace {w_id}"
)));
}
audit_log(
&mut *tx,
&authed,
"ai_skills.delete",
ActionKind::Delete,
&w_id,
Some(&name),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Deleted skill {name} from workspace {w_id}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn skill() -> SkillUpload {
SkillUpload {
name: "test-skill".to_string(),
description: "Useful for tests".to_string(),
instructions: "# Test\n\nDo the thing.".to_string(),
}
}
#[test]
fn validate_skill_rejects_oversized_description() {
let mut skill = skill();
skill.description = "x".repeat(MAX_SKILL_DESCRIPTION_CHARS + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_oversized_instructions() {
let mut skill = skill();
skill.instructions = "x".repeat(MAX_SKILL_INSTRUCTIONS_BYTES + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_oversized_name() {
let mut skill = skill();
skill.name = "a".repeat(MAX_SKILL_NAME_CHARS + 1);
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
}
#[test]
fn validate_skill_rejects_non_slug_name() {
// Uppercase, underscore, space and punctuation are all outside the
// Claude SKILL.md `[a-z0-9-]` name charset.
for bad in ["My-Skill", "my_skill", "my skill", "skill!"] {
let mut skill = skill();
skill.name = bad.to_string();
assert!(
matches!(validate_skill(&skill), Err(Error::BadRequest(_))),
"{bad:?} should be rejected"
);
}
}
#[test]
fn validate_skill_counts_description_in_characters() {
// 1024 two-byte chars exceed the byte limit but sit exactly on the
// character limit, so they must be accepted.
let mut skill = skill();
skill.description = "é".repeat(MAX_SKILL_DESCRIPTION_CHARS);
assert!(validate_skill(&skill).is_ok());
}
#[test]
fn workspace_capacity_allows_replacement_at_cap() {
// Already at the cap, but the upload only replaces an existing skill.
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
assert!(check_workspace_skill_capacity(at_cap, 1, 1).is_ok());
}
#[test]
fn workspace_capacity_rejects_new_skill_over_cap() {
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
assert!(matches!(
check_workspace_skill_capacity(at_cap, 0, 1),
Err(Error::BadRequest(_))
));
}
#[test]
fn collect_upload_names_trims_and_collects() {
let names = collect_upload_names(&[skill()]).unwrap();
assert_eq!(names, vec!["test-skill".to_string()]);
}
#[test]
fn collect_upload_names_rejects_duplicates() {
// Names are compared after trimming, so whitespace can't smuggle a dup in.
let dup = SkillUpload { name: " test-skill ".to_string(), ..skill() };
assert!(matches!(
collect_upload_names(&[skill(), dup]),
Err(Error::BadRequest(_))
));
}
}
-2
View File
@@ -69,7 +69,6 @@ mod ai;
#[cfg(feature = "private")]
mod ai_free_tier_ee;
mod ai_free_tier_oss;
mod ai_skills;
mod apps;
mod apps_raw_bundle;
pub use apps::invalidate_app_policy_cache;
@@ -713,7 +712,6 @@ pub async fn run_server(
Router::new()
})
.nest("/ai", ai::workspaced_service())
.nest("/ai_skills", ai_skills::workspaced_service())
.nest("/npm_proxy", windmill_api_npm_proxy::workspaced_service())
.nest(
"/path_autocomplete",
-1
View File
@@ -99,7 +99,6 @@ fn build_standard_scope_domains() -> Vec<ScopeDomain> {
("configs", "Configs", "Configuration management", false),
("oauth", "OAuth", "OAuth management", false),
("ai", "AI", "AI feature management", false),
("ai_skills", "AI Skills", "AI skill management", false),
(
"ai_evals",
"AI Evals",
+38
View File
@@ -130,6 +130,12 @@ pub struct EditResourceType {
pub schema: Option<serde_json::Value>,
pub description: Option<String>,
pub is_fileset: Option<bool>,
/// Doubly optional so an edit can distinguish the two things a plain
/// `Option` conflates: an absent field leaves the extension alone, while an
/// explicit `null` clears it. A hub pull relies on both — a type that stops
/// being a file type has to stop being one locally too.
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
pub format_extension: Option<Option<String>>,
}
#[derive(FromRow, Serialize, Deserialize)]
@@ -2803,10 +2809,42 @@ async fn update_resource_type(
if let Some(is_fileset) = ns.is_fileset {
sqlb.set("is_fileset", if is_fileset { "TRUE" } else { "FALSE" });
}
if let Some(format_extension) = ns.format_extension.clone() {
match format_extension {
Some(ext) => sqlb.set_str("format_extension", ext),
None => sqlb.set("format_extension", "NULL"),
};
}
sqlb.set_str("edited_at", "now()");
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
// Creation refuses the pair outright, so an edit must too — otherwise the same
// impossible type (a set of files that is also one file) is reachable by setting
// either half on an existing row. Whichever half the request omits is read from
// the row being edited, inside this transaction and with the row locked: read
// outside it, two concurrent edits each supplying one half would both pass.
let current = sqlx::query!(
"SELECT is_fileset, format_extension FROM resource_type
WHERE name = $1 AND workspace_id = $2 FOR UPDATE",
&name,
&w_id
)
.fetch_optional(&mut *tx)
.await?;
let effective_is_fileset = ns
.is_fileset
.unwrap_or_else(|| current.as_ref().map(|c| c.is_fileset).unwrap_or(false));
let effective_format_extension = match &ns.format_extension {
Some(value) => value.clone(),
None => current.and_then(|c| c.format_extension),
};
if effective_is_fileset && effective_format_extension.is_some() {
return Err(Error::BadRequest(
"A fileset resource type cannot have a format_extension".to_string(),
));
}
sqlx::query(&sql).execute(&mut *tx).await?;
audit_log(
&mut *tx,
+19
View File
@@ -66,3 +66,22 @@ where
NumericOrNull::Null => Ok(None),
}
}
/// Deserializer for a doubly-optional field, so a struct can tell an absent key
/// (`None`) from an explicit `null` (`Some(None)`).
///
/// Plain serde collapses both into the outer `None`, which makes the distinction
/// unusable exactly where it matters: a payload that omits a field means "leave it
/// alone", while one that sends `null` means "clear it".
///
/// ```ignore
/// #[serde(default, deserialize_with = "double_option", skip_serializing_if = "Option::is_none")]
/// pub field: Option<Option<String>>,
/// ```
pub fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
+5 -1
View File
@@ -17,6 +17,9 @@ interface HubResourceType {
app: string;
description: string;
is_fileset?: boolean;
// Absent from hubs predating the column, so a missing value is "ordinary type",
// not "unset it".
format_extension?: string | null;
}
export async function pull(opts: GlobalOptions) {
@@ -116,7 +119,8 @@ export async function pull(opts: GlobalOptions) {
typeof y.schema !== "string" &&
deepEqual(y.schema, x.schema) &&
y.description === x.description &&
(y.is_fileset ?? false) === (x.is_fileset ?? false)
(y.is_fileset ?? false) === (x.is_fileset ?? false) &&
(y.format_extension ?? null) === (x.format_extension ?? null)
)
) {
log.info("skipping " + x.name + " (same as current)");
@@ -25,6 +25,9 @@ export interface ResourceTypeFile {
schema?: any;
description?: string;
is_fileset?: boolean;
// Extension for a type whose value is one file rather than a set of fields; it
// is what makes the resource editor a file editor for that language.
format_extension?: string | null;
}
export async function pushResourceType(
@@ -1070,8 +1070,9 @@
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, and the plan tier and quota shown when the execution meter is
opened, last 30 days)</li
are started for, whether AI chat skills are turned on or off and how often one is
loaded, and the plan tier and quota shown when the execution meter is opened, last 30
days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -1123,8 +1124,9 @@
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers, the names of public hub scripts used, the languages debug sessions
are started for, and the plan tier and quota shown when the execution meter is
opened, last 30 days)</li
are started for, whether AI chat skills are turned on or off and how often one is
loaded, and the plan tier and quota shown when the execution meter is opened, last 30
days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -1152,20 +1154,19 @@
<div class="pb-4">
<Alert type="info" title="Log files stay on local disk" size="xs">
Instance object storage is not configured, so every server and worker keeps its log
files on its own disk. This page lists what each host wrote, but can only open the
files belonging to the replica serving the request — another host's are listed and
not readable — and a host's files go with it when it is replaced. Retention below
still governs the entries in the database and the files on disk.
files on its own disk. This page lists what each host wrote, but can only open the files
belonging to the replica serving the request — another host's are listed and not
readable — and a host's files go with it when it is replaced. Retention below still
governs the entries in the database and the files on disk.
</Alert>
</div>
{:else if !$enterpriseLicense}
<div class="pb-4">
<Alert type="info" title="Raw log files accumulate without the indexer" size="xs">
Log files are uploaded to instance object storage, and the indexer that would ingest
them into the columnar store and delete each one afterwards is an enterprise
feature. Retention below expires the database entries and the local files; the
uploaded copies are only removed when <b>Delete logs from s3 periodically</b> is on
under Object Storage.
them into the columnar store and delete each one afterwards is an enterprise feature.
Retention below expires the database entries and the local files; the uploaded copies
are only removed when <b>Delete logs from s3 periodically</b> is on under Object Storage.
</Alert>
</div>
{/if}
@@ -7,6 +7,7 @@
AlertTriangle,
ArrowDown,
AtSign,
BookOpen,
ChevronDown,
ChevronsRight,
CheckIcon,
@@ -35,6 +36,7 @@
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
import AIChatModelSettings from './AIChatModelSettings.svelte'
import McpConnections from './McpConnections.svelte'
import SkillsPicker from './SkillsPicker.svelte'
import ChatMode from './ChatMode.svelte'
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
@@ -206,6 +208,7 @@
let aiChatInput: AIChatInput | undefined = $state()
let mcpConnections: McpConnections | undefined = $state()
let skillsPicker: SkillsPicker | undefined = $state()
let plusMenuOpen = $state(false)
let editingMessageIndex = $state<number | null>(null)
@@ -931,39 +934,60 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
{#if canAttachFiles}
<DropdownV2
items={async () => [
{
displayName: 'Attach file or image',
icon: FileText,
action: () => {
plusMenuOpen = false
linkFiles()
}
},
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
}
},
...(aiChatManager.mode === AIMode.GLOBAL && mcpConnections
? [
{
displayName: 'MCP connections',
icon: Plug,
separatorTop: true,
submenuItems: await mcpConnections.menuItems(() => (plusMenuOpen = false))
}
]
: [])
]}
items={async () => {
// Both submenus fetch on the menu's first open, so they start
// together: awaited inline they queue, and the whole menu —
// attachments included — waits out two round trips.
const closeMenu = () => (plusMenuOpen = false)
const inGlobal = aiChatManager.mode === AIMode.GLOBAL
const [skillItems, mcpItems] = await Promise.all([
inGlobal ? skillsPicker?.menuItems(closeMenu) : undefined,
inGlobal ? mcpConnections?.menuItems(closeMenu) : undefined
])
return [
{
displayName: 'Attach file or image',
icon: FileText,
action: () => {
plusMenuOpen = false
linkFiles()
}
},
{
// A real (live) link needs the File System Access API; without it the
// folder is only snapshotted, so call it "Add folder", not "Link folder".
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
icon: Folder,
tooltip: canUseFsAccess
? 'Linked live — the assistant reads the folders current files from disk and refreshes each turn.'
: 'Loaded as a snapshot — the folders files are copied into your browser (they wont auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
action: () => {
plusMenuOpen = false
linkFolder()
}
},
...(skillItems
? [
{
displayName: 'Skills',
icon: BookOpen,
separatorTop: true,
submenuItems: skillItems
}
]
: []),
...(mcpItems
? [
{
displayName: 'MCP connections',
icon: Plug,
separatorTop: !skillItems,
submenuItems: mcpItems
}
]
: [])
]
}}
placement="bottom-start"
fixedHeight={false}
closeOnItemClick={false}
@@ -1103,6 +1127,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<ContextUsageIndicator />
<AIChatModelSettings />
{#if aiChatManager.mode === AIMode.GLOBAL}
<SkillsPicker bind:this={skillsPicker} />
<McpConnections bind:this={mcpConnections} />
{/if}
@@ -1135,9 +1135,10 @@ export class AIChatManager {
}
}
// Workspace AI skills (name + description) advertised in the GLOBAL system
// prompt and surfaced as slash commands in session chat. Loaded
// asynchronously when entering GLOBAL mode; the system message is rebuilt
// The `ai_skill` resources this user turned on for the operating workspace,
// advertised in the GLOBAL system prompt and surfaced as slash commands in
// session chat. Loaded asynchronously when entering GLOBAL mode and again
// whenever the picker changes the selection; the system message is rebuilt
// once they resolve.
globalSkills = $state<AiSkillListItem[]>([])
private globalSkillsRefreshId = 0
@@ -1173,9 +1174,10 @@ export class AIChatManager {
]
// Built-ins followed by workspace skills, with any skill whose name collides
// with a built-in dropped: the picker keys leaves by name, so a duplicate
// would break its keyed list and ambiguous-resolve nav. Built-ins win — they
// already shadow same-named skills at execution (the submit interception).
// with a built-in dropped. Built-ins win — they already shadow same-named
// skills at execution (the submit interception), so listing both would offer
// a row that cannot run. Two skills may still share a name; the picker keys
// those by path and the submit path declines to guess between them.
sessionCommands: ChatCommandItem[] = $derived([
...this.sessionBuiltinCommands,
...this.globalSkills
@@ -2136,7 +2138,11 @@ export class AIChatManager {
if (refreshId !== this.globalSkillsRefreshId) {
return
}
this.globalSkills = skills
// Newest-wins is not enough: a refresh for the workspace just left can still
// hold the newest id, and installing it would advertise that workspace's
// skills to a chat now acting elsewhere. Same check the identity and MCP
// refreshes make.
this.globalSkills = workspace === (this.operatingWorkspace ?? '') ? skills : []
if (this.mode === AIMode.GLOBAL) {
this.configureGlobalMode()
}
@@ -2224,16 +2230,25 @@ export class AIChatManager {
if (!this.isSessionChat || this.mode !== AIMode.GLOBAL || !instructions.startsWith('/')) {
return instructions
}
const match = /^\/([a-z0-9-]+)(?:\s+([\s\S]*))?$/.exec(instructions)
// Accepts a bare name or a whole resource path: names are what people type,
// but the picker inserts the path when two folders answer to the same name.
// Unicode-aware rather than `\w`, which is ASCII-only — a resource path may
// hold any word character, and the picker can insert one the user must then
// be able to send (`f/équipe/deploy`).
const match = /^\/([\p{L}\p{N}_\-/]+)(?:\s+([\s\S]*))?$/u.exec(instructions)
if (!match) {
return instructions
}
const skill = this.globalSkills.find((s) => s.name === match[1])
if (!skill) {
// A path identifies one skill; a name shared by two would otherwise silently
// apply instructions the user did not choose, so it is left unexpanded.
const byPath = this.globalSkills.find((s) => s.path === match[1])
const matches = byPath ? [byPath] : this.globalSkills.filter((s) => s.name === match[1])
if (matches.length !== 1) {
return instructions
}
const rest = match[2]?.trim()
return rest ? `Use the "${skill.name}" skill. ${rest}` : `Use the "${skill.name}" skill.`
const use = `Use the skill at "${matches[0].path}".`
return rest ? `${use} ${rest}` : use
}
canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT)
@@ -33,7 +33,7 @@ const mocks = vi.hoisted(() => ({
getAnthropicClient: vi.fn(),
getNonStreamingCompletion: vi.fn(),
runChatLoop: vi.fn(),
listAiSkills: vi.fn(),
listResource: vi.fn(),
getJob: vi.fn(),
whoami: vi.fn(),
workspace: 'test_workspace' as string | undefined,
@@ -49,8 +49,9 @@ vi.mock('monaco-editor', () => ({
vi.mock('$lib/utils/featureUsage', () => ({ logFeatureUsage: vi.fn() }))
vi.mock('$lib/gen', () => ({
WorkspaceService: {
listAiSkills: mocks.listAiSkills
WorkspaceService: {},
ResourceService: {
listResource: mocks.listResource
},
ScriptService: {},
FlowService: {},
@@ -169,7 +170,7 @@ beforeEach(() => {
mocks.isWebSearchEnabledForProvider.mockReturnValue(true)
mocks.getOpenaiClient.mockReturnValue({})
mocks.getAnthropicClient.mockReturnValue({})
mocks.listAiSkills.mockResolvedValue([])
mocks.listResource.mockResolvedValue([])
mocks.workspace = 'test_workspace'
mocks.runChatLoop.mockResolvedValue({
addedMessages: [],
@@ -330,17 +331,29 @@ describe('AIChatManager global skills', () => {
mocks.tryGetCurrentModel.mockReturnValue(model)
})
// Only selected skills reach the prompt, and the selection is keyed by
// workspace and account (see skills/enabledSkills.ts).
function selectSkills(workspace: string, ...paths: string[]) {
const stored = JSON.parse(localStorage.getItem('wm_skills_enabled') ?? '{}')
stored[`${workspace}:${TEST_EMAIL}`] = paths
localStorage.setItem('wm_skills_enabled', JSON.stringify(stored))
}
it('loads skills after beforeSend commits the session workspace', async () => {
let resolveParentSkills: ((skills: { name: string; description: string }[]) => void) | undefined
const parentSkills = new Promise<{ name: string; description: string }[]>((resolve) => {
let resolveParentSkills: ((skills: unknown[]) => void) | undefined
const parentSkills = new Promise<unknown[]>((resolve) => {
resolveParentSkills = resolve
})
mocks.workspace = 'parent'
mocks.listAiSkills.mockImplementation(({ workspace }: { workspace: string }) => {
selectSkills('parent', 'f/skills/parent-skill')
selectSkills('child', 'f/skills/child-skill')
mocks.listResource.mockImplementation(({ workspace }: { workspace: string }) => {
if (workspace === 'parent') {
return parentSkills
}
return Promise.resolve([{ name: 'child-skill', description: 'child workspace skill' }])
return Promise.resolve([
{ path: 'f/skills/child-skill', description: 'child workspace skill' }
])
})
mocks.runChatLoop.mockImplementation(async (config: any) => {
expect(config.workspace).toBe('child')
@@ -362,22 +375,45 @@ describe('AIChatManager global skills', () => {
}
await manager.sendRequest({ instructions: 'first', mode: AIMode.GLOBAL })
resolveParentSkills?.([{ name: 'parent-skill', description: 'parent workspace skill' }])
resolveParentSkills?.([
{ path: 'f/skills/parent-skill', description: 'parent workspace skill' }
])
await Promise.resolve()
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'parent' })
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'child' })
expect(mocks.listResource).toHaveBeenCalledWith(
expect.objectContaining({ workspace: 'parent', resourceType: 'ai_skill' })
)
expect(mocks.listResource).toHaveBeenCalledWith(
expect.objectContaining({ workspace: 'child', resourceType: 'ai_skill' })
)
expect(manager.systemMessage.content).toContain('child-skill')
expect(manager.systemMessage.content).not.toContain('parent-skill')
})
it('expands a leading slash skill command for the model while preserving the displayed text', async () => {
mocks.listAiSkills.mockResolvedValue([
{ name: 'review-code', description: 'review code for bugs' }
it('leaves a readable but unselected skill out of the prompt', async () => {
mocks.listResource.mockResolvedValue([
{ path: 'f/skills/selected', description: 'the one turned on' },
{ path: 'f/skills/unselected', description: 'readable but never turned on' }
])
selectSkills('test_workspace', 'f/skills/selected')
const manager = new AIChatManager()
manager.isSessionChat = true
await manager.refreshGlobalSkills('test_workspace')
await manager.changeMode(AIMode.GLOBAL)
expect(manager.systemMessage.content).toContain('f/skills/selected')
expect(manager.systemMessage.content).not.toContain('f/skills/unselected')
})
it('expands a leading slash skill command for the model while preserving the displayed text', async () => {
mocks.listResource.mockResolvedValue([
{ path: 'u/admin/review-code', description: 'review code for bugs' }
])
selectSkills('test_workspace', 'u/admin/review-code')
mocks.runChatLoop.mockImplementation(async (config: any) => {
const userMessage = config.messages[config.messages.length - 1]
expect(userMessage.content).toContain('Use the "review-code" skill. find bugs')
expect(userMessage.content).toContain('Use the skill at "u/admin/review-code". find bugs')
expect(userMessage.content).not.toContain('/review-code find bugs')
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
@@ -395,6 +431,33 @@ describe('AIChatManager global skills', () => {
expect(manager.displayMessages[0]?.content).toBe('/review-code find bugs')
})
it('does not expand a slash command two folders both answer to', async () => {
mocks.listResource.mockResolvedValue([
{ path: 'u/admin/deploy', description: 'personal deploy steps' },
{ path: 'f/team/deploy', description: 'the team deploy steps' }
])
selectSkills('test_workspace', 'u/admin/deploy', 'f/team/deploy')
mocks.runChatLoop.mockImplementation(async (config: any) => {
// Picking either one would silently apply instructions the user did not
// choose, so the text is left alone for the model to ask about.
const userMessage = config.messages[config.messages.length - 1]
expect(userMessage.content).toContain('/deploy ship it')
expect(userMessage.content).not.toContain('Use the skill at')
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = true
await manager.sendRequest({ instructions: '/deploy ship it', mode: AIMode.GLOBAL })
})
})
describe('AIChatManager global prompt identity', () => {
@@ -404,7 +467,7 @@ describe('AIChatManager global prompt identity', () => {
localStorage.clear()
mocks.getCurrentModel.mockReturnValue(model)
mocks.tryGetCurrentModel.mockReturnValue(model)
mocks.listAiSkills.mockResolvedValue([])
mocks.listResource.mockResolvedValue([])
})
afterEach(() => {
@@ -3099,8 +3162,8 @@ describe('AIChatManager manual compaction', () => {
vi.clearAllMocks()
mocks.getCurrentModel.mockReturnValue(model)
mocks.tryGetCurrentModel.mockReturnValue(model)
// changeMode(GLOBAL) refreshes workspace skills; keep it a no-op here.
mocks.listAiSkills.mockResolvedValue([])
// changeMode(GLOBAL) refreshes the selected skills; keep it a no-op here.
mocks.listResource.mockResolvedValue([])
})
function seedExchange(manager: AIChatManager) {
@@ -3315,15 +3378,19 @@ describe('AIChatManager manual compaction', () => {
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
})
it('shadows a workspace skill that collides with a built-in command', () => {
it('shadows a selected skill that collides with a built-in command', () => {
const manager = new AIChatManager()
manager.globalSkills = [
{ name: 'compact', description: 'a workspace skill that happens to be named compact' },
{ name: 'review-code', description: 'review code for bugs' }
{
path: 'u/admin/compact',
name: 'compact',
description: 'a skill that happens to be named compact'
},
{ path: 'u/admin/review-code', name: 'review-code', description: 'review code for bugs' }
]
// Built-ins come first and the colliding skill is dropped, so the picker
// never renders two leaves with the same `skill:compact` key.
// Built-ins come first and the colliding skill is dropped: the built-in
// wins at execution too, so listing both would offer a row that cannot run.
const names = manager.sessionCommands.map((c) => c.name)
expect(names).toEqual(['compact', 'clear', 'review-code'])
expect(manager.sessionCommands[0].description).toBe(
@@ -2,6 +2,7 @@
import DrillPicker from '$lib/components/DrillPicker.svelte'
import type { DrillLeaf, DrillNode } from '$lib/components/drillPicker'
import type { ChatCommandItem } from './global/core'
import { ambiguousSkillNames } from './skills/skillResources'
interface Props {
skills: ChatCommandItem[]
@@ -24,15 +25,22 @@
skill: 'Skills'
}
// No `secondary`: rows show just the command; the full description lives in
// the hover tooltip (rowTooltip below). It stays in `searchableText` so
// filtering by description keeps working.
// Two folders can each hold a skill of the same name, and `/name` cannot then
// say which one is meant. Those rows show their path so the two are at least
// distinguishable; unambiguous rows stay bare, with the description in the
// hover tooltip (rowTooltip below) and in `searchableText` so filtering by it
// keeps working.
const ambiguous = $derived(ambiguousSkillNames(skills.filter((s) => s.path !== undefined)))
const tree = $derived<DrillNode<ChatCommandItem>[]>(
skills.map((skill) => ({
type: 'leaf' as const,
key: `skill:${skill.name}`,
// Keyed by path where there is one: names are not unique across folders,
// and a duplicate key breaks the keyed list and its ambiguous-resolve nav.
key: `skill:${skill.path ?? skill.name}`,
label: `/${skill.name}`,
searchableText: `${skill.name} ${skill.description}`,
secondary: ambiguous.has(skill.name) ? skill.path : undefined,
searchableText: `${skill.name} ${skill.path ?? ''} ${skill.description}`,
section: skill.kind ? SECTION_LABELS[skill.kind] : undefined,
data: skill
}))
@@ -575,7 +575,10 @@
function getCommandFilter(text: string): string | undefined {
if (aiChatManager.mode !== AIMode.GLOBAL || !aiChatManager.isSessionChat) return undefined
const match = /^\/([a-z0-9-]*)$/.exec(text)
// Same character set the submit path expands, so a name the picker can insert
// does not close the picker as soon as it is typed. Paths reach here too, via
// the row inserted for an ambiguous name.
const match = /^\/([\p{L}\p{N}_\-/]*)$/u.exec(text)
return match?.[1]
}
@@ -640,8 +643,12 @@
}
}
function handleCommandSelection(skill: { name: string }) {
value = `/${skill.name} `
function handleCommandSelection(skill: { name: string; path?: string }) {
// The picker lists a row per skill, so two folders holding the same name are
// two distinct rows — but `/name` could not say which one was clicked, and
// submission refuses to guess. Those insert the path the row stands for.
const ambiguous = commandSkills.filter((c) => c.name === skill.name).length > 1
value = `/${ambiguous && skill.path ? skill.path : skill.name} `
showCommandTooltip = false
setTimeout(() => textarea?.focus(), 0)
}
@@ -0,0 +1,857 @@
<script lang="ts">
import { Alert, Button, Drawer } from '$lib/components/common'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import Path from '$lib/components/Path.svelte'
import FileInput from '$lib/components/common/fileInput/FileInput.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { markdownProse } from '$lib/components/markdownProse'
import { workspaceStore, userStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import type { Item } from '$lib/utils'
import { untrack } from 'svelte'
import {
BookOpen,
ClipboardPaste,
Eye,
List,
Pencil,
Plus,
RotateCcw,
Trash2
} from 'lucide-svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { isSkillEnabled, setSkillEnabled } from './skills/enabledSkills'
import {
ambiguousSkillNames,
deleteSkillResource,
listSkillResources,
readSkillBody,
saveSkillResource,
updateSkillResource,
type SkillResource
} from './skills/skillResources'
import {
buildSkillMd,
parseAndValidateSkill,
parseSkillMd,
type SkillUpload
} from './skills/skillMd'
const aiChatManager = getAiChatManager()
// A session chat operates on its own (possibly forked) workspace without
// switching `workspaceStore`, and that is the workspace the chat reads the
// enabled set under. Key everything here the same way or a toggle lands under
// a key nothing reads.
//
// `operatingWorkspace` is a plain getter over untracked state, so the store is
// read unconditionally rather than behind `??`: short-circuiting it would leave
// this derived with no dependency at all, frozen on the workspace it first saw.
let ws = $derived.by(() => {
const active = $workspaceStore
return aiChatManager.operatingWorkspace ?? active!
})
// A session whose fork is still staged has no workspace of its own yet, so `ws`
// resolves to the PARENT. Authoring through the picker would then edit the live
// parent, and a toggle would be stored under it and quietly stop applying the
// moment the first send commits the fork. Read at use, not once: the fork
// commits mid-session.
function pendingForkParent(): string | undefined {
return aiChatManager.sessionContextResolver?.()?.pendingForkOf
}
let forkPending = $state(false)
function refreshForkPending() {
forkPending = pendingForkParent() !== undefined
}
/** Guards every mutating action. Returns true when the caller must not proceed. */
function blockedByPendingFork(): boolean {
const parent = pendingForkParent()
if (parent === undefined) return false
refreshForkPending()
sendUserToast(
`This session has not created its workspace yet, so a skill would be written to "${parent}" instead. Send a message first.`,
true
)
return true
}
// `<root>/<skill>/SKILL.md` is 3 path segments; SKILL.md files nested deeper
// are likely vendored/incidental and are skipped so importing a parent dir
// doesn't sweep in unrelated skills.
const MAX_SKILL_DEPTH = 3
const MAX_SKILLS_PER_IMPORT = 50
/** Stands in for the name when only the description and body are being checked. */
const VALID_NAME_PLACEHOLDER = 'placeholder'
// A new skill opens on this as real, editable text rather than ghost placeholder
// text: the format is the point of the sample, and a SKILL.md is easier to adapt
// than to recall. `name` seeds the path above until the user edits the path
// themselves, so renaming here renames the skill.
const SKILL_TEMPLATE = `---
name: my-skill
description: What this skill covers, and when the assistant should reach for it
---
# My skill
What the assistant should do when this skill applies.
## Steps
1. First thing to do.
2. Second thing to do.
`
type Row = SkillResource & { enabled: boolean }
let drawer: Drawer | undefined = $state(undefined)
let skills = $state<Row[]>([])
let loading = $state(false)
let loadError = $state<string | undefined>(undefined)
let listNotice = $state<string | undefined>(undefined)
let saving = $state(false)
let toDelete: Row | undefined = $state(undefined)
let importFiles = $state<File[] | undefined>(undefined)
// Paste/edit modal. `editing` is the row being edited (undefined while
// creating), held so a path change can be applied as a move.
let editorOpen = $state(false)
let editing = $state<Row | undefined>(undefined)
let content = $state('')
let originalContent = $state('')
let path = $state('')
let pathError = $state('')
// Set by Path once the user edits the path themselves, which is what stops the
// frontmatter from overwriting their choice below.
let pathDirty = $state(false)
let detailMode: 'view' | 'edit' = $state('view')
// Staged folder import, confirmed before anything is written.
let pendingImport: SkillUpload[] | undefined = $state(undefined)
let pendingSkipped: string[] = $state([])
let overwriteChoices: Record<string, boolean> = $state({})
let ambiguous = $derived(ambiguousSkillNames(skills))
let parsed = $derived(parseSkillMd(content))
// The Path field is what names the skill, and Path validates it. The frontmatter
// `name` only seeds that field and is never persisted, so validating it here
// would block saving a skill whose path is legal but whose name is not — resource
// paths admit `_` and uppercase, SKILL.md names do not. The folder importer keeps
// validating, because there the name really does become the path segment.
let validated = $derived(parseAndValidateSkill(content, VALID_NAME_PLACEHOLDER))
let contentError = $derived('error' in validated ? validated.error : undefined)
// Measured against what the modal opened with, which for a new skill is the
// sample. Saving it untouched would store a skill called "my-skill", so an
// unedited body is not something to save; Reset restores exactly this baseline.
let contentChanged = $derived(content !== originalContent)
let pathChanged = $derived(!!editing && path !== editing.path)
let canSave = $derived(
!saving && !contentError && !!path && !pathError && (contentChanged || pathChanged)
)
// Keyed by the path the import would write to, not by bare name: a `deploy` that
// exists only in someone else's folder is not something this import overwrites,
// and offering it as a conflict would ask the user about a collision that isn't.
let existingPaths = $derived(new Set(skills.map((s) => s.path)))
let importTargets = $derived(
(pendingImport ?? ([] as SkillUpload[])).map((s) => ({
skill: s,
path: `${defaultOwner()}/${s.name}`
}))
)
let pendingConflicts = $derived(
importTargets.filter((t) => existingPaths.has(t.path)).map((t) => t.skill)
)
let pendingNew = $derived(
importTargets.filter((t) => !existingPaths.has(t.path)).map((t) => t.skill)
)
// Rows describe one workspace. A switch while the drawer is open must not leave
// A's rows on screen while the actions below target B: same path, different
// skill, and delete would remove the wrong one. Dropping them is all this does:
// this component mounts with the chat toolbar, so loading here would list
// resources for every user who never opens the menu. The two entry points load
// what they need.
let loadSeq = 0
$effect(() => {
const target = ws
untrack(() => {
loadSeq++
skills = []
listNotice = undefined
toDelete = undefined
pendingImport = undefined
// The editor holds one workspace's skill but saves to whatever `ws` is by
// then, so a switch mid-edit would write A's body into B at the same path.
// Closing it is the honest outcome: there is no version of that save the
// user asked for.
editorOpen = false
editing = undefined
// A drawer already on screen is neither entry point, and would sit there
// reporting that the new workspace has no skills.
if (drawer?.isOpen()) void loadSkills(target)
})
})
async function loadSkills(target = ws) {
// Checked before the sequence is taken, not only after the await: a stale
// action calling refresh(A) would otherwise claim the newest sequence and
// make the legitimate load for B discard its own result, leaving the drawer
// blank for the workspace actually on screen.
if (!target || target !== ws) return
refreshForkPending()
listNotice = undefined
const seq = ++loadSeq
loading = true
loadError = undefined
try {
const { skills: found, truncated } = await listSkillResources(target, $userStore ?? undefined)
// Newest-request-wins is not enough on its own: an action started in A and
// finishing after a switch to B holds the newest sequence, and would put
// A's rows on screen under B — where the next row action would edit or
// delete that path in B. The workspace has to still be the one asked for.
if (seq !== loadSeq || target !== ws) return
skills = found.map((s) => ({ ...s, enabled: isSkillEnabled(target, s.path) }))
// A notice, not `loadError`: that one replaces the list, and a truncated
// read still has skills worth showing.
listNotice = truncated
? `Showing the first ${found.length} skills; this workspace has more. Delete unused ones so the rest can be selected.`
: undefined
} catch (e) {
if (seq !== loadSeq || target !== ws) return
// Without this the drawer would render the empty state, which reads as
// "this workspace has no skills" rather than "we could not load them".
loadError = e.body ?? e.message
} finally {
if (seq === loadSeq) loading = false
}
}
export async function open() {
drawer?.openDrawer()
await loadSkills()
}
// A menu is a shortcut, not a directory: past this many the list stops being
// scannable, so the rest are reached through the drawer rather than dropped.
const MAX_MENU_SKILLS = 8
/** Rows for the chat's "+" menu: one per skill, checked when it is on, then
* the way to manage them. Loaded on open so the checks are current. */
export async function menuItems(closeMenu?: () => void): Promise<Item[]> {
// The menu opens on what is already known and refreshes behind it: waiting on
// a round trip would stall the whole `+` menu, attachments included.
if (skills.length === 0) {
await loadSkills()
} else {
void loadSkills()
}
// Enabled first: those are the ones a quick visit is most likely about.
const ordered = [...skills].sort(
(a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)
)
const shown = ordered.slice(0, MAX_MENU_SKILLS)
return [
...shown.map(({ path: p, name }) => ({
// Ambiguous names are shown by path — two rows reading `deploy` would
// leave the choice between them to chance.
displayName: ambiguous.has(name) ? p : name,
icon: BookOpen,
// Getters, not snapshots: the menu stays open across a click, and it has
// to read through the live list rather than the row captured here, since
// a reload replaces every row object and a getter bound to the old one
// would go on reporting the state it was built with.
get toggle() {
return row(p)?.enabled ?? false
},
action: () => toggle(p, !row(p)?.enabled)
})),
...(ordered.length > shown.length
? [
{
displayName: `Show all ${ordered.length}`,
icon: List,
action: () => {
closeMenu?.()
void open()
}
}
]
: []),
{
displayName: skills.length > 0 ? 'Manage skills' : 'Add a skill',
icon: Plus,
separatorTop: skills.length > 0,
action: () => {
closeMenu?.()
void open()
}
}
]
}
function row(p: string) {
return skills.find((s) => s.path === p)
}
async function toggle(p: string, enabled: boolean) {
if (blockedByPendingFork()) return
// Pinned for the whole call, like the other actions: the selection is stored
// per workspace, and the refresh below must not hand these skills to a chat
// that has since moved on.
const target = ws
if (!setSkillEnabled(target, p, enabled)) {
sendUserToast('Could not save the selection for this account.', true)
return
}
const skill = skills.find((s) => s.path === p)
if (skill) skill.enabled = enabled
// Whether people select skills at all. Never the skill itself: a path is
// workspace-authored text.
logFeatureUsage('ai_session', 'skill_toggle', {
key: enabled ? 'on' : 'off',
workspace: target
})
// The prompt lists exactly the enabled skills, so it has to be rebuilt
// before the next message rather than on the next mode change. A workspace
// switch during that rebuild is discarded inside refreshGlobalSkills.
await aiChatManager.refreshGlobalSkills(target)
}
/** Personal folder the folder import writes into. The username is not always a
* legal path segment — a superadmin who is not a member of the workspace gets
* their email back from `whoami` — and `resource.path` is CHECK-constrained, so
* it is narrowed the same way Path.svelte narrows it. */
function defaultOwner() {
const username = $userStore?.username ?? 'user'
const narrowed = username.includes('@')
? username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
: username
// Narrowing can empty the string outright (an all-punctuation local part),
// and `u//name` fails the path CHECK with nothing to explain it.
return `u/${narrowed || 'user'}`
}
function openCreate() {
if (blockedByPendingFork()) return
editing = undefined
content = SKILL_TEMPLATE
originalContent = SKILL_TEMPLATE
path = ''
pathDirty = false
detailMode = 'edit'
editorOpen = true
}
/** Back to what the modal opened with: the sample for a new skill, the saved
* body for one being edited. */
function resetContent() {
content = originalContent
}
async function openSkill(skill: Row, mode: 'view' | 'edit') {
// Pinned across the await: the workspace can change while the body loads, and
// closing the editor then would not stop this from reopening it with the old
// workspace's content — which submitEditor would save into the new one.
const source = ws
try {
const instructions = await readSkillBody(source, skill.path)
if (source !== ws) return
content = buildSkillMd({
name: skill.name,
description: skill.description,
instructions
})
originalContent = content
editing = skill
path = skill.path
pathDirty = false
detailMode = mode
editorOpen = true
} catch (e) {
sendUserToast(`Failed to load skill: ${e.body ?? e.message}`, true)
}
}
// Path opens on a generated name, so the frontmatter `name` has to replace that
// last segment rather than fill a blank. Its owner is left alone (Path picks up
// where the user last created something), and the whole thing stops as soon as
// they edit the path themselves.
//
// `path` is a dependency, not just a value read: Path settles it asynchronously,
// after the template's name is already parsed, so an effect watching only the
// content would run once against an empty path and never again. Writing back the
// value it already holds is what would loop, hence the equality guard.
$effect(() => {
const suggested = parsed.name
const current = path
untrack(() => {
if (!editorOpen || editing || pathDirty || !suggested) return
const owner = current.split('/').slice(0, -1).join('/')
if (!owner) return
const next = `${owner}/${suggested}`
if (next !== current) path = next
})
})
async function submitEditor() {
if (!('skill' in validated) || !path || pathError) return
if (blockedByPendingFork()) return
const target = ws
saving = true
try {
if (editing) {
await updateSkillResource(
target,
editing.path,
path,
validated.skill.description,
validated.skill.instructions
)
// A move leaves the old path selected but gone; carry the choice over
// so an edit that renames does not silently switch the skill off.
if (editing.path !== path && isSkillEnabled(target, editing.path)) {
setSkillEnabled(target, editing.path, false)
setSkillEnabled(target, path, true)
}
} else {
await saveSkillResource(
target,
path,
validated.skill.description,
validated.skill.instructions
)
// Authoring a skill is the act of choosing it.
setSkillEnabled(target, path, true)
}
editorOpen = false
await refresh(target)
sendUserToast(editing ? `Saved ${path}` : `Added ${path}`)
} catch (e) {
sendUserToast(`Failed to save skill: ${e.body ?? e.message}`, true)
} finally {
saving = false
}
}
async function remove(skill: Row) {
if (blockedByPendingFork()) return
const target = ws
try {
await deleteSkillResource(target, skill.path)
// A later skill at this path is a different one; it must be turned on
// deliberately rather than inherit this one's selection.
setSkillEnabled(target, skill.path, false)
sendUserToast(`Deleted ${skill.path}`)
await refresh(target)
} catch (e) {
sendUserToast(`Failed to delete skill: ${e.body ?? e.message}`, true)
}
}
async function refresh(target = ws) {
await loadSkills(target)
// Same reason: refreshing the chat with a workspace it has since left would
// advertise A's skills to a session now acting on B.
if (target !== ws) return
await aiChatManager.refreshGlobalSkills(target)
}
/** Where the file sat in the chosen folder. Clicking through sets
* `webkitRelativePath`; dropping sets `path`, which the picker's tree walk fills. */
function relativePathOf(f: File & { path?: string }): string {
return f.webkitRelativePath || f.path || f.name
}
/**
* Filter a folder's files down to in-depth SKILL.md, read them, and stage the
* result for confirmation.
*/
async function processFolderFiles(files: File[]) {
if (blockedByPendingFork()) return
// Pick SKILL.md files within the depth limit BEFORE reading any content,
// so a huge tree never gets read in full.
const skipped: string[] = []
const eligible: File[] = []
for (const f of files) {
const filePath = relativePathOf(f)
const segments = filePath.split('/')
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
if (segments.length > MAX_SKILL_DEPTH) {
skipped.push(`${filePath} (nested deeper than ${MAX_SKILL_DEPTH} folder levels)`)
continue
}
eligible.push(f)
}
if (eligible.length === 0) {
sendUserToast(
`No SKILL.md found within ${MAX_SKILL_DEPTH} folder levels.${
skipped.length ? ` Skipped ${skipped.length} deeper file(s).` : ''
}`,
true
)
return
}
if (eligible.length > MAX_SKILLS_PER_IMPORT) {
sendUserToast(
`Found ${eligible.length} skills in this folder; imports are limited to ${MAX_SKILLS_PER_IMPORT} at a time.`,
true
)
return
}
const collected: SkillUpload[] = []
const parseSkipped: string[] = []
for (const f of eligible) {
const filePath = relativePathOf(f)
const segments = filePath.split('/')
// The skill's id is the folder holding its SKILL.md, which is what
// becomes the resource path's name segment.
const folderName = segments.length >= 2 ? segments[segments.length - 2] : ''
const result = parseAndValidateSkill(await f.text(), folderName)
if ('error' in result) {
parseSkipped.push(`${folderName || filePath} (${result.error})`)
continue
}
// Two folders under different parents can share a leaf name, and both
// would target one path — the second silently replacing the first.
if (collected.some((s) => s.name === result.skill.name)) {
parseSkipped.push(
`${filePath} (another skill in this folder is already named ${result.skill.name})`
)
continue
}
collected.push(result.skill)
}
const allSkipped = [...skipped, ...parseSkipped]
if (collected.length === 0) {
sendUserToast(
`No valid skill found.${allSkipped.length ? ` Skipped: ${allSkipped.join(', ')}` : ''}`,
true
)
return
}
// Confirm before writing — the import can pull in several skills at once,
// and any that collide with existing skills default to overwrite.
pendingSkipped = allSkipped
const owner = defaultOwner()
overwriteChoices = Object.fromEntries(
collected.filter((s) => existingPaths.has(`${owner}/${s.name}`)).map((s) => [s.name, true])
)
pendingImport = collected
}
async function onDirSelected(event: CustomEvent<File[] | undefined>) {
const files = event.detail ?? []
// Clearing lets the same folder be chosen again — the component keeps the
// last selection on screen otherwise, and re-picking would look inert.
importFiles = undefined
if (files.length) await processFolderFiles(files)
}
/** `overwrite` is granted only for destinations the confirmation listed as an
* existing skill. Everything else is created, never upserted: a path the user
* was told was free may hold an unrelated resource — a credential, say — and an
* upsert would replace its value and type with a skill. Losing the import of a
* name is recoverable; losing what was there is not. */
async function importSkills(
toImport: { skill: SkillUpload; overwrite: boolean }[],
skipped: string[]
) {
const target = ws
const owner = defaultOwner()
saving = true
let written = 0
const failed: string[] = []
try {
for (const { skill, overwrite } of toImport) {
const dest = `${owner}/${skill.name}`
try {
await saveSkillResource(target, dest, skill.description, skill.instructions, {
overwrite
})
setSkillEnabled(target, dest, true)
written++
} catch (e) {
failed.push(`${skill.name} (${e.body ?? e.message})`)
}
}
let message = `Added ${written} skill(s) under ${owner}`
if (skipped.length) message += `; skipped ${skipped.length}`
if (failed.length) message += `; failed: ${failed.join(', ')}`
sendUserToast(message, failed.length > 0)
await refresh(target)
} finally {
saving = false
}
}
</script>
<Drawer bind:this={drawer} size="700px">
<DrawerContent
title="Skills"
on:close={() => drawer?.closeDrawer()}
tooltip="Reusable instruction sets for this chat, stored as ai_skill resources. Turning one on is personal to you and to this workspace — the assistant only sees the ones you selected."
>
{#snippet actions()}
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: ClipboardPaste }}
disabled={saving || forkPending}
onclick={openCreate}
>
New skill
</Button>
{/snippet}
{#if listNotice}
<Alert type="warning" title="Not all skills are shown" size="xs" class="mb-4">
{listNotice}
</Alert>
{/if}
{#if forkPending}
<Alert type="info" title="This session has no workspace yet" size="xs" class="mb-4">
Skills are read-only until the first message creates this session's fork. Editing or
selecting one now would apply to the parent workspace and stop applying once the fork is
created.
</Alert>
{/if}
<FileInput
disabled={forkPending}
folderOnly
bind:files={importFiles}
on:change={onDirSelected}
class="mb-4 !py-5"
iconSize={20}
>
<span class="text-xs text-secondary">
Drop a folder of <span class="font-mono">SKILL.md</span> files to import, or click to choose
one
</span>
</FileInput>
{#if loading}
<div class="text-xs text-secondary p-4 text-center">Loading skills…</div>
{:else if loadError}
<div class="text-xs text-red-600 dark:text-red-400">
Failed to load skills: {loadError}
</div>
{:else if skills.length === 0}
<div class="rounded-md border border-dashed px-3 py-6 text-center text-xs text-secondary">
No skills in this workspace yet. Paste a SKILL.md or import a folder of them.
</div>
{:else}
<div class="flex flex-col divide-y border rounded-md bg-surface-tertiary">
{#each skills as skill (skill.path)}
<div class="flex items-center gap-3 px-4 py-3">
<BookOpen size={16} class="shrink-0 text-tertiary" />
<div class="min-w-0 grow">
<div class="text-xs font-semibold text-emphasis truncate">
{ambiguous.has(skill.name) ? skill.path : skill.name}
</div>
{#if skill.description}
<div class="text-xs text-secondary truncate">{skill.description}</div>
{/if}
</div>
<Toggle
size="xs"
disabled={forkPending}
checked={skill.enabled}
on:change={async (e) => await toggle(skill.path, e.detail)}
/>
<DropdownV2
size="sm"
items={[
{
displayName: skill.canWrite ? 'Edit' : 'View',
icon: skill.canWrite ? Pencil : Eye,
action: () => openSkill(skill, skill.canWrite ? 'edit' : 'view')
},
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
disabled: !skill.canWrite || forkPending,
action: () => (toDelete = skill)
}
]}
/>
</div>
{/each}
</div>
{/if}
<ConfirmationModal
open={toDelete !== undefined}
title="Delete skill"
confirmationText="Delete"
onConfirmed={async () => {
const skill = toDelete
toDelete = undefined
if (skill) await remove(skill)
}}
onCanceled={() => (toDelete = undefined)}
>
<span class="text-xs text-primary">
This deletes the resource at <span class="font-semibold">{toDelete?.path}</span>, so
everyone who selected it loses the skill.
</span>
</ConfirmationModal>
<ConfirmationModal
open={pendingImport !== undefined}
title="Import skills"
type="info"
confirmationText="Import"
onConfirmed={async () => {
const toImport = [
...pendingNew.map((skill) => ({ skill, overwrite: false })),
...pendingConflicts
.filter((s) => overwriteChoices[s.name])
.map((skill) => ({ skill, overwrite: true }))
]
const skipped = pendingSkipped
pendingImport = undefined
pendingSkipped = []
overwriteChoices = {}
if (toImport.length) await importSkills(toImport, skipped)
else sendUserToast('No skills imported.')
}}
onCanceled={() => {
pendingImport = undefined
pendingSkipped = []
overwriteChoices = {}
}}
>
<div class="flex flex-col gap-3 text-xs">
<span class="text-secondary">
Skills are added under <span class="font-mono">{defaultOwner()}</span>. Move one to a
shared folder from the resources page to share it.
</span>
{#if pendingNew.length}
<div>
<span class="font-medium text-primary">Add {pendingNew.length} new skill(s):</span>
<span class="font-mono text-secondary">{pendingNew.map((s) => s.name).join(', ')}</span>
</div>
{/if}
{#if pendingConflicts.length}
<div class="flex flex-col gap-1.5">
<span class="font-medium text-primary">
{pendingConflicts.length} skill(s) already exist — choose which to overwrite:
</span>
<div class="rounded-md border divide-y">
{#each pendingConflicts as conflict (conflict.name)}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<span class="font-mono truncate">{conflict.name}</span>
<Toggle
bind:checked={overwriteChoices[conflict.name]}
size="xs"
options={{ right: 'Overwrite' }}
/>
</div>
{/each}
</div>
</div>
{/if}
{#if pendingSkipped.length}
<span class="text-secondary">{pendingSkipped.length} file(s) will be skipped.</span>
{/if}
</div>
</ConfirmationModal>
</DrawerContent>
</Drawer>
<Modal2
title={editing ? editing.name : 'New skill'}
bind:isOpen={editorOpen}
fixedWidth="md"
fixedHeight="adaptive"
>
{#snippet headerRight()}
{#if editing}
<ToggleButtonGroup bind:selected={detailMode}>
{#snippet children({ item })}
<ToggleButton value="view" label="View" icon={Eye} {item} small />
<ToggleButton
value="edit"
label="Edit"
icon={Pencil}
{item}
small
disabled={!editing?.canWrite}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
{/snippet}
<div class="w-full flex flex-col gap-3">
{#if detailMode === 'view'}
{#if parsed.description}
<p class="text-xs text-secondary">{parsed.description}</p>
{/if}
<div class="border rounded-md p-3 overflow-auto max-h-[60vh] space-y-2 {markdownProse.sm}">
<Markdown md={parsed.instructions} plugins={[gfmPlugin()]} />
</div>
{:else}
<Path
bind:path
bind:error={pathError}
bind:dirty={pathDirty}
initialPath={editing?.path ?? ''}
namePlaceholder="skill"
kind="resource"
workspaceOverride={ws}
autofocus={false}
/>
<!-- The same editor `/resources` gives these resources, so a skill reads the
same in both places. `fixedOverflowWidgets` off keeps its autocomplete
inside the modal instead of clipped behind it. -->
<div class="border border-border-light rounded-md overflow-hidden">
<SimpleEditor
autoHeight
lang="md"
bind:code={content}
fixedOverflowWidgets={false}
class="min-h-24"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-2xs text-red-500 min-w-0">{contentError ?? ''}</span>
<div class="flex items-center gap-2">
<Button
onclick={resetContent}
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: RotateCcw }}
disabled={saving || !contentChanged}
title={editing ? 'Discard unsaved changes to this skill' : 'Restore the sample skill'}
>
Reset
</Button>
<Button
onclick={submitEditor}
variant="accent"
unifiedSize="sm"
startIcon={{ icon: editing ? Pencil : Plus }}
disabled={!canSave}
>
{editing ? 'Save skill' : 'Add skill'}
</Button>
</div>
</div>
{/if}
</div>
</Modal2>
@@ -0,0 +1,74 @@
import { get } from 'svelte/store'
import { userStore } from '$lib/stores'
/**
* A set of workspace-object paths the chat may act through, remembered per
* workspace and per account.
*
* Being able to read a resource is not the same as wanting the chat to use it: a
* resource in a shared folder is readable by a whole team, and each enabled entry
* costs something on every turn an MCP server puts its tool descriptions in the
* model's context and reaches an external system, a skill puts its description
* there. So an entry is off until it is turned on.
*
* Stored per browser, like the chat's other per-user preferences, but keyed by
* email as well as workspace: browser storage outlives a logout, and inheriting
* the previous account's selection would hand the next person capabilities they
* never turned on. Workspace ids cannot contain `:`, so the composite key is
* unambiguous.
*/
export type EnabledPathsPreference = {
enabledPaths: (workspace: string) => string[]
isEnabled: (workspace: string, path: string) => boolean
/** Returns false when there is no account to record the preference against, so
* a caller that just created the object can say it did not stay on. */
setEnabled: (workspace: string, path: string, enabled: boolean) => boolean
}
export function createEnabledPathsPreference(storageKey: string): EnabledPathsPreference {
function scope(workspace: string): string | undefined {
const email = get(userStore)?.email
return email ? `${workspace}:${email}` : undefined
}
function read(): Record<string, string[]> {
if (typeof localStorage === 'undefined') return {}
try {
return JSON.parse(localStorage.getItem(storageKey) ?? '{}')
} catch {
return {}
}
}
function write(all: Record<string, string[]>) {
try {
localStorage.setItem(storageKey, JSON.stringify(all))
} catch (e) {
console.error(`Failed to persist ${storageKey}`, e)
}
}
function enabledPaths(workspace: string): string[] {
const key = scope(workspace)
return key ? (read()[key] ?? []) : []
}
return {
enabledPaths,
isEnabled: (workspace, path) => enabledPaths(workspace).includes(path),
setEnabled: (workspace, path, enabled) => {
const key = scope(workspace)
if (!key) return false
const all = read()
const current = new Set(all[key] ?? [])
if (enabled) {
current.add(path)
} else {
current.delete(path)
}
all[key] = [...current]
write(all)
return true
}
}
}
@@ -210,7 +210,8 @@ vi.mock('$lib/gen', async () => {
}),
createResource: vi.fn(async () => 'created'),
updateResource: vi.fn(async () => 'updated'),
deleteResource: vi.fn(async () => 'deleted')
deleteResource: vi.fn(async () => 'deleted'),
getResourceValue: vi.fn(async () => ({ content: 'skill body' }))
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: vi.fn(async () => false),
@@ -5435,6 +5436,18 @@ describe('session-only preview tools gating', () => {
})
})
describe('read_skill', () => {
it('refuses a path the user has not selected, without reading it', async () => {
localStorage.clear()
userStore.set({ username: 'bob', email: 'bob@windmill.dev', workspace_id: WORKSPACE } as any)
const res = await callGlobalTool('read_skill', { path: 'u/someone/private-notes' })
expect(res).toContain('not one of the skills selected')
expect(vi.mocked(ResourceService.getResourceValue)).not.toHaveBeenCalled()
})
})
describe('update_user_instructions', () => {
function makeHelpers(initial = '') {
let value = initial
@@ -17,8 +17,7 @@ import {
ScriptService,
SqsTriggerService,
VariableService,
WebsocketTriggerService,
WorkspaceService
WebsocketTriggerService
} from '$lib/gen'
import { createTwoFilesPatch } from 'diff'
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
@@ -83,6 +82,16 @@ import {
} from '../flow/inlineScriptsUtils'
import { searchNpmPackagesTool } from '../script/core'
import type { McpServer } from './mcpTools'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { enabledSkillPaths } from '../skills/enabledSkills'
import {
listSkillResources,
readSkillBody,
skillNameFromPath,
truncateChars,
truncateForPrompt
} from '../skills/skillResources'
import { MAX_SKILL_DESCRIPTION_LENGTH, MAX_SKILL_INSTRUCTIONS_LENGTH } from '../skills/skillMd'
import {
getDatatableSdkReference,
getFlowPrompt,
@@ -1362,9 +1371,9 @@ Data Tables:
? `
Skills:
- Skills are reusable instruction sets curated for this workspace, each covering a specific kind of task. The available skills are listed below by name and description.
- When a user's request matches a skill's description, call read_skill with its exact name to load the full instructions BEFORE acting, then follow them.
${skills.map((s) => `- ${s.name}: ${s.description}`).join('\n')}`
- Skills are reusable instruction sets the user selected for this chat, each covering a specific kind of task. The available skills are listed below by resource path and description.
- When a user's request matches a skill's description, call read_skill with its exact path to load the full instructions BEFORE acting, then follow them.
${skills.map((s) => `- ${s.path}: ${s.description}`).join('\n')}`
: ''
}${
mcpServers.length > 0
@@ -2253,7 +2262,9 @@ function getInstructions(
}
}
export type AiSkillListItem = { name: string; description: string }
/** A skill the user turned on, as the prompt and the `/` picker see it. `path`
* is the `ai_skill` resource and the model-facing id; `name` is its basename. */
export type AiSkillListItem = { path: string; name: string; description: string }
/** Live session facts appended to the GLOBAL system prompt for session chats.
* Provided by the session runtime as a resolver (copilot must not import the
@@ -2316,15 +2327,43 @@ export function getSessionContextPromptSection(ctx: SessionPromptContext): strin
return lines.join('\n')
}
/** `/` picker entry: a workspace skill or a built-in session action. The kind
* drives the picker's category grouping; entries without one are ungrouped. */
export type ChatCommandItem = AiSkillListItem & { kind?: 'action' | 'skill' }
/** `/` picker entry: a selected skill or a built-in session action. The kind
* drives the picker's category grouping; entries without one are ungrouped.
* Only skills carry a `path` built-in actions run locally and have no resource. */
export type ChatCommandItem = {
name: string
description: string
path?: string
kind?: 'action' | 'skill'
}
/** Fetch the workspace's AI skills (name + description) for the global system prompt. */
/**
* The skills this user turned on in this workspace, for the global system prompt.
* A readable `ai_skill` resource is only a candidate enabling one is a personal
* choice, since each enabled skill spends context on every turn.
*/
export async function loadWorkspaceSkills(workspace: string): Promise<AiSkillListItem[]> {
if (!workspace) return []
try {
return await WorkspaceService.listAiSkills({ workspace })
const enabled = new Set(enabledSkillPaths(workspace))
if (enabled.size === 0) return []
// Filtered against what is actually readable now, so a skill that was
// deleted or whose folder access was revoked drops out instead of being
// advertised to the model as something read_skill can load.
// A truncated listing still carries most of the workspace, and the drawer is
// where that is surfaced; dropping everything here would silently empty the
// Skills section instead.
return (await listSkillResources(workspace)).skills
.filter((s) => enabled.has(s.path))
.map(({ path, name, description }) => ({
path,
name,
// Every description goes into the system prompt on every turn, and any
// resource of this type can be selected — including ones written through
// git sync or the resource editor, which never saw the authoring form's
// bounds. One unbounded description would crowd out the conversation.
description: truncateChars(description, MAX_SKILL_DESCRIPTION_LENGTH)
}))
} catch (e) {
console.error('Failed to load AI skills', e)
return []
@@ -2332,32 +2371,52 @@ export async function loadWorkspaceSkills(workspace: string): Promise<AiSkillLis
}
const readSkillSchema = z.object({
name: z
path: z
.string()
.describe('The exact skill name as listed in the Skills section of the system prompt.')
.describe('The exact skill resource path as listed in the Skills section of the system prompt.')
})
export const readSkillTool: Tool<{}> = {
def: createToolDef(
readSkillSchema,
'read_skill',
'Load the full instructions for a workspace AI skill by name. Skills are listed in the system prompt under "Skills"; call this before acting on a task a skill covers, then follow its instructions.'
'Load the full instructions for a selected AI skill by resource path. Skills are listed in the system prompt under "Skills"; call this before acting on a task a skill covers, then follow its instructions.'
),
planModeSafe: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = readSkillSchema.parse(args)
toolCallbacks.setToolStatus(toolId, { content: `Reading skill "${parsed.name}"...` })
const name = skillNameFromPath(parsed.path)
// The prompt lists only selected skills, but the tool takes a path the model
// composed, so the selection is enforced here too rather than assumed. Without
// it the tool reads any resource holding a string `content` — the user's own
// access, but not what "load a selected skill" says it does.
if (!enabledSkillPaths(workspace).includes(parsed.path)) {
toolCallbacks.setToolStatus(toolId, { content: `Skill "${name}" is not selected` })
return `"${parsed.path}" is not one of the skills selected for this chat. Only the paths listed under "Skills" in the system prompt can be read.`
}
toolCallbacks.setToolStatus(toolId, { content: `Reading skill "${name}"...` })
try {
const skill = await WorkspaceService.getAiSkill({ workspace, name: parsed.name })
toolCallbacks.setToolStatus(toolId, { content: `Read skill "${parsed.name}"` })
return `Skill: ${skill.name}\nDescription: ${skill.description}\n\nInstructions:\n${skill.instructions}`
// Bounded here rather than in the reader: any `ai_skill` resource can be
// selected, including ones written through git sync or the resource editor
// that never passed the authoring form's limits, and an unbounded body
// would exhaust the context on one tool call. The editor reads the same
// resource untruncated, so opening a long skill cannot rewrite it short.
const instructions = truncateForPrompt(
await readSkillBody(workspace, parsed.path),
MAX_SKILL_INSTRUCTIONS_LENGTH
)
toolCallbacks.setToolStatus(toolId, { content: `Read skill "${name}"` })
// Whether a selected skill is actually reached for. No key: the path is
// workspace-authored text.
logFeatureUsage('ai_session', 'skill_read', { workspace })
return `Skill: ${parsed.path}\n\nInstructions:\n${instructions}`
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
toolCallbacks.setToolStatus(toolId, {
content: `Error reading skill "${parsed.name}"`,
content: `Error reading skill "${name}"`,
error: msg
})
return `Failed to read skill "${parsed.name}": ${msg}. Check the name against the Skills list in the system prompt.`
return `Failed to read skill "${parsed.path}": ${msg}. Check the path against the Skills list in the system prompt.`
}
}
}
@@ -9,8 +9,8 @@
*
* When the beta ends, replace every call to `isGlobalAiEnabled()` with `true`
* and delete this file. The references are intentionally narrow (chat mode
* visibility, custom prompt settings, the `change_mode` tool enum, and the
* AI skills workspace settings tab) so the rip-out is a small grep.
* visibility, custom prompt settings, and the `change_mode` tool enum) so the
* rip-out is a small grep.
*/
import { logFeatureUsage } from '$lib/utils/featureUsage'
@@ -0,0 +1,10 @@
import { createEnabledPathsPreference } from '../enabledPathsPreference'
/** Which `ai_skill` resources the chat may follow, per workspace and per account.
* Every enabled skill spends context on every turn, so selecting one is a personal
* choice rather than a consequence of being able to read it. */
const preference = createEnabledPathsPreference('wm_skills_enabled')
export const enabledSkillPaths = preference.enabledPaths
export const isSkillEnabled = preference.isEnabled
export const setSkillEnabled = preference.setEnabled
@@ -8,7 +8,7 @@ import {
parseAndValidateSkill,
parseSkillMd,
validateSkill
} from './aiSkills'
} from './skillMd'
describe('parseSkillMd', () => {
it('splits frontmatter name/description from the body', () => {
@@ -1,10 +1,13 @@
import YAML from 'yaml'
import { z } from 'zod'
/** A SKILL.md split into the three parts a `skills` resource stores: `name`
* becomes the resource path's basename, `description` its description column,
* `instructions` its file body. */
export type SkillUpload = { name: string; description: string; instructions: string }
// `name` + `description` mirror the Claude SKILL.md spec (counted in characters);
// the body is a byte-bounded payload. Keep these in sync with backend `validate_skill`.
// `name` + `description` mirror the Claude SKILL.md spec (counted in characters),
// so a skill stays portable with Claude Code; the body is a byte-bounded payload.
export const MAX_SKILL_NAME_LENGTH = 64
export const MAX_SKILL_DESCRIPTION_LENGTH = 1_024
export const MAX_SKILL_INSTRUCTIONS_LENGTH = 64 * 1024
@@ -12,8 +15,8 @@ export const MAX_SKILL_INSTRUCTIONS_LENGTH = 64 * 1024
const textEncoder = new TextEncoder()
// Single source of truth for skill field validation, shared by the paste/edit
// modal and the folder importer. Lengths are code-point / byte bounded to match
// the backend, so `.refine` (not `.max`, which counts UTF-16 units) is used.
// modal and the folder importer. Lengths are code-point / byte bounded, so
// `.refine` (not `.max`, which counts UTF-16 units) is used.
export const skillSchema = z.object({
name: z
.string()
@@ -0,0 +1,159 @@
import { ResourceService } from '$lib/gen'
import { canWrite } from '$lib/utils'
import type { UserExt } from '$lib/stores'
/**
* Skills are resources of this type: a file resource (`format_extension = 'md'`)
* whose `value.content` is the SKILL.md body, whose description column is what the
* assistant reads when deciding the skill applies, and whose path names it.
*/
export const SKILLS_RESOURCE_TYPE = 'ai_skill'
/** A skill as the picker and the system prompt see it never the body, which
* `read_skill` fetches only once the model commits to using the skill. */
export type SkillResource = {
path: string
/** Path basename: what the `/` command and the picker row show. */
name: string
description: string
editedAt?: string
canWrite: boolean
}
/** The `/`-command and display name for a skill. Paths are `[ufg]/x/y`, so the
* last segment is always present. */
export function skillNameFromPath(path: string): string {
return path.split('/').pop() ?? path
}
/** Basenames carried by more than one of these skills. Two folders can each hold
* a `deploy`, and then the name alone no longer says which one the picker shows
* the path for these, and the `/` command refuses to guess. */
export function ambiguousSkillNames(skills: readonly { name: string }[]): Set<string> {
const seen = new Map<string, number>()
for (const s of skills) seen.set(s.name, (seen.get(s.name) ?? 0) + 1)
return new Set([...seen].filter(([, n]) => n > 1).map(([name]) => name))
}
const SKILLS_PAGE_SIZE = 100
/** Pages to walk before giving up. Ordinary resources and repeated imports can
* make any number of skills, and a single page would drop the rest including a
* selected one, which would then vanish from the prompt with nothing to explain
* it. The bound is a guard against a paging bug looping forever, not a product
* cap, so reaching it is reported rather than passed off as the whole set. */
const MAX_SKILLS_PAGES = 100
/** The rows read, and whether the walk stopped at the bound rather than the end.
* Reported rather than thrown: a truncated read is still most of the skills, and
* dropping them all would take every selected skill out of the prompt at once. */
export type SkillListing = { skills: SkillResource[]; truncated: boolean }
/** Every skill resource readable in the workspace.
*
* `user` decides which rows the drawer offers to edit rather than only view; pass
* the account the workspace is being browsed as. Ownership is mostly implicit in
* the path (`u/<me>/…`, a folder the user owns), which is why this goes through
* the shared `canWrite` rather than reading `extra_perms` alone. */
export async function listSkillResources(
workspace: string,
user?: UserExt
): Promise<SkillListing> {
if (!workspace) return { skills: [], truncated: false }
const rows: SkillResource[] = []
for (let page = 1; page <= MAX_SKILLS_PAGES; page++) {
const resources = await ResourceService.listResource({
workspace,
resourceType: SKILLS_RESOURCE_TYPE,
page,
perPage: SKILLS_PAGE_SIZE
})
rows.push(
...resources.map((r) => ({
path: r.path,
name: skillNameFromPath(r.path),
description: r.description ?? '',
editedAt: r.edited_at,
canWrite: canWrite(r.path, r.extra_perms ?? {}, user)
}))
)
if (resources.length < SKILLS_PAGE_SIZE) return { skills: rows, truncated: false }
}
return { skills: rows, truncated: true }
}
/** Cut `text` to `maxChars` code points. For the description, whose cap is stated
* in characters cutting that one by bytes would reduce a legal 1,024-character
* CJK description to about a third of itself. */
export function truncateChars(text: string, maxChars: number): string {
const points = [...text]
return points.length <= maxChars ? text : `${points.slice(0, maxChars).join('')}… [truncated]`
}
/** Cut `text` to `maxBytes` of UTF-8, marking the cut so a reader (the model
* included) can tell truncation from a body that simply ends there.
*
* For the body, whose cap is a byte budget: 64k CJK characters are ~192 KiB, so a
* code-unit cut would let three times the intended payload through. */
export function truncateForPrompt(text: string, maxBytes: number): string {
const encoded = new TextEncoder().encode(text)
if (encoded.byteLength <= maxBytes) return text
// `fatal: false` replaces the partial code point a byte-aligned cut can leave
// with U+FFFD; dropping it keeps the tail clean.
const cut = new TextDecoder('utf-8').decode(encoded.slice(0, maxBytes)).replace(/$/, '')
return `${cut}… [truncated]`
}
/** The SKILL.md body of one skill. Throws rather than returning `''` when the
* resource holds no readable body: an empty string reaches the model as a
* successful read of a skill with no instructions, which it would then act on.
*
* Deliberately unbounded the editor loads through here and saves what it loaded,
* so truncating would rewrite an over-long skill the first time someone opened it.
* Bounding belongs at the prompt boundary, where the cost actually is. */
export async function readSkillBody(workspace: string, path: string): Promise<string> {
const value = (await ResourceService.getResourceValue({ workspace, path })) as
| { content?: unknown }
| undefined
if (typeof value?.content !== 'string') {
throw new Error(`resource ${path} has no string "content" — is it an ${SKILLS_RESOURCE_TYPE}?`)
}
return value.content
}
export async function saveSkillResource(
workspace: string,
path: string,
description: string,
instructions: string,
{ overwrite = false }: { overwrite?: boolean } = {}
): Promise<void> {
await ResourceService.createResource({
workspace,
updateIfExists: overwrite,
requestBody: {
path,
description,
value: { content: instructions },
resource_type: SKILLS_RESOURCE_TYPE
}
})
}
/** Save an edit to an existing skill, moving it when the path changed. */
export async function updateSkillResource(
workspace: string,
currentPath: string,
path: string,
description: string,
instructions: string
): Promise<void> {
await ResourceService.updateResource({
workspace,
path: currentPath,
requestBody: { path, description, value: { content: instructions } }
})
}
export async function deleteSkillResource(workspace: string, path: string): Promise<void> {
await ResourceService.deleteResource({ workspace, path })
}
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { session } = vi.hoisted(() => ({
session: { email: 'first@windmill.dev' } as { email?: string }
}))
vi.mock('$lib/stores', () => ({
// Read at call time, so a test can switch accounts the way a logout does.
userStore: { subscribe: (run: (v: unknown) => void) => (run({ ...session }), () => {}) }
}))
import { enabledSkillPaths, isSkillEnabled, setSkillEnabled } from './enabledSkills'
import { ambiguousSkillNames, truncateChars, truncateForPrompt } from './skillResources'
describe('enabledSkills', () => {
beforeEach(() => {
localStorage.clear()
session.email = 'first@windmill.dev'
})
it('keeps the selection separate per workspace', () => {
setSkillEnabled('ws_a', 'u/me/deploy', true)
expect(isSkillEnabled('ws_a', 'u/me/deploy')).toBe(true)
expect(isSkillEnabled('ws_b', 'u/me/deploy')).toBe(false)
})
it('does not hand the next account the previous ones selection', () => {
setSkillEnabled('ws_a', 'u/me/deploy', true)
session.email = 'second@windmill.dev'
expect(enabledSkillPaths('ws_a')).toEqual([])
})
it('reports failure when there is no account to record the choice against', () => {
session.email = undefined
expect(setSkillEnabled('ws_a', 'u/me/deploy', true)).toBe(false)
expect(enabledSkillPaths('ws_a')).toEqual([])
})
})
describe('skill names', () => {
it('flags a basename two folders both use, so /name is not resolved by chance', () => {
const ambiguous = ambiguousSkillNames([
{ name: 'deploy' },
{ name: 'deploy' },
{ name: 'release' }
])
expect([...ambiguous]).toEqual(['deploy'])
})
})
describe('prompt truncation', () => {
// The two caps are stated in different units, and using one truncator for both
// either lets three times the payload through or cuts a legal value to a third.
it('bounds a skill body by utf-8 bytes, not code units', () => {
const body = '漢'.repeat(100) // 300 bytes
expect(truncateForPrompt(body, 3000)).toBe(body)
const cut = truncateForPrompt(body, 30)
expect(new TextEncoder().encode(cut.replace('… [truncated]', '')).byteLength).toBeLessThanOrEqual(30)
expect(cut).toContain('[truncated]')
// A byte-aligned cut must not leave a broken code point behind.
expect(cut).not.toContain('\ufffd')
})
it('bounds a description by code points, so a CJK one is not cut to a third', () => {
const description = '漢'.repeat(100)
expect(truncateChars(description, 100)).toBe(description)
expect([...truncateChars(description, 10)].slice(0, 10).join('')).toBe('漢'.repeat(10))
})
})
@@ -1,66 +1,11 @@
import { get } from 'svelte/store'
import { userStore } from '$lib/stores'
import { createEnabledPathsPreference } from '$lib/components/copilot/chat/enabledPathsPreference'
/**
* Which MCP servers the chat may use, per workspace and per account.
*
* Being able to read an `mcp` resource is not the same as wanting the chat to
* act through it: a resource in a shared folder is readable by a whole team, and
* each server's tools both reach an external system and put their descriptions
* in the model's context. So a server is off until it is turned on here, and
* connecting one through the chat turns it on for the person who connected it.
*
* Stored per browser, like the chat's other per-user preferences, but keyed by
* email as well as workspace: browser storage outlives a logout, and inheriting
* the previous account's enabled servers would hand the next person tools they
* never turned on.
*/
const KEY = 'wm_mcp_enabled'
/** Which MCP servers the chat may act through, per workspace and per account. A
* server's tools both reach an external system and put their descriptions in the
* model's context, so one is off until it is turned on; connecting one through the
* chat turns it on for the person who connected it. */
const preference = createEnabledPathsPreference('wm_mcp_enabled')
function scope(workspace: string): string | undefined {
const email = get(userStore)?.email
return email ? `${workspace}:${email}` : undefined
}
function read(): Record<string, string[]> {
if (typeof localStorage === 'undefined') return {}
try {
return JSON.parse(localStorage.getItem(KEY) ?? '{}')
} catch {
return {}
}
}
function write(all: Record<string, string[]>) {
try {
localStorage.setItem(KEY, JSON.stringify(all))
} catch (e) {
console.error('Failed to persist enabled MCP servers', e)
}
}
export function enabledMcpPaths(workspace: string): string[] {
const key = scope(workspace)
return key ? (read()[key] ?? []) : []
}
export function isMcpEnabled(workspace: string, path: string): boolean {
return enabledMcpPaths(workspace).includes(path)
}
/** Returns false when there is no account to record the preference against, so a
* caller that just connected a server can say it did not stay on. */
export function setMcpEnabled(workspace: string, path: string, enabled: boolean): boolean {
const key = scope(workspace)
if (!key) return false
const all = read()
const current = new Set(all[key] ?? [])
if (enabled) {
current.add(path)
} else {
current.delete(path)
}
all[key] = [...current]
write(all)
return true
}
export const enabledMcpPaths = preference.enabledPaths
export const isMcpEnabled = preference.isEnabled
export const setMcpEnabled = preference.setEnabled
@@ -15,8 +15,6 @@
import { supportsAutocomplete } from '../copilot/utils'
import TestAiKey from '../copilot/TestAIKey.svelte'
import Label from '../Label.svelte'
import AiSkillsSettings from './AiSkillsSettings.svelte'
import { isGlobalAiEnabled } from '../copilot/chat/global/gate'
import SettingsPageHeader from '../settings/SettingsPageHeader.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import Toggle from '../Toggle.svelte'
@@ -607,10 +605,6 @@
</div>
</SettingCard>
{/if}
{#if promptScope === 'workspace' && isGlobalAiEnabled()}
<AiSkillsSettings />
{/if}
</div>
<AIPromptsModal
@@ -1,658 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { createDropdownMenu, melt } from '@melt-ui/svelte'
import Button from '../common/button/Button.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import Modal2 from '../common/modal/Modal2.svelte'
import Toggle from '../Toggle.svelte'
import DropdownV2 from '../DropdownV2.svelte'
import Checkbox from '../common/checkbox/Checkbox.svelte'
import Markdown from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { markdownProse } from '$lib/components/markdownProse'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import SettingCard from '../instanceSettings/SettingCard.svelte'
import autosize from '$lib/autosize'
import { conditionalMelt } from '$lib/utils'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { WorkspaceService } from '$lib/gen'
import { buildSkillMd, parseAndValidateSkill, parseSkillMd, type SkillUpload } from './aiSkills'
import {
ChevronDown,
ClipboardPaste,
Eye,
FolderUp,
ListChecks,
Pencil,
Plus,
Trash2
} from 'lucide-svelte'
type SkillListItem = { name: string; description: string }
// `<root>/<skill>/SKILL.md` is 3 path segments; SKILL.md files nested deeper
// are likely vendored/incidental and are skipped so importing a parent dir
// doesn't sweep in unrelated skills.
const MAX_SKILL_DEPTH = 3
const MAX_SKILLS_PER_IMPORT = 50
const MAX_SKILLS_PER_WORKSPACE = 100
const SAMPLE_SKILL_PLACEHOLDER =
'---\nname: my-skill\ndescription: what this skill helps with\n---\n\n# My skill\n\nInstructions for the assistant…'
const menuItemClass =
'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'
let skills: SkillListItem[] = $state([])
let uploading: boolean = $state(false)
let pasteContent: string = $state('')
// The content the modal opened with, so Save can be gated on unsaved changes.
let originalContent: string = $state('')
let pasteModalOpen: boolean = $state(false)
// Set while the paste modal is editing an existing skill; holds the skill's
// name before edits so a rename can delete the old entry after save.
let editingOriginalName: string | undefined = $state(undefined)
let dirInput: HTMLInputElement | undefined = $state(undefined)
let toDelete: string | undefined = $state(undefined)
let pendingImport: SkillUpload[] | undefined = $state(undefined)
let pendingSkipped: string[] = $state([])
// Per-conflict overwrite choice for a folder import, keyed by skill name.
let overwriteChoices: Record<string, boolean> = $state({})
// The skill detail modal opens in read mode with rendered markdown; a header
// toggle flips it to raw SKILL.md editing.
let detailMode: 'view' | 'edit' = $state('view')
// Multi-select "manage" mode: rows gain a checkbox for batch deletion.
let manageMode: boolean = $state(false)
let selected: Record<string, boolean> = $state({})
let confirmBatchDelete: boolean = $state(false)
let listRequestId = 0
let existingNames = $derived(new Set(skills.map((s) => s.name)))
let selectedCount = $derived(skills.filter((s) => selected[s.name]).length)
let allSelected = $derived(skills.length > 0 && selectedCount === skills.length)
// Leave manage mode automatically once a batch delete empties it below the
// two-skill threshold that surfaces the "Manage skills" button.
$effect(() => {
if (manageMode && skills.length <= 1) exitManage()
})
let pendingConflicts = $derived(
(pendingImport ?? ([] as SkillUpload[])).filter((s) => existingNames.has(s.name))
)
let pendingNew = $derived(
(pendingImport ?? ([] as SkillUpload[])).filter((s) => !existingNames.has(s.name))
)
// Parsed view of the modal's raw content, for rendering the skill in read mode.
let viewParsed = $derived(parseSkillMd(pasteContent))
let isDirty = $derived(pasteContent !== originalContent)
// Validate through the shared schema; surfaced inline so Save can be gated
// without a toast.
let pasteResult = $derived(parseAndValidateSkill(pasteContent))
let pasteError = $derived('error' in pasteResult ? pasteResult.error : undefined)
// Reset edit mode whenever the paste modal closes so a later "Paste a skill"
// opens a blank creation form.
$effect(() => {
if (!pasteModalOpen) editingOriginalName = undefined
})
// melt dropdown for the "+ Add skills" button: arrow-key nav, outside/escape
// close and focus management come for free.
const {
elements: { trigger: addMenuTrigger, menu: addMenu, item: addMenuItem },
states: { open: addMenuOpen }
} = createDropdownMenu({
positioning: { placement: 'bottom-end', gutter: 4, fitViewport: true },
loop: true,
forceVisible: true
})
// attach the menu trigger to the design-system <Button>'s DOM node so it keeps
// its styling — melt element stores are callable on a node like `use:melt`.
let addTriggerEl: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined)
$effect(() => {
const el = addTriggerEl
if (!el) return
const applied = conditionalMelt(el, addMenuTrigger as any) as { destroy?: () => void }
return applied?.destroy
})
async function loadList(workspace: string | undefined) {
const requestId = ++listRequestId
if (!workspace) {
skills = []
return
}
try {
const loaded = await WorkspaceService.listAiSkills({ workspace })
if (requestId === listRequestId && workspace === $workspaceStore) {
skills = loaded
}
} catch (e) {
if (requestId === listRequestId && workspace === $workspaceStore) {
sendUserToast(`Failed to load skills: ${e}`, true)
}
}
}
/**
* Turn a map of `relativePath -> content` (from an imported folder) into skills.
* A skill is any `SKILL.md`; its id is the name of the folder holding it.
*/
function collectSkills(files: Record<string, string>): {
skills: SkillUpload[]
skipped: string[]
} {
const collected: SkillUpload[] = []
const skipped: string[] = []
for (const [path, content] of Object.entries(files)) {
const segments = path.split('/')
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
const folderName = segments.length >= 2 ? segments[segments.length - 2] : ''
const result = parseAndValidateSkill(content, folderName)
if ('error' in result) {
skipped.push(`${folderName || path} (${result.error})`)
} else {
collected.push(result.skill)
}
}
return { skills: collected, skipped }
}
async function uploadSkills(parsed: SkillUpload[], skipped: string[] = []) {
const workspace = $workspaceStore
if (!workspace || parsed.length === 0) {
sendUserToast(
`No valid skill found.${skipped.length ? ` Skipped: ${skipped.join(', ')}` : ''}`,
true
)
return false
}
if (parsed.length > MAX_SKILLS_PER_IMPORT) {
sendUserToast(`Cannot add more than ${MAX_SKILLS_PER_IMPORT} skills at a time.`, true)
return false
}
// Uploads upsert, so only names not already stored count toward the cap.
const newCount = parsed.filter((s) => !existingNames.has(s.name)).length
if (skills.length + newCount > MAX_SKILLS_PER_WORKSPACE) {
sendUserToast(`This workspace can store at most ${MAX_SKILLS_PER_WORKSPACE} skills.`, true)
return false
}
uploading = true
try {
await WorkspaceService.uploadAiSkills({
workspace,
requestBody: { skills: parsed }
})
let message = `Added ${parsed.length} skill(s)`
if (skipped.length) message += `; skipped ${skipped.length}: ${skipped.join(', ')}`
sendUserToast(message)
await loadList(workspace)
return true
} catch (e) {
sendUserToast(`Failed to add skills: ${e}`, true)
return false
} finally {
uploading = false
}
}
function openPaste() {
editingOriginalName = undefined
pasteContent = ''
originalContent = ''
detailMode = 'edit'
pasteModalOpen = true
}
async function openSkill(name: string, mode: 'view' | 'edit') {
const workspace = $workspaceStore
if (!workspace) return
try {
const skill = await WorkspaceService.getAiSkill({ workspace, name })
pasteContent = buildSkillMd(skill)
originalContent = pasteContent
editingOriginalName = name
detailMode = mode
pasteModalOpen = true
} catch (e) {
sendUserToast(`Failed to load skill: ${e}`, true)
}
}
async function submitPastedSkill() {
// Guarded by `pasteError` disabling the button; bail defensively if reached.
if (!('skill' in pasteResult)) return
const parsed = pasteResult.skill
const renamedFrom = editingOriginalName
if (await uploadSkills([parsed])) {
// A rename saves under the new name; drop the old entry so it doesn't linger.
if (renamedFrom && renamedFrom !== parsed.name) {
await deleteSkill(renamedFrom, { silent: true })
}
pasteContent = ''
pasteModalOpen = false
}
}
/**
* Filter a folder's files down to in-depth SKILL.md, read them, and stage the
* result for confirmation.
*/
async function processFolderFiles(files: File[]) {
// Pick SKILL.md files within the depth limit BEFORE reading any content,
// so a huge tree never gets read in full.
const skipped: string[] = []
const eligible: File[] = []
for (const f of files) {
const path = f.webkitRelativePath || f.name
const segments = path.split('/')
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
if (segments.length > MAX_SKILL_DEPTH) {
skipped.push(`${path} (nested deeper than ${MAX_SKILL_DEPTH} folder levels)`)
continue
}
eligible.push(f)
}
if (eligible.length === 0) {
sendUserToast(
`No SKILL.md found within ${MAX_SKILL_DEPTH} folder levels.${
skipped.length ? ` Skipped ${skipped.length} deeper file(s).` : ''
}`,
true
)
return
}
if (eligible.length > MAX_SKILLS_PER_IMPORT) {
sendUserToast(
`Found ${eligible.length} skills in this folder; imports are limited to ${MAX_SKILLS_PER_IMPORT} at a time.`,
true
)
return
}
const map: Record<string, string> = {}
for (const f of eligible) {
map[f.webkitRelativePath || f.name] = await f.text()
}
const { skills: parsed, skipped: parseSkipped } = collectSkills(map)
const allSkipped = [...skipped, ...parseSkipped]
if (parsed.length === 0) {
sendUserToast(
`No valid skill found.${allSkipped.length ? ` Skipped: ${allSkipped.join(', ')}` : ''}`,
true
)
return
}
// Confirm before writing — the import can pull in several skills at once,
// and any that collide with existing skills default to overwrite.
pendingSkipped = allSkipped
overwriteChoices = Object.fromEntries(
parsed.filter((s) => existingNames.has(s.name)).map((s) => [s.name, true])
)
pendingImport = parsed
}
async function onDirSelected(event: Event) {
const target = event.target as HTMLInputElement
const files = Array.from(target.files ?? [])
// Reset early so re-selecting the same folder re-fires `change`.
if (dirInput) dirInput.value = ''
await processFolderFiles(files)
}
async function deleteSkill(name: string, opts: { silent?: boolean } = {}) {
const workspace = $workspaceStore
if (!workspace) return
try {
await WorkspaceService.deleteAiSkill({ workspace, name })
if (!opts.silent) sendUserToast(`Deleted skill ${name}`)
await loadList(workspace)
} catch (e) {
sendUserToast(`Failed to delete skill: ${e}`, true)
}
}
function exitManage() {
manageMode = false
selected = {}
}
// Escape leaves manage mode, mirroring the "Done" button — but only when no
// modal/menu is open, so it doesn't steal Escape from them.
function onWindowKeydown(e: KeyboardEvent) {
if (
e.key === 'Escape' &&
manageMode &&
!pasteModalOpen &&
!confirmBatchDelete &&
!$addMenuOpen &&
toDelete === undefined &&
pendingImport === undefined
) {
exitManage()
}
}
function toggleSelect(name: string) {
selected = { ...selected, [name]: !selected[name] }
}
function toggleSelectAll() {
selected = allSelected ? {} : Object.fromEntries(skills.map((s) => [s.name, true]))
}
async function deleteSelected() {
const workspace = $workspaceStore
const names = skills.filter((s) => selected[s.name]).map((s) => s.name)
if (!workspace || names.length === 0) return
uploading = true
try {
for (const name of names) {
await WorkspaceService.deleteAiSkill({ workspace, name })
}
sendUserToast(`Deleted ${names.length} skill(s)`)
exitManage()
} catch (e) {
sendUserToast(`Failed to delete skills: ${e}`, true)
} finally {
uploading = false
await loadList(workspace)
}
}
onMount(() => {
return workspaceStore.subscribe((workspace) => {
toDelete = undefined
pendingImport = undefined
pendingSkipped = []
overwriteChoices = {}
exitManage()
void loadList(workspace)
})
})
</script>
<svelte:window onkeydown={onWindowKeydown} />
{#snippet pasteZone()}
<textarea
bind:value={pasteContent}
placeholder={SAMPLE_SKILL_PLACEHOLDER}
class="w-full min-h-24 p-2 border border-border-light rounded-md bg-surface text-primary font-mono text-xs resize-y"
rows="5"
use:autosize
></textarea>
{#if editingOriginalName || pasteContent.trim()}
<div class="flex items-center justify-between gap-2 mt-2">
<span class="text-2xs text-red-500 min-w-0">{isDirty && pasteError ? pasteError : ''}</span>
<Button
onclick={submitPastedSkill}
variant="accent"
unifiedSize="sm"
startIcon={{ icon: editingOriginalName ? Pencil : Plus }}
disabled={uploading || !isDirty || !!pasteError}
>
{editingOriginalName ? 'Save skill' : 'Add skill'}
</Button>
</div>
{/if}
{/snippet}
<SettingCard
label="Custom skills"
description="Add your own skills to the AI Chat. The expected format is the same as Claude or Codex."
>
{#snippet headerAction()}
<div class="flex items-center gap-2">
{#if manageMode}
<Button
variant="accent"
destructive
unifiedSize="sm"
startIcon={{ icon: Trash2 }}
disabled={selectedCount === 0 || uploading}
onclick={() => (confirmBatchDelete = true)}
>
Delete{selectedCount ? ` (${selectedCount})` : ''}
</Button>
<Button variant="default" unifiedSize="sm" disabled={uploading} onclick={exitManage}>
Done
</Button>
{:else}
{#if skills.length > 1}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: ListChecks }}
disabled={uploading}
onclick={() => (manageMode = true)}
>
Manage skills
</Button>
{/if}
<Button
bind:element={addTriggerEl}
{...$addMenuTrigger}
variant="default"
unifiedSize="sm"
startIcon={{ icon: Plus }}
endIcon={{ icon: ChevronDown }}
disabled={uploading}
>
Add skills
</Button>
{/if}
</div>
{#if $addMenuOpen}
<div
use:melt={$addMenu}
class="z-[6000] flex flex-col gap-0.5 p-1 w-64 rounded-lg border border-border-light bg-surface shadow-xl focus:outline-none"
>
<button use:melt={$addMenuItem} class={menuItemClass} onclick={() => dirInput?.click()}>
<FolderUp size={16} class="shrink-0 text-tertiary" />
<span class="text-xs font-medium text-primary">Import a folder of skills</span>
</button>
<button use:melt={$addMenuItem} class={menuItemClass} onclick={openPaste}>
<ClipboardPaste size={16} class="shrink-0 text-tertiary" />
<span class="text-xs font-medium text-primary">Paste a skill</span>
</button>
</div>
{/if}
{/snippet}
<div class="flex flex-col gap-3 pt-1">
{#if skills.length === 0}
<div class="rounded-md border border-dashed px-3 py-6 text-center text-xs text-secondary">
No custom skills yet
</div>
{:else}
<div class="rounded-md border divide-y max-h-96 overflow-y-auto">
{#if manageMode}
<div class="sticky top-0 z-10 flex items-center gap-3 px-3 py-2 bg-surface-secondary">
<Checkbox
checked={allSelected}
indeterminate={selectedCount > 0 && !allSelected}
onChange={toggleSelectAll}
/>
<span class="text-2xs text-secondary">
{selectedCount ? `${selectedCount} selected` : 'Select all'}
</span>
</div>
{/if}
{#each skills as skill (skill.name)}
<div class="flex items-center justify-between gap-4 px-3 py-2">
{#if manageMode}
<label class="flex items-center gap-3 min-w-0 grow cursor-pointer">
<Checkbox
checked={!!selected[skill.name]}
onChange={() => toggleSelect(skill.name)}
/>
<div class="min-w-0">
<div class="text-xs font-mono truncate">{skill.name}</div>
<div class="text-2xs text-secondary truncate">{skill.description}</div>
</div>
</label>
{:else}
<div class="min-w-0">
<div class="text-xs font-mono truncate">{skill.name}</div>
<div class="text-2xs text-secondary truncate">{skill.description}</div>
<Button
onclick={() => openSkill(skill.name, 'view')}
variant="subtle"
unifiedSize="2xs"
wrapperClasses="w-fit mt-0.5"
btnClasses="!px-0 text-2xs font-normal text-secondary hover:text-primary hover:!bg-transparent"
>
Show more
</Button>
</div>
<DropdownV2
size="sm"
items={[
{
displayName: 'Edit',
icon: Pencil,
disabled: uploading,
action: () => openSkill(skill.name, 'edit')
},
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
action: () => (toDelete = skill.name)
}
]}
/>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</SettingCard>
<!-- Hidden folder picker fired by the dropdown's "Import a folder of skills". -->
<input
bind:this={dirInput}
type="file"
style="display: none;"
onchange={onDirSelected}
{...{ webkitdirectory: true, directory: true }}
/>
<Modal2
title={editingOriginalName ?? 'Paste a skill'}
bind:isOpen={pasteModalOpen}
fixedWidth="md"
fixedHeight="adaptive"
>
{#snippet headerRight()}
{#if editingOriginalName}
<ToggleButtonGroup bind:selected={detailMode}>
{#snippet children({ item })}
<ToggleButton value="view" label="View" icon={Eye} {item} small />
<ToggleButton value="edit" label="Edit" icon={Pencil} {item} small />
{/snippet}
</ToggleButtonGroup>
{/if}
{/snippet}
<div class="w-full flex flex-col">
{#if detailMode === 'view'}
<div class="w-full flex flex-col gap-3">
{#if viewParsed.description}
<p class="text-xs text-secondary">{viewParsed.description}</p>
{/if}
<div class="border rounded-md p-3 overflow-auto max-h-[60vh] space-y-2 {markdownProse.sm}">
<Markdown md={viewParsed.instructions} plugins={[gfmPlugin()]} />
</div>
</div>
{:else}
{@render pasteZone()}
{/if}
</div>
</Modal2>
<ConfirmationModal
open={pendingImport !== undefined}
title="Import skills"
type="info"
confirmationText="Import"
onConfirmed={async () => {
const toImport = [...pendingNew, ...pendingConflicts.filter((s) => overwriteChoices[s.name])]
const skipped = pendingSkipped
pendingImport = undefined
pendingSkipped = []
overwriteChoices = {}
if (toImport.length) await uploadSkills(toImport, skipped)
else sendUserToast('No skills imported.')
}}
onCanceled={() => {
pendingImport = undefined
pendingSkipped = []
overwriteChoices = {}
}}
>
<div class="flex flex-col gap-3 text-xs">
{#if pendingNew.length}
<div>
<span class="font-medium text-primary">Add {pendingNew.length} new skill(s):</span>
<span class="font-mono text-secondary">{pendingNew.map((s) => s.name).join(', ')}</span>
</div>
{/if}
{#if pendingConflicts.length}
<div class="flex flex-col gap-1.5">
<span class="font-medium text-primary">
{pendingConflicts.length} skill(s) already exist — choose which to overwrite:
</span>
<div class="rounded-md border divide-y">
{#each pendingConflicts as conflict (conflict.name)}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<span class="font-mono truncate">{conflict.name}</span>
<Toggle
bind:checked={overwriteChoices[conflict.name]}
size="xs"
options={{ right: 'Overwrite' }}
/>
</div>
{/each}
</div>
</div>
{/if}
{#if pendingSkipped.length}
<span class="text-secondary">{pendingSkipped.length} file(s) will be skipped.</span>
{/if}
</div>
</ConfirmationModal>
<ConfirmationModal
open={toDelete !== undefined}
title="Delete skill"
confirmationText="Delete"
onConfirmed={async () => {
const name = toDelete
toDelete = undefined
if (name) await deleteSkill(name)
}}
onCanceled={() => (toDelete = undefined)}
>
<span>
Delete the skill <code>{toDelete}</code>? The AI chat will no longer be able to use it.
</span>
</ConfirmationModal>
<ConfirmationModal
open={confirmBatchDelete}
title="Delete skills"
confirmationText="Delete"
onConfirmed={async () => {
confirmBatchDelete = false
await deleteSelected()
}}
onCanceled={() => (confirmBatchDelete = false)}
>
<span>
Delete {selectedCount} selected skill(s)? The AI chat will no longer be able to use them.
</span>
</ConfirmationModal>