feat(sync): 添加 Vault 所有的用户 Skill 记录

This commit is contained in:
2026-09-09 08:39:14 +08:00
parent bdd1543a4d
commit b0783c9356
25 changed files with 1507 additions and 29 deletions
+15 -5
View File
@@ -96,11 +96,21 @@ class AgentRuntime:
provider = self.providers.get(request.provider_id)
skill_config = None
if request.skill_id:
if self.skills is None:
raise RuntimeError("Skill Runtime is not configured.")
skill_config = self.skills.build_agent_configuration(
request.skill_id, provider.config.capabilities
)
if request.skill_id.startswith("user_skill_"):
from app.services.user_skills import build_agent_configuration
skill_config = await asyncio.to_thread(
build_agent_configuration,
request.skill_id,
provider.config.capabilities,
self.tools,
)
else:
if self.skills is None:
raise RuntimeError("Skill Runtime is not configured.")
skill_config = self.skills.build_agent_configuration(
request.skill_id, provider.config.capabilities
)
now = datetime.now(timezone.utc)
run = AgentRun(
run_id=f"run_{uuid4().hex}",
+77
View File
@@ -502,6 +502,83 @@ class SkillListResponse(Contract):
items: list[Skill] = Field(default_factory=list)
class UserSkillData(Contract):
version: int = Field(ge=1, le=9007199254740991)
name: str = Field(min_length=1, max_length=128)
description: str = Field(default="", max_length=2000)
prompt: str = Field(default="", max_length=64000)
tools: list[str] = Field(default_factory=list, max_length=64)
permissions: list[str] = Field(default_factory=list, max_length=32)
retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
required_capabilities: list[ModelCapability] = Field(default_factory=list, max_length=16)
created_at_ms: int = Field(ge=0, le=253402300799999)
updated_at_ms: int = Field(ge=0, le=253402300799999)
@field_validator("name")
@classmethod
def user_skill_name_not_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("name must not be blank")
return value
@field_validator("tools", "permissions")
@classmethod
def user_skill_identifiers(cls, values: list[str]) -> list[str]:
if len(values) != len(set(values)):
raise ValueError("identifiers must be unique")
if any(
not value
or len(value) > 128
or any(not (char.isascii() and (char.isalnum() or char in "._-")) for char in value)
for value in values
):
raise ValueError("identifier is invalid")
return values
@model_validator(mode="after")
def user_skill_timestamps(self):
if self.updated_at_ms < self.created_at_ms:
raise ValueError("updated_at_ms precedes created_at_ms")
return self
class UserSkillWriteRequest(Contract):
revision: str = Field(default="", pattern=r"^(?:[0-9a-f]{64})?$")
name: str = Field(min_length=1, max_length=128)
description: str = Field(default="", max_length=2000)
prompt: str = Field(default="", max_length=64000)
tools: list[str] = Field(default_factory=list, max_length=64)
permissions: list[str] = Field(default_factory=list, max_length=32)
retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
required_capabilities: list[ModelCapability] = Field(default_factory=list, max_length=16)
@field_validator("name")
@classmethod
def user_skill_write_name_not_blank(cls, value: str) -> str:
if not value.strip():
raise ValueError("name must not be blank")
return value
@field_validator("tools", "permissions")
@classmethod
def user_skill_write_identifiers(cls, values: list[str]) -> list[str]:
return UserSkillData.user_skill_identifiers(values)
class UserSkill(Contract):
skill_id: str = Field(pattern=r"^user_skill_[0-9a-f]{32}$")
revision: str = Field(pattern=r"^[0-9a-f]{64}$")
data: UserSkillData
status: Literal["ready", "dependency_missing", "permission_required"]
missing_dependencies: list[str] = Field(default_factory=list)
undeclared_permissions: list[str] = Field(default_factory=list)
class UserSkillListResponse(Contract):
items: list[UserSkill] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class ExtensionInstallRequest(Contract):
package_path: str
+6
View File
@@ -100,6 +100,12 @@ class SkillRuntime:
except ValidationError as exc:
raise _manifest_error("skill", exc) from exc
_validate_id("skill", manifest.skill_id)
if manifest.skill_id.startswith("user_skill_"):
raise ExtensionError(
"SKILL_ID_RESERVED",
"The user_skill_ prefix is reserved for Vault-owned user Skills.",
status_code=422,
)
_validate_permissions("skill", manifest.permissions)
if manifest.skill_id in self._records:
raise ExtensionError(
+49
View File
@@ -95,6 +95,9 @@ from app.contracts import (
SearchResponse,
Skill,
SkillListResponse,
UserSkill,
UserSkillListResponse,
UserSkillWriteRequest,
Task,
TaskCreateRequest,
TaskListResponse,
@@ -677,6 +680,52 @@ async def list_tools() -> ToolListResponse:
# Skills
@router.get("/user-skills", response_model=UserSkillListResponse, tags=["Skills"])
async def list_user_skills(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
) -> UserSkillListResponse:
from app.services.user_skills import list_user_skills as list_records
items, total = await asyncio.to_thread(
list_records, container.tools, limit=limit, offset=offset
)
return UserSkillListResponse(
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
)
@router.get("/user-skills/{skill_id}", response_model=UserSkill, tags=["Skills"])
async def get_user_skill(skill_id: str) -> UserSkill:
from app.services.user_skills import get_user_skill as get_record
return await asyncio.to_thread(get_record, skill_id, container.tools)
@router.post("/user-skills", response_model=UserSkill, status_code=201, tags=["Skills"])
async def create_user_skill(request: UserSkillWriteRequest) -> UserSkill:
from app.services.user_skills import create_user_skill as create_record
return await asyncio.to_thread(create_record, request, container.tools)
@router.put("/user-skills/{skill_id}", response_model=UserSkill, tags=["Skills"])
async def update_user_skill(skill_id: str, request: UserSkillWriteRequest) -> UserSkill:
from app.services.user_skills import update_user_skill as update_record
return await asyncio.to_thread(update_record, skill_id, request, container.tools)
@router.delete(
"/user-skills/{skill_id}", response_model=OperationResponse, tags=["Skills"]
)
async def delete_user_skill(skill_id: str, revision: str = Query()) -> OperationResponse:
from app.services.user_skills import delete_user_skill as delete_record
await asyncio.to_thread(delete_record, skill_id, revision)
return OperationResponse(status="completed", resource_id=skill_id, message="deleted")
@router.get("/skills", response_model=SkillListResponse, tags=["Skills"])
async def list_skills() -> SkillListResponse:
return SkillListResponse(items=container.skills.list())
+212
View File
@@ -0,0 +1,212 @@
"""Vault-owned user Skill records and their declarative Agent configuration."""
from __future__ import annotations
from time import time_ns
from uuid import UUID, uuid4
from app import host_bridge
from app.agent.permissions import KNOWN_PERMISSIONS
from app.contracts import ModelCapability, UserSkill, UserSkillData, UserSkillWriteRequest
from app.errors import ApiError
from app.extensions.runtime import AgentConfiguration
from app.services.desktop_notes import call
def _operation_id() -> str:
return host_bridge.operation_id.get() or str(uuid4())
def _validate_skill_id(skill_id: str) -> None:
if not (
skill_id.startswith("user_skill_")
and len(skill_id) == 43
and all(char in "0123456789abcdef" for char in skill_id[11:])
):
raise ApiError(422, "USER_SKILL_ID_INVALID", "用户 Skill 标识无效。")
def _validate_declarations(request: UserSkillWriteRequest) -> None:
unknown = sorted(set(request.permissions) - KNOWN_PERMISSIONS)
if unknown:
raise ApiError(
422,
"USER_SKILL_PERMISSION_UNKNOWN",
"用户 Skill 声明了未知权限。",
{"permissions": unknown},
)
def _state(data: UserSkillData, tools) -> tuple[str, list[str], list[str]]:
missing = [name for name in data.tools if not tools.contains(name)]
declared = set(data.permissions)
required = {
tools.get(name).definition.permission
for name in data.tools
if tools.contains(name) and tools.get(name).definition.permission
}
undeclared = sorted(permission for permission in required - declared if permission)
status = "dependency_missing" if missing else "permission_required" if undeclared else "ready"
return status, missing, undeclared
def _public(document: dict, tools) -> UserSkill:
data = UserSkillData.model_validate(document["record"]["data"])
status, missing, undeclared = _state(data, tools)
return UserSkill(
skill_id=document["record"]["id"],
revision=document["hash"],
data=data,
status=status,
missing_dependencies=missing,
undeclared_permissions=undeclared,
)
def _request_values(request: UserSkillWriteRequest) -> dict:
return request.model_dump(exclude={"revision"}, mode="json")
def _replay(operation_id: str, skill_id: str, request: UserSkillWriteRequest | None, expected: str):
receipt = call("user_skills.operation", operation_id=operation_id)
if receipt is None:
return None
data = receipt.get("record", {}).get("data", {})
requested = {} if request is None else _request_values(request)
mismatched_fields = sorted(
key for key, value in requested.items() if data.get(key) != value
)
matches = (
receipt.get("record", {}).get("kind") == "user_skill"
and receipt.get("record", {}).get("id") == skill_id
and receipt.get("expected") == expected
and receipt.get("deleted") is (request is None)
and not mismatched_fields
)
if not matches:
raise ApiError(
409,
"USER_SKILL_OPERATION_CONFLICT",
"该幂等键已用于不同的用户 Skill 操作。",
{
"kind_matches": receipt.get("record", {}).get("kind") == "user_skill",
"id_matches": receipt.get("record", {}).get("id") == skill_id,
"expected_matches": receipt.get("expected") == expected,
"operation_matches": receipt.get("deleted") is (request is None),
"mismatched_fields": mismatched_fields,
},
)
return receipt if request is not None else True
def list_user_skills(tools, *, limit: int, offset: int) -> tuple[list[UserSkill], int]:
page = call("user_skills.list", offset=offset, limit=limit)
return [_public(item, tools) for item in page["items"]], page["total"]
def get_user_skill(skill_id: str, tools) -> UserSkill:
_validate_skill_id(skill_id)
document = call("user_skills.get", id=skill_id)
if document is None:
raise ApiError(404, "USER_SKILL_NOT_FOUND", "用户 Skill 不存在。", {"skill_id": skill_id})
return _public(document, tools)
def create_user_skill(request: UserSkillWriteRequest, tools) -> UserSkill:
_validate_declarations(request)
if request.revision:
raise ApiError(422, "USER_SKILL_REVISION_INVALID", "新建用户 Skill 时 revision 必须为空。")
operation_id = _operation_id()
skill_id = f"user_skill_{UUID(operation_id).hex}"
if replay := _replay(operation_id, skill_id, request, ""):
return _public(replay, tools)
now = time_ns() // 1_000_000
data = UserSkillData(
version=1,
created_at_ms=now,
updated_at_ms=now,
**request.model_dump(exclude={"revision"}),
)
document = call(
"user_skills.write",
record={"schema": 1, "kind": "user_skill", "id": skill_id, "data": data.model_dump(mode="json")},
expected="",
operation_id=operation_id,
)
return _public(document, tools)
def update_user_skill(skill_id: str, request: UserSkillWriteRequest, tools) -> UserSkill:
_validate_skill_id(skill_id)
_validate_declarations(request)
if not request.revision:
raise ApiError(422, "USER_SKILL_REVISION_REQUIRED", "更新用户 Skill 需要当前 revision。")
operation_id = _operation_id()
if replay := _replay(operation_id, skill_id, request, request.revision):
return _public(replay, tools)
current = get_user_skill(skill_id, tools)
data = UserSkillData(
version=current.data.version + 1,
created_at_ms=current.data.created_at_ms,
updated_at_ms=max(time_ns() // 1_000_000, current.data.updated_at_ms),
**request.model_dump(exclude={"revision"}),
)
try:
document = call(
"user_skills.write",
record={"schema": 1, "kind": "user_skill", "id": skill_id, "data": data.model_dump(mode="json")},
expected=request.revision,
operation_id=operation_id,
)
except ApiError as error:
if error.code == "REVISION_CONFLICT":
raise ApiError(409, "USER_SKILL_REVISION_CONFLICT", "用户 Skill 已被其他设备修改,请重新加载。") from None
raise
return _public(document, tools)
def delete_user_skill(skill_id: str, revision: str) -> None:
_validate_skill_id(skill_id)
if len(revision) != 64 or any(char not in "0123456789abcdef" for char in revision):
raise ApiError(422, "USER_SKILL_REVISION_INVALID", "删除用户 Skill 需要当前 revision。")
operation_id = _operation_id()
if _replay(operation_id, skill_id, None, revision):
return
try:
call("user_skills.delete", id=skill_id, expected=revision, operation_id=operation_id)
except ApiError as error:
if error.code == "REVISION_CONFLICT":
raise ApiError(409, "USER_SKILL_REVISION_CONFLICT", "用户 Skill 已被其他设备修改,请重新加载。") from None
raise
def build_agent_configuration(skill_id: str, provider_capabilities: list[ModelCapability], tools) -> AgentConfiguration:
skill = get_user_skill(skill_id, tools)
if skill.status != "ready":
raise ApiError(
409,
"USER_SKILL_NOT_READY",
"用户 Skill 的工具或权限声明尚未满足。",
{
"skill_id": skill_id,
"missing_dependencies": skill.missing_dependencies,
"undeclared_permissions": skill.undeclared_permissions,
},
)
missing = sorted(
capability.value
for capability in set(skill.data.required_capabilities) - set(provider_capabilities)
)
if missing:
raise ApiError(
409,
"USER_SKILL_MODEL_CAPABILITY_MISSING",
"当前模型不满足用户 Skill 的能力要求。",
{"skill_id": skill_id, "missing_capabilities": missing},
)
return AgentConfiguration(
skill_id=skill_id,
system_prompt=skill.data.prompt,
allowed_tools=list(skill.data.tools),
permissions=list(skill.data.permissions),
retrieval=skill.data.retrieval.model_copy(deep=True),
)
+16 -3
View File
@@ -18,6 +18,9 @@ import threading
PROTOCOL = 1
MAX_BOOTSTRAP = 16384
UUID_PATTERN = re.compile(
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
)
def bootstrap(line: bytes) -> dict:
@@ -79,9 +82,19 @@ class SessionAuth:
return
from app import host_bridge
vault = single(b"x-opennexus-vault").decode("ascii", errors="replace")
token = host_bridge.vault_id.set(vault if re.fullmatch(r"[0-9a-f-]{36}", vault) else None)
operation = single(b"x-request-id").decode("ascii", errors="replace")
operation_token = host_bridge.operation_id.set(operation if re.fullmatch(r"[0-9a-f-]{36}", operation) else None)
token = host_bridge.vault_id.set(vault if UUID_PATTERN.fullmatch(vault) else None)
# Mutating clients may retain a UUID across an ambiguous response. Other
# endpoint-specific idempotency tokens remain available to the route but
# do not enter the Host journal unless they are valid operation UUIDs.
idempotency = single(b"idempotency-key").decode("ascii", errors="replace")
operation = (
idempotency
if UUID_PATTERN.fullmatch(idempotency)
else single(b"x-request-id").decode("ascii", errors="replace")
)
operation_token = host_bridge.operation_id.set(
operation if UUID_PATTERN.fullmatch(operation) else None
)
try:
await self.app(scope, receive, send)
finally:
+2
View File
@@ -318,6 +318,8 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
"/api/agent/runs/{run_id}/events",
"/api/agent/runs/{run_id}/trace",
"/api/skills",
"/api/user-skills",
"/api/user-skills/{skill_id}",
"/api/plugins",
"/api/plugins/install",
"/api/plugins/{plugin_id}/host",
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
from types import SimpleNamespace
import asyncio
import pytest
from pydantic import ValidationError
from app import host_bridge
from app.contracts import ModelCapability, UserSkillWriteRequest
from app.errors import ApiError
from app.extensions import ExtensionError, SkillRuntime
from app.agent.tools import ToolRegistry
from app.services import user_skills
from app.sidecar import SessionAuth
class FakeTools:
def __init__(self, permissions: dict[str, str | None]):
self.permissions = permissions
def contains(self, name: str) -> bool:
return name in self.permissions
def get(self, name: str):
return SimpleNamespace(definition=SimpleNamespace(permission=self.permissions[name]))
def request(**updates) -> UserSkillWriteRequest:
values = {
"name": "Review notes",
"description": "A portable declarative Skill",
"prompt": "Review the selected note carefully.",
"tools": ["notes.read"],
"permissions": ["notes.read"],
"required_capabilities": ["chat", "tool_calling"],
}
values.update(updates)
return UserSkillWriteRequest.model_validate(values)
def test_user_skill_crud_uses_host_records_cas_and_idempotency(monkeypatch):
tools = FakeTools({"notes.read": "notes.read"})
documents: dict[str, dict] = {}
operations: dict[str, dict] = {}
calls = []
def fake_call(method: str, **params):
calls.append((method, params))
if method == "user_skills.operation":
return operations.get(params["operation_id"])
if method == "user_skills.write":
operation = params["operation_id"]
if operation in operations:
return operations[operation]
skill_id = params["record"]["id"]
current = documents.get(skill_id)
actual = current["hash"] if current else ""
if params["expected"] != actual:
raise ApiError(409, "REVISION_CONFLICT", "conflict")
digest = ("a" if current is None else "b") * 64
result = {"record": params["record"], "hash": digest, "file_id": "file-1", "expected": params["expected"], "deleted": False}
documents[skill_id] = result
operations[operation] = result
return result
if method == "user_skills.get":
return documents.get(params["id"])
if method == "user_skills.list":
values = list(documents.values())
return {"items": values[params["offset"]:params["offset"] + params["limit"]], "total": len(values)}
if method == "user_skills.delete":
current = documents.get(params["id"])
if not current or current["hash"] != params["expected"]:
raise ApiError(409, "REVISION_CONFLICT", "conflict")
removed = documents.pop(params["id"])
result = {**removed, "expected": params["expected"], "deleted": True}
operations[params["operation_id"]] = result
return result
raise AssertionError(method)
monkeypatch.setattr(user_skills, "call", fake_call)
token = host_bridge.operation_id.set("00000000-0000-4000-8000-000000000001")
try:
created = user_skills.create_user_skill(request(), tools)
finally:
host_bridge.operation_id.reset(token)
assert created.skill_id.startswith("user_skill_")
assert created.revision == "a" * 64
assert created.data.version == 1
assert created.status == "ready"
first_write = next(params for method, params in calls if method == "user_skills.write")
assert first_write["operation_id"] == "00000000-0000-4000-8000-000000000001"
assert set(first_write["record"]["data"]) == {
"version", "name", "description", "prompt", "tools", "permissions",
"retrieval", "required_capabilities", "created_at_ms", "updated_at_ms",
}
token = host_bridge.operation_id.set("00000000-0000-4000-8000-000000000001")
try:
assert user_skills.create_user_skill(request(), tools) == created
with pytest.raises(ApiError) as changed_replay:
user_skills.create_user_skill(request(name="Different"), tools)
finally:
host_bridge.operation_id.reset(token)
assert changed_replay.value.code == "USER_SKILL_OPERATION_CONFLICT"
listed, total = user_skills.list_user_skills(tools, limit=100, offset=0)
assert total == 1 and listed[0] == created
updated = user_skills.update_user_skill(
created.skill_id, request(revision=created.revision, name="Edited"), tools
)
assert updated.data.name == "Edited" and updated.data.version == 2
with pytest.raises(ApiError, match="用户 Skill 已被其他设备修改") as conflict:
user_skills.update_user_skill(
created.skill_id, request(revision=created.revision, name="Stale"), tools
)
assert conflict.value.code == "USER_SKILL_REVISION_CONFLICT"
user_skills.delete_user_skill(created.skill_id, updated.revision)
assert user_skills.list_user_skills(tools, limit=100, offset=0)[1] == 0
def test_user_skill_declarations_are_validated_and_runtime_stays_device_gated(monkeypatch):
tools = FakeTools({"notes.read": "notes.read", "notes.write": "notes.write"})
document = {
"record": {
"schema": 1,
"kind": "user_skill",
"id": "user_skill_00000000000000000000000000000001",
"data": {
"version": 1,
"name": "Writer",
"description": "",
"prompt": "Write only after confirmation.",
"tools": ["notes.write"],
"permissions": [],
"retrieval": {"top_k": 10, "rerank": True, "citation": True},
"required_capabilities": ["chat"],
"created_at_ms": 1,
"updated_at_ms": 1,
},
},
"hash": "c" * 64,
"file_id": "file-1",
}
monkeypatch.setattr(user_skills, "call", lambda method, **params: document)
skill = user_skills.get_user_skill(document["record"]["id"], tools)
assert skill.status == "permission_required"
assert skill.undeclared_permissions == ["notes.write"]
with pytest.raises(ApiError) as not_ready:
user_skills.build_agent_configuration(
skill.skill_id, [ModelCapability.chat], tools
)
assert not_ready.value.code == "USER_SKILL_NOT_READY"
document["record"]["data"]["permissions"] = ["notes.write"]
document["record"]["data"]["required_capabilities"] = ["vision"]
with pytest.raises(ApiError) as missing_capability:
user_skills.build_agent_configuration(skill.skill_id, [ModelCapability.chat], tools)
assert missing_capability.value.code == "USER_SKILL_MODEL_CAPABILITY_MISSING"
document["record"]["data"]["required_capabilities"] = ["chat"]
config = user_skills.build_agent_configuration(skill.skill_id, [ModelCapability.chat], tools)
assert config.allowed_tools == ["notes.write"]
assert config.permissions == ["notes.write"]
assert config.system_prompt == "Write only after confirmation."
calls = []
monkeypatch.setattr(user_skills, "call", lambda *args, **kwargs: calls.append((args, kwargs)))
with pytest.raises(ApiError) as unknown:
user_skills.create_user_skill(request(permissions=["secrets.export"]), tools)
assert unknown.value.code == "USER_SKILL_PERMISSION_UNKNOWN"
assert calls == []
with pytest.raises(ValidationError):
UserSkillWriteRequest.model_validate({
**request().model_dump(), "api_key": "must-never-enter-a-record"
})
def test_authenticated_sidecar_uses_uuid_idempotency_key_for_host_journal():
seen = []
async def app(scope, receive, send):
seen.append((host_bridge.vault_id.get(), host_bridge.operation_id.get()))
await send({"type": "http.response.start", "status": 204, "headers": []})
await send({"type": "http.response.body", "body": b""})
async def invoke(idempotency: bytes, request_id: bytes):
async def receive():
return {"type": "http.disconnect"}
async def send(message):
return None
scope = {
"type": "http",
"headers": [
(b"authorization", b"Bearer secret"),
(b"x-core-generation", b"generation"),
(b"host", b"127.0.0.1:1234"),
(b"x-opennexus-vault", b"00000000-0000-4000-8000-000000000010"),
(b"x-request-id", request_id),
(b"idempotency-key", idempotency),
],
}
await SessionAuth(app, "secret", "generation", 1234)(scope, receive, send)
stable = b"00000000-0000-4000-8000-000000000020"
fallback = b"00000000-0000-4000-8000-000000000030"
asyncio.run(invoke(stable, fallback))
asyncio.run(invoke(b"media-upload-key", fallback))
asyncio.run(invoke(b"a" * 36, fallback))
assert seen == [
("00000000-0000-4000-8000-000000000010", stable.decode()),
("00000000-0000-4000-8000-000000000010", fallback.decode()),
("00000000-0000-4000-8000-000000000010", fallback.decode()),
]
def test_installed_packages_cannot_claim_the_user_skill_record_namespace(tmp_path):
package = tmp_path / "reserved"
package.mkdir()
(package / "skill.yaml").write_text(
"skill_id: user_skill_00000000000000000000000000000001\n"
"name: collision\nversion: 1.0.0\n",
encoding="utf-8",
)
with pytest.raises(ExtensionError) as error:
SkillRuntime(ToolRegistry()).install(package)
assert error.value.code == "SKILL_ID_RESERVED"
+12 -1
View File
@@ -63,7 +63,7 @@ Host 在写入 journal、捕获外部修改和上传前验证记录;未知字
桌面 Task CRUD 经 Host records broker,操作重放返回原始记录且同 ID 不同字段拒绝;Core 不以全局 SQLite 作为任务来源。已明确归属于当前 Vault 的旧 Task 表在首次访问时逐条迁移,完成后设置所有权标记,来源表保留;未分配 Vault 的全局旧数据不猜测归属。文件身份采纳时,任务链接通过 Host 别名解析,并在上传队列物化前将规范化引用写成新的逻辑记录。
用户 Skill/配置、可选对话等仍须逐类定义白名单与适配器;不得用复制任意 JSON/SQLite 代替。
用户 Skill 的独立白名单与适配器见下文;可选对话等其他类别仍须逐类定义不得用复制任意 JSON/SQLite 代替。
## 主题与编辑器偏好记录 v1
@@ -82,6 +82,17 @@ Host 在写入 journal、捕获外部修改和上传前验证记录;未知字
本机已有三项侧栏 localStorage 值仍作为初始偏好读取并保存,不删除来源。组件共享布局状态,远端值立即反映在侧栏;窗口可用空间不足时仅收窄渲染宽度,不改写同步偏好,恢复空间后恢复偏好宽度。布局记录不含本机窗口坐标、显示器信息、已打开文件路径或执行权限。本版本未支持的旧客户端不能被视作已验证兼容,跨版本发布兼容验收仍需覆盖新增记录类别。
## 用户 Skill 记录 v1
用户创建的声明式 Skill 使用 `opennexus-records/v1/user-skills/user_skill_<32位小写十六进制>.json`kind 为 `user_skill`,业务 ID 与文件 `file_id` 分离;`user_skill_` 前缀由此命名空间保留,安装包不得占用。data 白名单为 version、name、description、prompt、tools、permissions、retrieval、required_capabilities、created_at_ms、updated_at_ms;记录信封和内容摘要继续作为文件 CAS 与冲突依据。用户 Skill 默认参与当前 Vault 同步。
name 为 1128 个 Unicode 字符且不能全为空白,description 最多 2000 字符,prompt 最多 64000 字符。tools 最多 64 个、permissions 最多 32 个、required_capabilities 最多 16 个;各列表不得重复,标识仅允许 ASCII 字母、数字、点、下划线和连字符。retrieval 只含 top_k 1100、rerank、citation;模型能力和权限采用当前公开枚举。时间为非负 UTC Unix 毫秒且 updated_at_ms 不早于 created_at_msversion 不超过 JavaScript 安全整数。整体记录仍受 1 MiB 上限约束。
同步的 permissions 是 Skill 对工具需求的声明,不是目标设备授权。API Key、令牌、设备授权、启用状态、正在运行的 Agent、安装目录、包来源、环境变量和秘密值均不进入记录;新设备按本机 Tool 可用性、权限策略和模型能力重新判断。缺少工具或声明不足的记录可编辑和同步,但不可选择运行;用户明确选择一个 ready 的 Skill 后,每次工具调用仍经过设备本地 PermissionManager。
Core 经 Host 提供按 Vault 的 list/get/create/update/delete,创建 ID 从操作 UUID 稳定派生。相同操作 UUID 和相同载荷返回 Host 持久化的原回执;相同 UUID 配合不同 ID、CAS 基线、操作类型或字段值返回 `USER_SKILL_OPERATION_CONFLICT`。更新和删除必须携带 64 位内容 revision;过期 revision 返回 `USER_SKILL_REVISION_CONFLICT`。前端切换 Vault 后丢弃迟到响应,不能把旧 Vault 的列表或保存结果发布到新 Vault。
## 工作区人设记录 v1
桌面端使用 `opennexus-records/v1/persona/default.json`schema=1、kind=persona、id=default。data 白名单为 version02^531 整数)、name(最多 128 Unicode 标量)、system_prompt(最多 16000 Unicode 标量)、dialogue_pairs(最多 20 对,每对仅 user/assistant,各最多 8000 Unicode 标量);整条记录仍受 1 MiB 字节限制。人设正文是用户内容,不自动赋予权限或启动任务。未知字段一律拒绝。
@@ -72,6 +72,11 @@ Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault
| POST | `/api/skills/{skill_id}/enable` | 启用 Skill |
| POST | `/api/skills/{skill_id}/disable` | 停用 Skill |
| DELETE | `/api/skills/{skill_id}` | 卸载 Skill |
| GET | `/api/user-skills` | 列出当前 Vault 的用户 Skill 逻辑记录 |
| POST | `/api/user-skills` | 新建用户 Skill;支持 UUID Idempotency-Key |
| GET | `/api/user-skills/{skill_id}` | 读取用户 Skill 与内容 revision |
| PUT | `/api/user-skills/{skill_id}` | 以内容 revision CAS 更新用户 Skill |
| DELETE | `/api/user-skills/{skill_id}?revision=...` | 以内容 revision CAS 删除用户 Skill |
| GET | `/api/plugins` | 获取 Plugin 列表及状态 |
| POST | `/api/plugins/install` | 安装 Plugin |
| GET | `/api/plugins/{plugin_id}` | 获取 Plugin Manifest 与状态 |
@@ -761,3 +761,14 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- 修复同时解除首次上传/合并 seedCurrentPreferences 中准备布局记录时的 RECORD_SCOPE_DENIED 障碍;可选范围过滤仍由独立 sync_scope 决定,允许本机编辑不等于授权上传。尚未补齐用户 Skill 编辑/逻辑记录,本轮优先修复实际边界缺陷。
- 完整 desktop/all-targets Rust 回归 146 通过、12 ignoredlibrary 129、Host 10、其他集成 7),包括真实 Core 桥接与 Sync 服务集成;Clippy --all-targets -D warnings 通过。日志 .build/layout-command-full.log 与 .build/layout-command-clippy.log。未执行真正双机桌面 UI 验收,不能据此标记整体生产化完成。
## 增量:当前 Vault 的用户 Skill 逻辑记录
- 新增 `user_skill_<32位小写十六进制>` 严格逻辑记录,Host 固定存入 `opennexus-records/v1/user-skills/`;名称、提示词、工具、权限声明、模型能力、检索参数、版本与时间均有类型、数量和长度上限,未知字段在写入 journal 前拒绝。该类别进入默认同步范围;安装目录、密钥、设备授权、启用状态和运行环境不能写入记录,安装包也不能占用 `user_skill_` 保留命名空间。
- Core 增加当前 Vault 专用的 list/get/create/update/delete API,并通过窄化 Host RPC 访问,不能借操作回执读取 persona/task 等记录。更新和删除使用摘要 CAS;有效 UUID `Idempotency-Key` 进入 Host journal,同一操作与相同输入可在响应未知后重放,改变目标、旧摘要、操作类型或业务字段会返回 `USER_SKILL_OPERATION_CONFLICT`。Workspace 持久回执补充原始 expected 摘要,重开后仍能完成输入一致性核验。
- Skill 页面增加用户 Skill 编辑器,明确权限字段只是声明而非设备授权;工具缺失、权限声明不足与可运行状态分开展示。Agent 页面只列可运行记录,实际 `/api/agent/runs` 会从 Host 读取当前 Vault 的用户 Skill 并再次核对本机工具、权限声明和模型能力。列表按 Host 分页读取,避免超过 100 项时静默截断。
- 前端保存和删除在结果不明确时保留原 UUID;同一 Vault、记录和表单内容重试会复用该 UUID,内容改变才产生新操作。Vault 切换同步清除编辑状态和待重试标识,迟到的列表或保存结果不得写入新 Vault 的界面状态。该内存重试状态不跨应用完全退出,不能将其视作跨重启 UI 草稿恢复。
- 实际 Python Core + Rust Host 集成覆盖创建、20 次相同重放、改变输入冲突、列表、跨 Vault 拒绝、CAS 更新、过期摘要拒绝、Agent 选择运行、删除及关闭前 journal 计数。实际 Uvicorn Sync + 两个 Rust Workspace 客户端对用户 Skill 进行了 60 轮同时修改,保留本地/采用远端/创建文本副本各 20 轮,并验证旧摘要拒绝、重复解决、两端收敛、重开稳定;第三个默认范围客户端可接收用户 Skill,同时仍排除未选择的人设与布局。
- 最终 desktop Rust 全目标 148 通过、12 ignoredlibrary 131、Host 10、其余集成 7),Clippy `--all-targets -D warnings` 通过,日志 `.build/user-skill-rust-full-final.log``.build/user-skill-clippy-current.log`。后端 908 项通过、1 项依赖弃用警告,日志 `.build/user-skill-python-full-current.log`。前端 103 个测试文件、536 项通过,类型检查和生产构建通过,日志 `.build/user-skill-frontend-full-final2.log``.build/user-skill-frontend-build-final2.log`
- 证据来自本机隔离服务和客户端进程,尚未替代两台独立硬件上的桌面 UI、跨发布版本或每个持久化边界强制终止验收。Provider 非秘密参数、安装清单、对话等规划内逻辑数据仍未适配;完整生产化目标保持未完成。
+117 -1
View File
@@ -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();
+53 -5
View File
@@ -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"]}),
))
}
}
+16 -4
View File
@@ -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();
+145 -1
View File
@@ -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);
}
+24 -2
View File
@@ -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()
+35
View File
@@ -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 =
+1 -1
View File
@@ -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 } })
})
+25 -1
View File
@@ -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 },
})
}
+51 -5
View File
@@ -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,
}
})