mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
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:
co-authored by
windmill-internal-app[bot]
parent
870f67121d
commit
cfcfe298dd
-29
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
-18
@@ -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"
|
||||
}
|
||||
+5
-3
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
-28
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
-35
@@ -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"
|
||||
}
|
||||
-23
@@ -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 @@
|
||||
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
@@ -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))?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t 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 folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t 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
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
parseAndValidateSkill,
|
||||
parseSkillMd,
|
||||
validateSkill
|
||||
} from './aiSkills'
|
||||
} from './skillMd'
|
||||
|
||||
describe('parseSkillMd', () => {
|
||||
it('splits frontmatter name/description from the body', () => {
|
||||
+7
-4
@@ -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 one’s 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>
|
||||
Reference in New Issue
Block a user