feat(sync): 添加 Vault 所有的用户 Skill 记录
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
//! Preference schemas contain portable values only; no paths, permissions, providers or secrets.
|
||||
//! Portable settings may declare required permissions, but never carry device grants, paths or secrets.
|
||||
use crate::workspace::{HostError, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
@@ -17,6 +17,27 @@ struct Persona {
|
||||
dialogue_pairs: Vec<DialoguePair>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UserSkillRetrieval {
|
||||
top_k: u8,
|
||||
rerank: bool,
|
||||
citation: bool,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UserSkill {
|
||||
version: u64,
|
||||
name: String,
|
||||
description: String,
|
||||
prompt: String,
|
||||
tools: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
retrieval: UserSkillRetrieval,
|
||||
required_capabilities: Vec<String>,
|
||||
created_at_ms: i64,
|
||||
updated_at_ms: i64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct Layout {
|
||||
primary_expanded: bool,
|
||||
@@ -103,6 +124,20 @@ fn markdown(value: &Markdown) -> bool {
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"_+-".contains(&b))
|
||||
}
|
||||
fn unique_bounded(values: &[String], max_items: usize, max_chars: usize) -> bool {
|
||||
values.len() <= max_items
|
||||
&& values.iter().all(|value| {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= max_chars
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
})
|
||||
&& values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, value)| !values[..index].contains(value))
|
||||
}
|
||||
pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||
let valid = match kind {
|
||||
"persona" => {
|
||||
@@ -121,6 +156,56 @@ pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||
(200.0..=520.0).contains(&value.workspace_width)
|
||||
&& (200.0..=520.0).contains(&value.chat_width)
|
||||
}
|
||||
"user_skill" => {
|
||||
let value: UserSkill = decode(value)?;
|
||||
let _ = (value.retrieval.rerank, value.retrieval.citation);
|
||||
let timestamp = |time: i64| (0..=253402300799999).contains(&time);
|
||||
let known_permissions = [
|
||||
"notes.read",
|
||||
"notes.search",
|
||||
"notes.write",
|
||||
"notes.delete",
|
||||
"tasks.read",
|
||||
"tasks.write",
|
||||
"attachments.read",
|
||||
"network.request",
|
||||
"secrets.use",
|
||||
"ui.command",
|
||||
"ui.settings",
|
||||
"ui.sidebar",
|
||||
];
|
||||
let known_capabilities = [
|
||||
"chat",
|
||||
"vision",
|
||||
"tool_calling",
|
||||
"reasoning",
|
||||
"streaming",
|
||||
"structured_output",
|
||||
"embedding",
|
||||
"transcription",
|
||||
"speaker_matching",
|
||||
];
|
||||
value.version <= 9007199254740991
|
||||
&& !value.name.trim().is_empty()
|
||||
&& value.name.chars().count() <= 128
|
||||
&& value.description.chars().count() <= 2000
|
||||
&& value.prompt.chars().count() <= 64000
|
||||
&& unique_bounded(&value.tools, 64, 128)
|
||||
&& unique_bounded(&value.permissions, 32, 64)
|
||||
&& value
|
||||
.permissions
|
||||
.iter()
|
||||
.all(|v| known_permissions.contains(&v.as_str()))
|
||||
&& unique_bounded(&value.required_capabilities, 16, 64)
|
||||
&& value
|
||||
.required_capabilities
|
||||
.iter()
|
||||
.all(|v| known_capabilities.contains(&v.as_str()))
|
||||
&& (1..=100).contains(&value.retrieval.top_k)
|
||||
&& timestamp(value.created_at_ms)
|
||||
&& timestamp(value.updated_at_ms)
|
||||
&& value.updated_at_ms >= value.created_at_ms
|
||||
}
|
||||
"theme_settings" => {
|
||||
let value: Theme = decode(value)?;
|
||||
let _ = value.headings.custom;
|
||||
@@ -181,6 +266,37 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn user_skill_schema_is_portable_strict_and_default_synced() {
|
||||
let id = "user_skill_00000000000000000000000000000001";
|
||||
let path = crate::records::path_for("user_skill", id).unwrap();
|
||||
let data = json!({"version":1,"name":"Review","description":"Check a note","prompt":"Be precise.","tools":["notes.read"],"permissions":["notes.read"],"retrieval":{"top_k":10,"rerank":true,"citation":true},"required_capabilities":["chat","tool_calling"],"created_at_ms":1,"updated_at_ms":2});
|
||||
let record = json!({"schema":1,"kind":"user_skill","id":id,"data":data});
|
||||
crate::records::validate(&path, &serde_json::to_vec(&record).unwrap()).unwrap();
|
||||
assert!(crate::records::allowed(&path));
|
||||
assert!(crate::sync_scope::OptionalScope::default().includes(&path));
|
||||
for field in [
|
||||
"api_key",
|
||||
"package_path",
|
||||
"enabled",
|
||||
"device_grants",
|
||||
"environment",
|
||||
] {
|
||||
let mut bad = data.clone();
|
||||
bad[field] = json!("private");
|
||||
assert_eq!(
|
||||
validate("user_skill", &bad).unwrap_err().code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
let mut bad = data.clone();
|
||||
bad["permissions"] = json!(["notes.read", "unknown.permission"]);
|
||||
assert_eq!(
|
||||
validate("user_skill", &bad).unwrap_err().code,
|
||||
"RECORD_DATA_INVALID"
|
||||
);
|
||||
assert!(crate::records::path_for("user_skill", "user_skill_ABCD").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn layout_schema_limits_widths_and_rejects_device_fields() {
|
||||
let good = json!({"primaryExpanded":true,"workspaceWidth":400.5,"chatWidth":320});
|
||||
let path = crate::records::path_for("layout", "sidebars").unwrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Versioned logical records: explicit fields only, never raw application databases/config.
|
||||
use crate::workspace::{HostError, Result, Workspace};
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -32,6 +33,18 @@ pub fn path(id: &str) -> Result<String> {
|
||||
}
|
||||
Ok(format!("opennexus-records/v1/tasks/{id}.json"))
|
||||
}
|
||||
|
||||
pub fn user_skill_path(id: &str) -> Result<String> {
|
||||
if !id.starts_with("user_skill_")
|
||||
|| id.len() != 43
|
||||
|| !id[11..]
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v))
|
||||
{
|
||||
return Err(HostError::new("RECORD_ID_INVALID"));
|
||||
}
|
||||
Ok(format!("opennexus-records/v1/user-skills/{id}.json"))
|
||||
}
|
||||
pub fn path_for(kind: &str, id: &str) -> Result<String> {
|
||||
match (kind, id) {
|
||||
("task", id) => path(id),
|
||||
@@ -41,6 +54,7 @@ pub fn path_for(kind: &str, id: &str) -> Result<String> {
|
||||
("persona", "default") => Ok("opennexus-records/v1/persona/default.json".into()),
|
||||
("layout", "sidebars") => Ok("opennexus-records/v1/layout/sidebars.json".into()),
|
||||
("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()),
|
||||
("user_skill", id) => user_skill_path(id),
|
||||
_ => Err(HostError::new("RECORD_ID_INVALID")),
|
||||
}
|
||||
}
|
||||
@@ -57,10 +71,17 @@ pub fn allowed(path_value: &str) -> bool {
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
path_value
|
||||
if path_value
|
||||
.strip_prefix("opennexus-records/v1/tasks/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.is_some_and(|id| path(id).is_ok())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
path_value
|
||||
.strip_prefix("opennexus-records/v1/user-skills/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.is_some_and(|id| user_skill_path(id).is_ok())
|
||||
}
|
||||
pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
|
||||
if content.len() > 1024 * 1024 {
|
||||
@@ -154,23 +175,31 @@ impl Workspace {
|
||||
))
|
||||
}
|
||||
pub fn record_list(&mut self, offset: usize, limit: usize) -> Result<Value> {
|
||||
self.record_list_kind("task", offset, limit)
|
||||
}
|
||||
pub fn record_list_kind(&mut self, kind: &str, offset: usize, limit: usize) -> Result<Value> {
|
||||
if limit == 0 || limit > 1000 {
|
||||
return Err(HostError::new("RECORD_LIMIT_INVALID"));
|
||||
}
|
||||
let prefix = match kind {
|
||||
"task" => "opennexus-records/v1/tasks/",
|
||||
"user_skill" => "opennexus-records/v1/user-skills/",
|
||||
_ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")),
|
||||
};
|
||||
let paths = self
|
||||
.sync_paths()?
|
||||
.into_iter()
|
||||
.filter(|path| path.starts_with("opennexus-records/v1/tasks/") && allowed(path))
|
||||
.filter(|path| path.starts_with(prefix) && allowed(path))
|
||||
.collect::<Vec<_>>();
|
||||
let total = paths.len();
|
||||
let mut items = Vec::new();
|
||||
let mut bytes = 0;
|
||||
for path in paths.into_iter().skip(offset).take(limit) {
|
||||
let id = path
|
||||
.strip_prefix("opennexus-records/v1/tasks/")
|
||||
.strip_prefix(prefix)
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.ok_or_else(|| HostError::new("RECORD_ID_INVALID"))?;
|
||||
if let Some(value) = self.record_get(id)? {
|
||||
if let Some(value) = self.record_get_kind(kind, id)? {
|
||||
let size = serde_json::to_vec(&value)
|
||||
.map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))?
|
||||
.len();
|
||||
@@ -196,8 +225,27 @@ impl Workspace {
|
||||
}
|
||||
let bytes = self.payload(operation, &[])?;
|
||||
let record = validate(path, &bytes)?;
|
||||
let expected = receipt["result"]["expected"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.or(self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT expected FROM journal WHERE operation_id=?1",
|
||||
[operation],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?)
|
||||
.or(self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT hash FROM file_ops WHERE id=?1",
|
||||
[operation],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?);
|
||||
Ok(Some(
|
||||
json!({"record":record,"hash":crate::workspace::hash(&bytes),"deleted":receipt["result"]["deleted"],"state":receipt["state"]}),
|
||||
json!({"record":record,"hash":crate::workspace::hash(&bytes),"expected":expected,"deleted":receipt["result"]["deleted"],"state":receipt["state"]}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,9 +379,13 @@ impl Workspace {
|
||||
.optional()?;
|
||||
value
|
||||
.map(|(state, result)| {
|
||||
let result: Option<Entry> = result
|
||||
let result: Option<serde_json::Value> = result
|
||||
.map(|value| {
|
||||
serde_json::from_str(&value).map_err(|_| HostError::new("DATABASE_ERROR"))
|
||||
let parsed: serde_json::Value = serde_json::from_str(&value)
|
||||
.map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
serde_json::from_value::<Entry>(parsed.clone())
|
||||
.map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
Ok::<serde_json::Value, HostError>(parsed)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(serde_json::json!({"operation_id":operation_id,"state":state,"result":result}))
|
||||
@@ -690,7 +694,11 @@ impl Workspace {
|
||||
})
|
||||
},
|
||||
)?;
|
||||
let result = serde_json::to_string(&entry).map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
let mut result =
|
||||
serde_json::to_value(&entry).map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
result["expected"] = serde_json::json!(expected);
|
||||
let result =
|
||||
serde_json::to_string(&result).map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
tx.execute(
|
||||
"UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1",
|
||||
params![operation_id, result],
|
||||
@@ -1002,11 +1010,15 @@ impl Workspace {
|
||||
} else {
|
||||
result.deleted = true;
|
||||
}
|
||||
let mut operation_result =
|
||||
serde_json::to_value(&result).map_err(|_| HostError::new("DATABASE_ERROR"))?;
|
||||
operation_result["expected"] = serde_json::json!(expected);
|
||||
tx.execute(
|
||||
"UPDATE operations SET state='committed',result=?2 WHERE operation_id=?1",
|
||||
params![
|
||||
id,
|
||||
serde_json::to_string(&result).map_err(|_| HostError::new("DATABASE_ERROR"))?
|
||||
serde_json::to_string(&operation_result)
|
||||
.map_err(|_| HostError::new("DATABASE_ERROR"))?
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
@@ -103,6 +103,57 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.list" => {
|
||||
let p: List = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_list_kind("user_skill", p.offset, p.limit)
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.get" => {
|
||||
let p: RecordRead = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
ws.record_get_kind("user_skill", &p.id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.write" => {
|
||||
let p: RecordWrite = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
if p.record["kind"] != "user_skill" {
|
||||
return Err("RECORD_SCHEMA_UNSUPPORTED".into());
|
||||
}
|
||||
let path =
|
||||
crate::records::path_for("user_skill", p.record["id"].as_str().unwrap_or(""))
|
||||
.map_err(|e| e.code)?;
|
||||
let bytes = serde_json::to_vec(&p.record).map_err(|_| "RECORD_SCHEMA_INVALID")?;
|
||||
ws.write_operation(&path, &p.expected, &bytes, "local", &p.operation_id)
|
||||
.map_err(|e| e.code)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.delete" => {
|
||||
let p: RecordDelete = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
let path = crate::records::user_skill_path(&p.id).map_err(|e| e.code)?;
|
||||
ws.mutate_operation("delete", &path, "", &p.expected, &p.operation_id)
|
||||
.map_err(|e| e.code)?;
|
||||
ws.record_operation(&p.operation_id)
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|e| e.code)
|
||||
}
|
||||
"workspace.user_skills.operation" => {
|
||||
let p: Operation = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
let value = ws.record_operation(&p.operation_id).map_err(|e| e.code)?;
|
||||
if value
|
||||
.as_ref()
|
||||
.is_some_and(|receipt| receipt["record"]["kind"] != "user_skill")
|
||||
{
|
||||
return Err("RECORD_OPERATION_DENIED".into());
|
||||
}
|
||||
Ok(value.unwrap_or(Value::Null))
|
||||
}
|
||||
"workspace.records.get" => {
|
||||
let p: RecordRead = decode(params)?;
|
||||
bound(ws, &p.vault_id)?;
|
||||
@@ -234,6 +285,54 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn user_skills_are_vault_bound_listed_and_deleted_as_logical_records() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let id = "user_skill_00000000000000000000000000000001";
|
||||
let record = json!({"schema":1,"kind":"user_skill","id":id,"data":{"version":1,"name":"Review","description":"","prompt":"Review carefully","tools":["notes.read"],"permissions":["notes.read"],"retrieval":{"top_k":10,"rerank":true,"citation":true},"required_capabilities":["chat"],"created_at_ms":1,"updated_at_ms":1}});
|
||||
let operation_id = uuid::Uuid::new_v4().to_string();
|
||||
let write = json!({"rpc":"workspace.user_skills.write","params":{"vault_id":ws.vault_id,"record":record,"expected":"","operation_id":operation_id}});
|
||||
let receipt = dispatch(&mut ws, &write).unwrap();
|
||||
assert_eq!(receipt["expected"], "");
|
||||
assert_eq!(receipt["deleted"], false);
|
||||
assert_eq!(dispatch(&mut ws, &write).unwrap(), receipt);
|
||||
let list = json!({"rpc":"workspace.user_skills.list","params":{"vault_id":ws.vault_id,"offset":0,"limit":100}});
|
||||
let listed = dispatch(&mut ws, &list).unwrap();
|
||||
assert_eq!(listed["total"], 1);
|
||||
assert_eq!(listed["items"][0]["record"], record);
|
||||
drop(ws);
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let delete = json!({"rpc":"workspace.user_skills.delete","params":{"vault_id":ws.vault_id,"id":id,"expected":receipt["hash"],"operation_id":uuid::Uuid::new_v4().to_string()}});
|
||||
let deleted = dispatch(&mut ws, &delete).unwrap();
|
||||
assert_eq!(deleted["deleted"], true);
|
||||
assert_eq!(dispatch(&mut ws, &delete).unwrap(), deleted);
|
||||
assert_eq!(dispatch(&mut ws, &list).unwrap()["total"], 0);
|
||||
assert_eq!(ws.pending_count().unwrap(), 2);
|
||||
let persona = json!({"schema":1,"kind":"persona","id":"default","data":{"version":1,"name":"private","system_prompt":"","dialogue_pairs":[]}});
|
||||
let persona_operation = uuid::Uuid::new_v4().to_string();
|
||||
ws.write_operation(
|
||||
"opennexus-records/v1/persona/default.json",
|
||||
"",
|
||||
&serde_json::to_vec(&persona).unwrap(),
|
||||
"local",
|
||||
&persona_operation,
|
||||
)
|
||||
.unwrap();
|
||||
let smuggle = json!({"rpc":"workspace.user_skills.operation","params":{"vault_id":ws.vault_id,"operation_id":persona_operation}});
|
||||
assert_eq!(
|
||||
dispatch(&mut ws, &smuggle).unwrap_err(),
|
||||
"RECORD_OPERATION_DENIED"
|
||||
);
|
||||
let mut denied = write;
|
||||
denied["params"]["vault_id"] = json!("other-vault");
|
||||
denied["params"]["operation_id"] = json!(uuid::Uuid::new_v4().to_string());
|
||||
assert_eq!(
|
||||
dispatch(&mut ws, &denied).unwrap_err(),
|
||||
"VAULT_PERMISSION_CHANGED"
|
||||
);
|
||||
assert_eq!(ws.pending_count().unwrap(), 3);
|
||||
}
|
||||
#[test]
|
||||
fn persona_is_vault_bound_durable_and_cas_protected() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
|
||||
@@ -368,5 +368,149 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored["hash"], first["revision"]);
|
||||
assert_eq!(workspace.lock().unwrap().pending_count().unwrap(), 9);
|
||||
|
||||
// User-created Skills are Vault records, use Host CAS/idempotency, and are
|
||||
// resolved by the actual Agent route without copying package paths or grants.
|
||||
let create_skill_operation = uuid::Uuid::new_v4();
|
||||
let skill_body = json!({
|
||||
"revision":"", "name":"Vault reviewer", "description":"portable",
|
||||
"prompt":"Answer with the exact phrase user-skill-active.", "tools":[],
|
||||
"permissions":[], "retrieval":{"top_k":10,"rerank":true,"citation":true},
|
||||
"required_capabilities":["chat"]
|
||||
});
|
||||
let (status, user_skill) = request(
|
||||
&mut core,
|
||||
"POST",
|
||||
"/api/user-skills",
|
||||
&vault,
|
||||
&create_skill_operation.to_string(),
|
||||
Some(skill_body.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "{user_skill}");
|
||||
let skill_id = user_skill["skill_id"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
skill_id,
|
||||
format!("user_skill_{}", create_skill_operation.simple())
|
||||
);
|
||||
assert_eq!(user_skill["status"], "ready");
|
||||
for _ in 0..20 {
|
||||
let (status, replay) = request(
|
||||
&mut core,
|
||||
"POST",
|
||||
"/api/user-skills",
|
||||
&vault,
|
||||
&create_skill_operation.to_string(),
|
||||
Some(skill_body.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "{replay}");
|
||||
assert_eq!(replay, user_skill);
|
||||
}
|
||||
let mut changed = skill_body.clone();
|
||||
changed["name"] = json!("Changed replay");
|
||||
let (status, conflict) = request(
|
||||
&mut core,
|
||||
"POST",
|
||||
"/api/user-skills",
|
||||
&vault,
|
||||
&create_skill_operation.to_string(),
|
||||
Some(changed),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 409, "{conflict}");
|
||||
assert_eq!(conflict["error"]["code"], "USER_SKILL_OPERATION_CONFLICT");
|
||||
let (status, listed) = request(
|
||||
&mut core,
|
||||
"GET",
|
||||
"/api/user-skills?limit=100&offset=0",
|
||||
&vault,
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{listed}");
|
||||
assert_eq!(listed["items"][0]["skill_id"], skill_id);
|
||||
let (status, denied) = request(
|
||||
&mut core,
|
||||
"GET",
|
||||
"/api/user-skills?limit=100&offset=0",
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 409, "{denied}");
|
||||
assert_eq!(denied["error"]["code"], "VAULT_PERMISSION_CHANGED");
|
||||
let mut update_body = skill_body.clone();
|
||||
update_body["revision"] = user_skill["revision"].clone();
|
||||
update_body["name"] = json!("Updated reviewer");
|
||||
let update_operation = uuid::Uuid::new_v4().to_string();
|
||||
let (status, updated_skill) = request(
|
||||
&mut core,
|
||||
"PUT",
|
||||
&format!("/api/user-skills/{skill_id}"),
|
||||
&vault,
|
||||
&update_operation,
|
||||
Some(update_body.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{updated_skill}");
|
||||
assert_eq!(updated_skill["data"]["version"], 2);
|
||||
let (status, stale) = request(
|
||||
&mut core,
|
||||
"PUT",
|
||||
&format!("/api/user-skills/{skill_id}"),
|
||||
&vault,
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
Some(update_body.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 409, "{stale}");
|
||||
assert_eq!(stale["error"]["code"], "USER_SKILL_REVISION_CONFLICT");
|
||||
for _ in 0..2 {
|
||||
let (status, replay) = request(
|
||||
&mut core,
|
||||
"PUT",
|
||||
&format!("/api/user-skills/{skill_id}"),
|
||||
&vault,
|
||||
&update_operation,
|
||||
Some(update_body.clone()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{replay}");
|
||||
assert_eq!(replay, updated_skill);
|
||||
}
|
||||
let (status, run) = request(
|
||||
&mut core,
|
||||
"POST",
|
||||
"/api/agent/runs",
|
||||
&vault,
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
Some(json!({"input":"Confirm the Skill configuration","provider_id":"mock","model":"mock-1","skill_id":skill_id})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 202, "{run}");
|
||||
assert_eq!(run["skill_id"], skill_id);
|
||||
let delete_operation = uuid::Uuid::new_v4().to_string();
|
||||
let delete_path = format!(
|
||||
"/api/user-skills/{skill_id}?revision={}",
|
||||
updated_skill["revision"].as_str().unwrap()
|
||||
);
|
||||
for _ in 0..2 {
|
||||
let (status, deleted) = request(
|
||||
&mut core,
|
||||
"DELETE",
|
||||
&delete_path,
|
||||
&vault,
|
||||
&delete_operation,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{deleted}");
|
||||
}
|
||||
assert!(!root
|
||||
.join(format!("opennexus-records/v1/user-skills/{skill_id}.json"))
|
||||
.exists());
|
||||
assert_eq!(workspace.lock().unwrap().pending_count().unwrap(), 12);
|
||||
}
|
||||
|
||||
@@ -566,6 +566,12 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
||||
json!({"primaryExpanded":true,"workspaceWidth":272,"chatWidth":320}),
|
||||
"workspaceWidth",
|
||||
),
|
||||
(
|
||||
"user_skill",
|
||||
"user_skill_00000000000000000000000000000001",
|
||||
json!({"version":1,"name":"initial","description":"portable","prompt":"Review carefully","tools":["notes.read"],"permissions":["notes.read"],"retrieval":{"top_k":10,"rerank":true,"citation":true},"required_capabilities":["chat"],"created_at_ms":1,"updated_at_ms":1}),
|
||||
"name",
|
||||
),
|
||||
] {
|
||||
let path = notesagent_host::records::path_for(kind, id).unwrap();
|
||||
let record = json!({"schema":1,"kind":kind,"id":id,"data":data});
|
||||
@@ -582,7 +588,7 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
||||
let mut ws = target.lock().unwrap();
|
||||
let current = ws.record_get_kind(kind, id).unwrap().unwrap();
|
||||
let mut next = current["record"].clone();
|
||||
next["data"][field] = if kind == "persona" {
|
||||
next["data"][field] = if kind != "layout" {
|
||||
json!(format!("side-{side}-round-{round}"))
|
||||
} else {
|
||||
json!(300 + round * 2 + side)
|
||||
@@ -665,7 +671,11 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
||||
0
|
||||
);
|
||||
assert!(!client_b.push_one(&workspace_b, &binding_b).await.unwrap());
|
||||
for (kind, id) in [("persona", "default"), ("layout", "sidebars")] {
|
||||
for (kind, id) in [
|
||||
("persona", "default"),
|
||||
("layout", "sidebars"),
|
||||
("user_skill", "user_skill_00000000000000000000000000000001"),
|
||||
] {
|
||||
assert_eq!(
|
||||
workspace.lock().unwrap().record_get_kind(kind, id).unwrap(),
|
||||
workspace_b
|
||||
@@ -691,6 +701,18 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
||||
.unwrap()
|
||||
> 0
|
||||
{}
|
||||
assert_eq!(
|
||||
excluded_ws
|
||||
.lock()
|
||||
.unwrap()
|
||||
.record_get_kind("user_skill", "user_skill_00000000000000000000000000000001")
|
||||
.unwrap(),
|
||||
workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.record_get_kind("user_skill", "user_skill_00000000000000000000000000000001")
|
||||
.unwrap()
|
||||
);
|
||||
for (kind, id) in [("persona", "default"), ("layout", "sidebars")] {
|
||||
let original = workspace
|
||||
.lock()
|
||||
|
||||
@@ -253,6 +253,41 @@ export interface Skill {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type UserSkillStatus = 'ready' | 'dependency_missing' | 'permission_required'
|
||||
|
||||
export interface UserSkillData {
|
||||
version: number
|
||||
name: string
|
||||
description: string
|
||||
prompt: string
|
||||
tools: string[]
|
||||
permissions: string[]
|
||||
retrieval: { top_k: number; rerank: boolean; citation: boolean }
|
||||
required_capabilities: string[]
|
||||
created_at_ms: number
|
||||
updated_at_ms: number
|
||||
}
|
||||
|
||||
export interface UserSkill {
|
||||
skill_id: string
|
||||
revision: string
|
||||
data: UserSkillData
|
||||
status: UserSkillStatus
|
||||
missing_dependencies: string[]
|
||||
undeclared_permissions: string[]
|
||||
}
|
||||
|
||||
export interface UserSkillWriteRequest {
|
||||
revision: string
|
||||
name: string
|
||||
description: string
|
||||
prompt: string
|
||||
tools: string[]
|
||||
permissions: string[]
|
||||
retrieval: { top_k: number; rerank: boolean; citation: boolean }
|
||||
required_capabilities: string[]
|
||||
}
|
||||
|
||||
// ============ Plugin ============
|
||||
|
||||
export type PluginStatus =
|
||||
|
||||
@@ -97,7 +97,7 @@ async function handleOpenCitation(data: Record<string, unknown>) {
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>{{ t('模型提供商', 'Model provider') }}</label><select v-model="form.provider_id" class="select"><option v-for="p in providerStore.enabledProviders" :key="p.provider_id" :value="p.provider_id">{{ p.name }}</option></select></div>
|
||||
<div class="field"><label>{{ t('模型', 'Model') }}</label><input v-model="form.model" class="input" list="agent-models" :placeholder="t('填写模型 ID', 'Enter model ID')" required /><datalist id="agent-models"><option v-for="m in models" :key="m.model_id" :value="m.model_id">{{ m.name }}</option></datalist></div>
|
||||
<div class="field"><label>{{ t('技能', 'Skill') }}</label><select v-model="form.skill_id" class="select"><option value="">{{ t('不使用技能', 'No skill') }}</option><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></select></div>
|
||||
<div class="field"><label>{{ t('技能', 'Skill') }}</label><select v-model="form.skill_id" class="select"><option value="">{{ t('不使用技能', 'No skill') }}</option><optgroup :label="t('已安装 Skill', 'Installed Skills')"><option v-for="s in skillStore.readySkills" :key="s.skill_id" :value="s.skill_id">{{ s.name }}</option></optgroup><optgroup :label="t('当前库的用户 Skill', 'User Skills in this Vault')"><option v-for="s in skillStore.readyUserSkills" :key="s.skill_id" :value="s.skill_id">{{ s.data.name }}</option></optgroup></select></div>
|
||||
<div class="field"><label>{{ t('最大步骤', 'Maximum steps') }}</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
|
||||
<div class="field"><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
|
||||
<div class="field"><label>{{ t('运行超时(秒)', 'Run timeout (seconds)') }}</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import ExtensionInstallDialog from '@/components/common/ExtensionInstallDialog.v
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
import { t } from '@/i18n'
|
||||
import UserSkillEditor from './UserSkillEditor.vue'
|
||||
|
||||
const skillStore = useSkillStore()
|
||||
const actionError = ref('')
|
||||
@@ -31,6 +32,7 @@ async function uninstall(skillId: string, name: string) {
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<ExtensionInstallDialog v-if="showInstall" kind="Skill" :install="skillStore.installSkill" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
|
||||
<header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="showInstall = true">{{ t('安装 Skill', 'Install Skill') }}</button></header>
|
||||
<UserSkillEditor />
|
||||
<div v-if="skillStore.error || actionError" class="error-banner">{{ skillStore.error || actionError }}</div>
|
||||
<div v-if="skillStore.selectedSkill" class="panel detail-panel">
|
||||
<div class="detail-head"><div><span class="badge" :class="{ success: skillStore.selectedSkill.status === 'ready', error: skillStore.selectedSkill.status === 'error', warning: skillStore.selectedSkill.status.includes('missing') }">{{ skillStore.selectedSkill.status }}</span><h2>{{ skillStore.selectedSkill.icon }} {{ skillStore.selectedSkill.name }}</h2><p class="muted">v{{ skillStore.selectedSkill.version }} · {{ skillStore.selectedSkill.author || t('未知作者', 'Unknown author') }}</p></div><div class="inline-actions"><button class="button-secondary" @click="toggle(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.enabled)">{{ skillStore.selectedSkill.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button><button class="button-danger" @click="uninstall(skillStore.selectedSkill.skill_id, skillStore.selectedSkill.name)">{{ t('卸载', 'Uninstall') }}</button></div></div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import UserSkillEditor from './UserSkillEditor.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
import * as service from '@/services/skillService'
|
||||
import type { UserSkill } from '@/contracts'
|
||||
|
||||
vi.mock('@/services/skillService', () => ({
|
||||
listSkills: vi.fn(), listUserSkills: vi.fn(), createUserSkill: vi.fn(), updateUserSkill: vi.fn(), deleteUserSkill: vi.fn(),
|
||||
}))
|
||||
|
||||
const saved: UserSkill = {
|
||||
skill_id: 'user_skill_' + '1'.repeat(32), revision: 'a'.repeat(64), status: 'ready',
|
||||
missing_dependencies: [], undeclared_permissions: [],
|
||||
data: { version: 1, name: 'Review', description: '', prompt: 'Review carefully', tools: ['notes.read'], permissions: ['notes.read'], retrieval: { top_k: 10, rerank: true, citation: true }, required_capabilities: ['chat'], created_at_ms: 1, updated_at_ms: 1 },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
useWorkspaceStore().vaultId = 'vault-one'
|
||||
vi.mocked(service.listSkills).mockResolvedValue([])
|
||||
vi.mocked(service.listUserSkills).mockResolvedValue([])
|
||||
vi.mocked(service.createUserSkill).mockResolvedValue(saved)
|
||||
vi.mocked(service.updateUserSkill).mockResolvedValue({ ...saved, revision: 'b'.repeat(64), data: { ...saved.data, version: 2 } })
|
||||
vi.mocked(service.deleteUserSkill).mockResolvedValue({ status: 'completed' })
|
||||
})
|
||||
|
||||
it('creates a complete declarative record and labels declarations as non-grants', async () => {
|
||||
const wrapper = mount(UserSkillEditor)
|
||||
await wrapper.findAll('input.input')[0]!.setValue('Review')
|
||||
await wrapper.findAll('input.input')[1]!.setValue('notes.read')
|
||||
await wrapper.get('textarea').setValue('Review carefully')
|
||||
await wrapper.get('input[value="notes.read"]').setValue(true)
|
||||
await wrapper.get('input[value="chat"]').setValue(true)
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(service.createUserSkill).toHaveBeenCalledWith(expect.objectContaining({
|
||||
revision: '', name: 'Review', prompt: 'Review carefully', tools: ['notes.read'],
|
||||
permissions: ['notes.read'], required_capabilities: ['chat'],
|
||||
retrieval: { top_k: 10, rerank: true, citation: true },
|
||||
}), expect.stringMatching(/^[0-9a-f-]{36}$/))
|
||||
expect(wrapper.text()).toContain('不是设备授权')
|
||||
expect(useSkillStore().userSkills).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not publish a late save response into a different Vault', async () => {
|
||||
let finish!: (value: UserSkill) => void
|
||||
vi.mocked(service.createUserSkill).mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mount(UserSkillEditor)
|
||||
await wrapper.findAll('input.input')[0]!.setValue('Review')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
useWorkspaceStore().vaultId = 'vault-two'
|
||||
await wrapper.vm.$nextTick()
|
||||
finish(saved)
|
||||
await flushPromises()
|
||||
expect(useSkillStore().userSkills).toEqual([])
|
||||
expect(wrapper.text()).toContain('WORKSPACE_CHANGED')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not publish a late list response into a different Vault', async () => {
|
||||
let finish!: (value: UserSkill[]) => void
|
||||
vi.mocked(service.listUserSkills).mockImplementation(() => new Promise(resolve => { finish = resolve }))
|
||||
const store = useSkillStore()
|
||||
const loading = store.loadSkills()
|
||||
useWorkspaceStore().vaultId = 'vault-two'
|
||||
finish([saved])
|
||||
await loading
|
||||
expect(store.userSkills).toEqual([])
|
||||
})
|
||||
|
||||
it('reuses the operation UUID after an ambiguous save failure and changes it with the payload', async () => {
|
||||
vi.mocked(service.createUserSkill)
|
||||
.mockRejectedValueOnce(new Error('HOST_TIMEOUT'))
|
||||
.mockResolvedValueOnce(saved)
|
||||
.mockRejectedValueOnce(new Error('HOST_TIMEOUT'))
|
||||
const wrapper = mount(UserSkillEditor)
|
||||
const name = wrapper.findAll('input.input')[0]!
|
||||
await name.setValue('Review')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
const firstOperation = vi.mocked(service.createUserSkill).mock.calls[0]![1]
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(service.createUserSkill).mock.calls[1]![1]).toBe(firstOperation)
|
||||
await wrapper.findAll('button').find(button => button.text().includes('清空表单'))!.trigger('click')
|
||||
await name.setValue('Changed')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(service.createUserSkill).mock.calls[2]![1]).not.toBe(firstOperation)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
import { t } from '@/i18n'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import type { UserSkill, UserSkillWriteRequest } from '@/contracts'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
const permissions = [
|
||||
'notes.read', 'notes.search', 'notes.write', 'notes.delete', 'tasks.read', 'tasks.write',
|
||||
'attachments.read', 'network.request', 'secrets.use', 'ui.command', 'ui.settings', 'ui.sidebar',
|
||||
]
|
||||
const capabilities = [
|
||||
'chat', 'vision', 'tool_calling', 'reasoning', 'streaming', 'structured_output',
|
||||
'embedding', 'transcription', 'speaker_matching',
|
||||
]
|
||||
const skillStore = useSkillStore()
|
||||
const workspace = useWorkspaceStore()
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
const editingId = ref<string | null>(null)
|
||||
const loadedVault = ref(workspace.vaultId)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
let pendingSave: { fingerprint: string; operationId: string } | null = null
|
||||
const pendingDeletes = new Map<string, { revision: string; operationId: string }>()
|
||||
const editingSkill = computed(() => skillStore.userSkills.find(skill => skill.skill_id === editingId.value) ?? null)
|
||||
const form = reactive({
|
||||
revision: '', name: '', description: '', prompt: '', tools: '', permissions: [] as string[],
|
||||
capabilities: [] as string[], topK: 10, rerank: true, citation: true,
|
||||
})
|
||||
|
||||
function reset() {
|
||||
editingId.value = null
|
||||
Object.assign(form, { revision: '', name: '', description: '', prompt: '', tools: '', permissions: [], capabilities: [], topK: 10, rerank: true, citation: true })
|
||||
error.value = ''
|
||||
pendingSave = null
|
||||
}
|
||||
|
||||
function edit(skill: UserSkill) {
|
||||
editingId.value = skill.skill_id
|
||||
Object.assign(form, {
|
||||
revision: skill.revision,
|
||||
name: skill.data.name,
|
||||
description: skill.data.description,
|
||||
prompt: skill.data.prompt,
|
||||
tools: skill.data.tools.join(', '),
|
||||
permissions: [...skill.data.permissions],
|
||||
capabilities: [...skill.data.required_capabilities],
|
||||
topK: skill.data.retrieval.top_k,
|
||||
rerank: skill.data.retrieval.rerank,
|
||||
citation: skill.data.retrieval.citation,
|
||||
})
|
||||
error.value = ''
|
||||
pendingSave = null
|
||||
}
|
||||
|
||||
function payload(): UserSkillWriteRequest {
|
||||
return {
|
||||
revision: form.revision,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
prompt: form.prompt,
|
||||
tools: [...new Set(form.tools.split(',').map(value => value.trim()).filter(Boolean))],
|
||||
permissions: [...form.permissions],
|
||||
retrieval: { top_k: form.topK, rerank: form.rerank, citation: form.citation },
|
||||
required_capabilities: [...form.capabilities],
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const vault = loadedVault.value
|
||||
if (!vault || workspace.vaultId !== vault) { error.value = t('工作区已切换,请重新加载。', 'The workspace changed; reload the form.'); return }
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
const request = payload()
|
||||
const fingerprint = JSON.stringify({ vault, skillId: editingId.value, request })
|
||||
if (pendingSave?.fingerprint !== fingerprint) pendingSave = { fingerprint, operationId: crypto.randomUUID() }
|
||||
const saved = editingId.value
|
||||
? await skillStore.updateUserSkill(editingId.value, request, vault, pendingSave.operationId)
|
||||
: await skillStore.createUserSkill(request, vault, pendingSave.operationId)
|
||||
if (workspace.vaultId === vault) { pendingSave = null; edit(saved) }
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : t('用户 Skill 保存失败', 'Failed to save user Skill')
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function remove(skill: UserSkill) {
|
||||
if (!(await askConfirm(`${t('确定删除用户 Skill', 'Delete user Skill')} “${skill.data.name}”?`))) return
|
||||
const vault = loadedVault.value
|
||||
if (!vault || workspace.vaultId !== vault) return
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
let pending = pendingDeletes.get(skill.skill_id)
|
||||
if (!pending || pending.revision !== skill.revision) {
|
||||
pending = { revision: skill.revision, operationId: crypto.randomUUID() }
|
||||
pendingDeletes.set(skill.skill_id, pending)
|
||||
}
|
||||
await skillStore.deleteUserSkill(skill.skill_id, skill.revision, vault, pending.operationId)
|
||||
pendingDeletes.delete(skill.skill_id)
|
||||
if (editingId.value === skill.skill_id) reset()
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : t('用户 Skill 删除失败', 'Failed to delete user Skill')
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
|
||||
watch(() => workspace.vaultId, async vault => {
|
||||
loadedVault.value = vault; pendingDeletes.clear(); reset()
|
||||
if (vault) await skillStore.loadSkills()
|
||||
}, { flush: 'sync' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel user-skills">
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<header class="section-head">
|
||||
<div><h2>{{ t('当前库的用户 Skill', 'User Skills in this Vault') }}</h2><p class="muted">{{ t('提示词和声明式配置会随当前库同步。设备授权、密钥、安装目录和运行状态不会同步;同步到新设备后仍按该设备的权限策略确认。', 'Prompts and declarative settings sync with this Vault. Device grants, secrets, package paths, and runtime state stay local; the destination device still applies its own permission policy.') }}</p></div>
|
||||
<button class="button-secondary" :disabled="!workspace.vaultId || busy" @click="reset">{{ t('新建用户 Skill', 'New user Skill') }}</button>
|
||||
</header>
|
||||
<div v-if="skillStore.userSkillError || error" class="error-banner">{{ error || skillStore.userSkillError }}</div>
|
||||
<div v-if="!workspace.vaultId" class="empty-state"><strong>{{ t('请先打开工作区', 'Open a workspace first') }}</strong></div>
|
||||
<template v-else>
|
||||
<div class="user-skill-grid">
|
||||
<button v-for="skill in skillStore.userSkills" :key="skill.skill_id" class="user-skill-card" :class="{ active: editingId === skill.skill_id }" @click="edit(skill)">
|
||||
<span><strong>{{ skill.data.name }}</strong><small>v{{ skill.data.version }} · {{ skill.status }}</small></span>
|
||||
<span v-if="skill.missing_dependencies.length" class="badge warning">{{ t('缺少工具', 'Missing tools') }}</span>
|
||||
<span v-else-if="skill.undeclared_permissions.length" class="badge warning">{{ t('权限声明不足', 'Permission declaration required') }}</span>
|
||||
<span v-else class="badge success">{{ t('可选择运行', 'Ready to select') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="user-skill-form" @submit.prevent="save">
|
||||
<div class="form-grid">
|
||||
<label class="field"><span>{{ t('名称', 'Name') }}</span><input v-model="form.name" class="input" maxlength="128" required /></label>
|
||||
<label class="field"><span>{{ t('工具 ID(逗号分隔)', 'Tool IDs (comma-separated)') }}</span><input v-model="form.tools" class="input" maxlength="8256" placeholder="notes.read, notes.search" /></label>
|
||||
<label class="field wide"><span>{{ t('说明', 'Description') }}</span><input v-model="form.description" class="input" maxlength="2000" /></label>
|
||||
<label class="field wide"><span>{{ t('系统提示词', 'System prompt') }}</span><textarea v-model="form.prompt" class="textarea prompt" maxlength="64000" rows="8" /></label>
|
||||
<label class="field"><span>Top K</span><input v-model.number="form.topK" class="input" type="number" min="1" max="100" required /></label>
|
||||
<div class="field checks"><span>{{ t('检索行为', 'Retrieval behavior') }}</span><label><input v-model="form.rerank" type="checkbox" />{{ t('重排', 'Rerank') }}</label><label><input v-model="form.citation" type="checkbox" />{{ t('引用', 'Citations') }}</label></div>
|
||||
</div>
|
||||
<fieldset><legend>{{ t('权限声明(不是设备授权)', 'Permission declarations (not device grants)') }}</legend><label v-for="permission in permissions" :key="permission" class="check"><input v-model="form.permissions" type="checkbox" :value="permission" />{{ permission }}</label></fieldset>
|
||||
<fieldset><legend>{{ t('模型能力要求', 'Required model capabilities') }}</legend><label v-for="capability in capabilities" :key="capability" class="check"><input v-model="form.capabilities" type="checkbox" :value="capability" />{{ capability }}</label></fieldset>
|
||||
<div class="inline-actions"><button class="button-primary" :disabled="busy">{{ busy ? t('保存中…', 'Saving…') : t('保存', 'Save') }}</button><button v-if="editingSkill" type="button" class="button-danger" :disabled="busy" @click="remove(editingSkill)">{{ t('删除', 'Delete') }}</button><button type="button" class="button-secondary" :disabled="busy" @click="reset">{{ t('清空表单', 'Clear form') }}</button></div>
|
||||
</form>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-skills { margin-bottom: var(--space-xl); }
|
||||
.section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-lg); margin-bottom: var(--space-lg); }
|
||||
.section-head p { max-width: 820px; margin-top: var(--space-xs); line-height: 1.5; }
|
||||
.user-skill-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--space-sm); margin-bottom: var(--space-lg); }
|
||||
.user-skill-card { display: flex; justify-content: space-between; gap: var(--space-sm); align-items: center; padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); color: inherit; text-align: left; cursor: pointer; }
|
||||
.user-skill-card.active { border-color: var(--color-accent-primary); box-shadow: 0 0 0 2px var(--color-accent-soft); }
|
||||
.user-skill-card span:first-child { display: grid; gap: 4px; }
|
||||
.user-skill-card small { color: var(--color-text-tertiary); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-md); }
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.prompt { min-height: 180px; }
|
||||
.checks { display: flex; flex-wrap: wrap; align-content: start; gap: var(--space-sm); }
|
||||
.checks > span { flex-basis: 100%; }
|
||||
.checks label, .check { display: inline-flex; align-items: center; gap: 6px; }
|
||||
fieldset { margin: var(--space-lg) 0 0; padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
|
||||
legend { padding: 0 var(--space-xs); color: var(--color-text-secondary); }
|
||||
.check { margin: 6px var(--space-md) 6px 0; }
|
||||
.inline-actions { margin-top: var(--space-lg); }
|
||||
@media (max-width: 760px) { .section-head { display: grid; } .form-grid { grid-template-columns: 1fr; } .wide { grid-column: auto; } }
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { apiClient } from './apiClient'
|
||||
import * as service from './skillService'
|
||||
|
||||
vi.mock('./apiClient', () => {
|
||||
const client = { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), postBinary: vi.fn() }
|
||||
return { apiClient: client, default: client }
|
||||
})
|
||||
|
||||
const request = {
|
||||
revision: '', name: 'Review', description: '', prompt: 'Review carefully', tools: ['notes.read'],
|
||||
permissions: ['notes.read'], retrieval: { top_k: 10, rerank: true, citation: true }, required_capabilities: ['chat'],
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses dedicated user Skill routes and stable idempotency keys', async () => {
|
||||
const createOperation = '00000000-0000-4000-8000-000000000001'
|
||||
const updateOperation = '00000000-0000-4000-8000-000000000002'
|
||||
const deleteOperation = '00000000-0000-4000-8000-000000000003'
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ items: [] })
|
||||
vi.mocked(apiClient.post).mockResolvedValue({})
|
||||
vi.mocked(apiClient.put).mockResolvedValue({})
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ status: 'completed' })
|
||||
await service.listUserSkills()
|
||||
await service.createUserSkill(request, createOperation)
|
||||
await service.updateUserSkill('user_skill_' + '1'.repeat(32), { ...request, revision: 'a'.repeat(64) }, updateOperation)
|
||||
await service.deleteUserSkill('user_skill_' + '1'.repeat(32), 'b'.repeat(64), deleteOperation)
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/user-skills', { params: { limit: 100, offset: 0 } })
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/user-skills', request, { headers: { 'Idempotency-Key': createOperation } })
|
||||
expect(apiClient.put).toHaveBeenCalledWith('/api/user-skills/user_skill_' + '1'.repeat(32), expect.objectContaining({ revision: 'a'.repeat(64) }), { headers: { 'Idempotency-Key': updateOperation } })
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/api/user-skills/user_skill_' + '1'.repeat(32), { params: { revision: 'b'.repeat(64) }, headers: { 'Idempotency-Key': deleteOperation } })
|
||||
})
|
||||
|
||||
it('loads every bounded Host page instead of silently truncating user Skills', async () => {
|
||||
const items = Array.from({ length: 101 }, (_, index) => ({ skill_id: `user_skill_${String(index).padStart(32, '0')}` }))
|
||||
vi.mocked(apiClient.get)
|
||||
.mockResolvedValueOnce({ items: items.slice(0, 100), page: { total: 101 } })
|
||||
.mockResolvedValueOnce({ items: items.slice(100), page: { total: 101 } })
|
||||
expect(await service.listUserSkills()).toHaveLength(101)
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/api/user-skills', { params: { limit: 100, offset: 100 } })
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './apiClient'
|
||||
import type { ApiSkill, OperationResponse, Skill } from '@/contracts'
|
||||
import type { ApiSkill, OperationResponse, Skill, UserSkill, UserSkillWriteRequest } from '@/contracts'
|
||||
|
||||
function toSkill(skill: ApiSkill): Skill {
|
||||
const { manifest } = skill
|
||||
@@ -45,3 +45,27 @@ export async function disableSkill(skillId: string): Promise<Skill> {
|
||||
export async function uninstallSkill(skillId: string): Promise<OperationResponse> {
|
||||
return apiClient.delete(`/api/skills/${skillId}`)
|
||||
}
|
||||
|
||||
export async function listUserSkills(): Promise<UserSkill[]> {
|
||||
const items: UserSkill[] = []
|
||||
for (let page = 0; page < 100; page += 1) {
|
||||
const response = await apiClient.get<{ items: UserSkill[]; page?: { total: number } }>('/api/user-skills', { params: { limit: 100, offset: items.length } })
|
||||
items.push(...response.items)
|
||||
if (!response.items.length || items.length >= (response.page?.total ?? items.length)) return items
|
||||
}
|
||||
throw new Error('USER_SKILL_LIST_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
export async function createUserSkill(request: UserSkillWriteRequest, operationId: string = crypto.randomUUID()): Promise<UserSkill> {
|
||||
return apiClient.post('/api/user-skills', request, { headers: { 'Idempotency-Key': operationId } })
|
||||
}
|
||||
|
||||
export async function updateUserSkill(skillId: string, request: UserSkillWriteRequest, operationId: string = crypto.randomUUID()): Promise<UserSkill> {
|
||||
return apiClient.put(`/api/user-skills/${skillId}`, request, { headers: { 'Idempotency-Key': operationId } })
|
||||
}
|
||||
|
||||
export async function deleteUserSkill(skillId: string, revision: string, operationId: string = crypto.randomUUID()): Promise<OperationResponse> {
|
||||
return apiClient.delete(`/api/user-skills/${skillId}`, {
|
||||
params: { revision }, headers: { 'Idempotency-Key': operationId },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Skill } from '@/contracts'
|
||||
import type { Skill, UserSkill, UserSkillWriteRequest } from '@/contracts'
|
||||
import * as skillService from '@/services/skillService'
|
||||
import { t } from '@/i18n'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
export const useSkillStore = defineStore('skill', () => {
|
||||
const workspace = useWorkspaceStore()
|
||||
const skills = ref<Skill[]>([])
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const userSkills = ref<UserSkill[]>([])
|
||||
const userSkillError = ref<string | null>(null)
|
||||
|
||||
const selectedSkill = computed(() =>
|
||||
skills.value.find((s) => s.skill_id === selectedSkillId.value) || null
|
||||
@@ -17,19 +21,55 @@ export const useSkillStore = defineStore('skill', () => {
|
||||
const enabledSkills = computed(() => skills.value.filter((s) => s.enabled))
|
||||
const installedSkills = computed(() => skills.value.filter((s) => s.status !== 'error'))
|
||||
const readySkills = computed(() => skills.value.filter((s) => s.status === 'ready'))
|
||||
const readyUserSkills = computed(() => userSkills.value.filter((skill) => skill.status === 'ready'))
|
||||
|
||||
async function loadSkills() {
|
||||
isLoading.value = true
|
||||
const vault = workspace.vaultId
|
||||
try {
|
||||
skills.value = await skillService.listSkills()
|
||||
error.value = null
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : t('Skill 加载失败', 'Failed to load Skills')
|
||||
const [installed, user] = await Promise.allSettled([
|
||||
skillService.listSkills(), vault ? skillService.listUserSkills() : Promise.resolve([]),
|
||||
])
|
||||
if (installed.status === 'fulfilled') { skills.value = installed.value; error.value = null }
|
||||
else error.value = installed.reason instanceof Error ? installed.reason.message : t('Skill 加载失败', 'Failed to load Skills')
|
||||
if (workspace.vaultId !== vault) return
|
||||
if (user.status === 'fulfilled') { userSkills.value = user.value; userSkillError.value = null }
|
||||
else { userSkills.value = []; userSkillError.value = user.reason instanceof Error ? user.reason.message : t('用户 Skill 加载失败', 'Failed to load user Skills') }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function assertVault(vaultId: string) {
|
||||
if (!vaultId || workspace.vaultId !== vaultId) throw new Error('WORKSPACE_CHANGED')
|
||||
}
|
||||
|
||||
async function createUserSkill(request: UserSkillWriteRequest, vaultId: string, operationId?: string) {
|
||||
assertVault(vaultId)
|
||||
const created = await skillService.createUserSkill(request, operationId)
|
||||
assertVault(vaultId)
|
||||
userSkills.value.unshift(created); userSkillError.value = null
|
||||
return created
|
||||
}
|
||||
|
||||
async function updateUserSkill(skillId: string, request: UserSkillWriteRequest, vaultId: string, operationId?: string) {
|
||||
assertVault(vaultId)
|
||||
const updated = await skillService.updateUserSkill(skillId, request, operationId)
|
||||
assertVault(vaultId)
|
||||
const index = userSkills.value.findIndex(skill => skill.skill_id === skillId)
|
||||
if (index >= 0) userSkills.value[index] = updated
|
||||
userSkillError.value = null
|
||||
return updated
|
||||
}
|
||||
|
||||
async function deleteUserSkill(skillId: string, revision: string, vaultId: string, operationId?: string) {
|
||||
assertVault(vaultId)
|
||||
await skillService.deleteUserSkill(skillId, revision, operationId)
|
||||
assertVault(vaultId)
|
||||
userSkills.value = userSkills.value.filter(skill => skill.skill_id !== skillId)
|
||||
userSkillError.value = null
|
||||
}
|
||||
|
||||
function selectSkill(skillId: string | null) {
|
||||
selectedSkillId.value = skillId
|
||||
}
|
||||
@@ -68,6 +108,9 @@ export const useSkillStore = defineStore('skill', () => {
|
||||
enabledSkills,
|
||||
installedSkills,
|
||||
readySkills,
|
||||
userSkills,
|
||||
readyUserSkills,
|
||||
userSkillError,
|
||||
isLoading,
|
||||
error,
|
||||
loadSkills,
|
||||
@@ -76,5 +119,8 @@ export const useSkillStore = defineStore('skill', () => {
|
||||
enableSkill,
|
||||
disableSkill,
|
||||
uninstallSkill,
|
||||
createUserSkill,
|
||||
updateUserSkill,
|
||||
deleteUserSkill,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user