mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-18 23:33:05 +00:00
feat(agent): Add Agent Skill management and read tools
- Add file-backed Skill storage with import preview, install, export, source refs, and safe file reads - Expose the Skill Host API and embedded Preset/Character Skill import prompts - Register skill.list and skill.read in the Agent tool loop - Cover archive roundtrips, source cleanup, symlink safety, and API contracts - Refresh Agent docs around the current Skill capability boundary
This commit is contained in:
parent
20a42e722b
commit
f990f778c9
61 changed files with 5548 additions and 88 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# `window.__TAURITAVERN__.api.agent` — Agent API
|
||||
|
||||
本文档是 Agent Host ABI 草案。它描述前端/扩展可见的稳定入口,不等同于 Rust 内部 service/repository。
|
||||
本文档是 Agent Host ABI 当前参考。它描述前端/扩展可见的稳定入口,不等同于 Rust 内部 service/repository。
|
||||
|
||||
状态:当前已实现 canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、workspace 读改工具循环与前端 dryRun adapter。本文以当前已落地 Host ABI 为准,并在后续章节保留 readDiff/rollback/approval/listRuns 等未来草案。
|
||||
状态:当前已实现 canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、Skill tools、workspace 读改工具循环与前端 dryRun adapter。本文以当前已落地 Host ABI 为准,并在后续章节保留 readDiff/rollback/approval/listRuns 等未来设计。
|
||||
|
||||
`provider_state` 是 Rust 后端内部 continuation contract,不是 Host ABI。前端/扩展不应读写 `_tauritavern_provider_state`;需要诊断时通过 run events、`modelResponsePath` 与 LLM API log 观察。
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ Public Host ABI 可以允许调用方省略 `stableChatId`,但 `api.agent.star
|
|||
- 如果调用方希望 `worldinfo_read_activated` 返回非错误结果,必须在 prompt snapshot 中提供本次 run 的 `worldInfoActivation`。
|
||||
- 当前拒绝已有 `tools`、`tool_choice`、`role: "tool"` 或已有 `tool_calls` 的外部 tool turns。
|
||||
- 当前拒绝 `stream: true` 与 `autoCommit: true`。
|
||||
- `workspaceMode` / `resumeRunId` 当前只是后续阶段字段,不应作为当前行为依赖。
|
||||
- `workspaceMode` / `resumeRunId` 当前只是后续字段,不应作为当前行为依赖。
|
||||
- 参数无效必须 reject,不静默回退 Legacy Generate。
|
||||
|
||||
## 5. subscribe
|
||||
|
|
@ -375,7 +375,7 @@ commit.cursor_integrity
|
|||
commit.save_failed
|
||||
```
|
||||
|
||||
## 15. Rust Command 草案
|
||||
## 15. Rust Command
|
||||
|
||||
```text
|
||||
start_agent_run(dto)
|
||||
|
|
@ -388,7 +388,7 @@ finalize_agent_run_commit(dto)
|
|||
|
||||
Command 层必须是薄封装。Agent loop 不写在 command 内。
|
||||
|
||||
后续草案命令:
|
||||
后续命令:
|
||||
|
||||
```text
|
||||
approve_agent_tool_call(dto)
|
||||
|
|
@ -414,20 +414,22 @@ Agent Mode on:
|
|||
|
||||
## 17. 当前工具与手动验证
|
||||
|
||||
当前开放八个内建工具:
|
||||
当前开放十个内建工具:
|
||||
|
||||
| Canonical name | Model-facing alias | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `chat.search` | `chat_search` | 搜索当前 run 绑定的聊天。只有 `query` 必填;可选 `limit`、`role`、`start_message`、`end_message`、`scan_limit`。返回 message index、snippet 与 ref。 |
|
||||
| `chat.read_messages` | `chat_read_messages` | 按 0-based message index 读取当前聊天消息;每项可选 `start_char`、`max_chars` 读取长消息片段。 |
|
||||
| `worldinfo.read_activated` | `worldinfo_read_activated` | 读取本次 run 的最终激活世界书条目;模型可读文本只包含条目名、世界书名、条目内容。 |
|
||||
| `skill.list` | `skill_list` | 列出已安装 Skill 的索引摘要;当前尚未接入 profile visible/deny policy。 |
|
||||
| `skill.read` | `skill_read` | 读取已安装 Skill 内的 UTF-8 文本文件;默认 `SKILL.md`,支持 `path` 与 `max_chars`。 |
|
||||
| `workspace.list_files` | `workspace_list_files` | 列出模型可见 workspace 文件;`path` 省略、空字符串、`.`、`./` 表示 workspace root |
|
||||
| `workspace.read_file` | `workspace_read_file` | 读取 UTF-8 文本文件并返回行号;完整读取记录 read-state |
|
||||
| `workspace.write_file` | `workspace_write_file` | 写 UTF-8 文本到 manifest 可写 roots,当前为 `output/`、`scratch/`、`plan/`、`summaries/`、`persist/` |
|
||||
| `workspace.apply_patch` | `workspace_apply_patch` | 单文件 `old_string` / `new_string` 精确替换,要求已完整读取或由本 run 创建/修改 |
|
||||
| `workspace.finish` | `workspace_finish` | 结束工具循环,默认 final artifact 为 `output/main.md` |
|
||||
|
||||
当前不存在 `skill.list`、`skill.read`、MCP、shell 或 extension bridge 工具。
|
||||
当前不存在 MCP、shell 或 extension bridge 工具。
|
||||
|
||||
模型可修正的工具错误会作为 `is_error = true` tool result 回填下一轮。宿主级 IO、journal、checkpoint、序列化、取消和模型响应结构错误仍然让 run failed。
|
||||
|
||||
|
|
@ -446,4 +448,4 @@ const run = await agent.startRunFromLegacyGenerate({
|
|||
const stop = agent.subscribe(run.runId, event => console.log(event));
|
||||
```
|
||||
|
||||
更完整的多阶段工具循环 smoke 见 `docs/CurrentState/AgentFramework.md`。
|
||||
更完整的多轮工具循环 smoke 见 `docs/CurrentState/AgentFramework.md`。
|
||||
|
|
|
|||
|
|
@ -32,8 +32,11 @@ const api = host?.api;
|
|||
- 提供 Extension KV JSON + Blob 存储,支持多 table。
|
||||
- `api.agent`(已落地 canonical model IR、provider_state continuation、上下文只读工具与 workspace 读改工具循环)
|
||||
- 面向 Agent Run / timeline / workspace / checkpoint / commit。
|
||||
- 当前提供启动 run、订阅/读取事件、取消、读取 workspace 文件、prepare/finalize/commit;模型侧内建 chat search/read、world info read 与 workspace list/read/write/patch/finish 工具由 Rust runtime 注册。
|
||||
- approval、listRuns、readDiff、rollback 仍是后续阶段;当前入口显式 throw。
|
||||
- 当前提供启动 run、订阅/读取事件、取消、读取 workspace 文件、prepare/finalize/commit;模型侧内建 chat search/read、world info read、skill list/read 与 workspace list/read/write/patch/finish 工具由 Rust runtime 注册。
|
||||
- approval、listRuns、readDiff、rollback 仍是后续工作;当前入口显式 throw。
|
||||
- `api.skill`(已落地)
|
||||
- 面向本地 Agent Skill 管理。
|
||||
- 当前提供 list、previewImport、installImport、readFile、export/exportSkill;Agent run 内只能通过 `skill.list` / `skill.read` 工具消费已安装 Skill。
|
||||
- `api.mcp`(规划中)
|
||||
- 面向 MCP server/tool/resource/prompt 的独立平台能力。
|
||||
- Agent 可以消费 MCP,但 MCP 不依附 Agent Mode。
|
||||
|
|
@ -47,7 +50,8 @@ const api = host?.api;
|
|||
| [Dev.md](Dev.md) | `api.dev` 完整参考 |
|
||||
| [WorldInfo.md](WorldInfo.md) | `api.worldInfo` 完整参考 |
|
||||
| [ExtensionStore.md](Extension.md) | `api.extension.store` 完整参考 |
|
||||
| [Agent.md](Agent.md) | `api.agent` 草案(Agent Run / workspace / timeline) |
|
||||
| [Agent.md](Agent.md) | `api.agent` 当前参考(Agent Run / workspace / timeline) |
|
||||
| [Skill.md](Skill.md) | `api.skill` 完整参考(Skill 管理、导入导出、读取) |
|
||||
| [MCP.md](MCP.md) | `api.mcp` 草案(MCP server/tool/resource/prompt) |
|
||||
| [Migration.md](Migration.md) | 从 SillyTavern 扩展迁移到 TauriTavern 的适配指南 |
|
||||
|
||||
|
|
@ -55,5 +59,6 @@ const api = host?.api;
|
|||
|
||||
- API 类型定义见 `src/types.d.ts`
|
||||
- 宿主契约与稳定性边界见 `docs/FrontendHostContract.md`
|
||||
- Agent 已落地当前 Host ABI、canonical model IR、provider_state continuation、上下文只读工具与 workspace 读改工具;真实边界见 `docs/API/Agent.md`、`docs/CurrentState/AgentFramework.md` 与 `docs/CurrentState/AgentProviderState.md`
|
||||
- Agent 已落地当前 Host ABI、canonical model IR、provider_state continuation、上下文只读工具、Skill tools 与 workspace 读改工具;真实边界见 `docs/API/Agent.md`、`docs/CurrentState/AgentFramework.md` 与 `docs/CurrentState/AgentProviderState.md`
|
||||
- Skill 管理 API 真实边界见 `docs/API/Skill.md` 与 `docs/Agent/Skill.md`
|
||||
- MCP 仍处于规划阶段;实现前请以 `docs/API/MCP.md` 与 `docs/FrontendHostContract.md` 的草案约束为准
|
||||
|
|
|
|||
102
docs/API/Skill.md
Normal file
102
docs/API/Skill.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# `window.__TAURITAVERN__.api.skill` — Skill API
|
||||
|
||||
Skill API 用于管理本地 Agent Skill。它不是 Agent run API;Agent 只是 Skill 的消费者之一。
|
||||
|
||||
## 入口
|
||||
|
||||
```js
|
||||
await (window.__TAURITAVERN__?.ready ?? window.__TAURITAVERN_MAIN_READY__);
|
||||
|
||||
const skill = window.__TAURITAVERN__.api.skill;
|
||||
```
|
||||
|
||||
## 方法
|
||||
|
||||
```ts
|
||||
type TauriTavernSkillApi = {
|
||||
list(): Promise<TauriTavernSkillIndexEntry[]>;
|
||||
previewImport(input: TauriTavernSkillImportInput): Promise<TauriTavernSkillImportPreview>;
|
||||
installImport(request: {
|
||||
input: TauriTavernSkillImportInput;
|
||||
conflictStrategy?: 'skip' | 'replace';
|
||||
}): Promise<TauriTavernSkillInstallResult>;
|
||||
readFile(options: {
|
||||
name: string;
|
||||
path: string;
|
||||
maxChars?: number;
|
||||
}): Promise<TauriTavernSkillReadResult>;
|
||||
export(options: { name: string }): Promise<TauriTavernSkillExportPayload>;
|
||||
exportSkill(options: { name: string }): Promise<TauriTavernSkillExportPayload>;
|
||||
};
|
||||
```
|
||||
|
||||
## 导入输入
|
||||
|
||||
```ts
|
||||
type TauriTavernSkillImportInput =
|
||||
| {
|
||||
kind: 'inlineFiles';
|
||||
files: Array<{
|
||||
path: string;
|
||||
encoding?: 'utf8' | 'utf-8' | 'base64';
|
||||
content: string;
|
||||
mediaType?: string;
|
||||
sizeBytes?: number;
|
||||
sha256?: string;
|
||||
}>;
|
||||
source?: unknown;
|
||||
}
|
||||
| {
|
||||
kind: 'directory';
|
||||
path: string;
|
||||
source?: unknown;
|
||||
}
|
||||
| {
|
||||
kind: 'archiveFile';
|
||||
path: string;
|
||||
source?: unknown;
|
||||
};
|
||||
```
|
||||
|
||||
`source` 用于记录来源引用。Preset / Character embedded import 会传入稳定来源 ID,以便删除 Preset / Character 时清理仅由该来源引用的 Skill。
|
||||
|
||||
## 冲突语义
|
||||
|
||||
`previewImport()` 会返回:
|
||||
|
||||
```ts
|
||||
type conflict.kind = 'new' | 'same' | 'different';
|
||||
```
|
||||
|
||||
- `new`:同名 Skill 不存在。
|
||||
- `same`:同名且内容 hash 相同。
|
||||
- `different`:同名但内容 hash 不同,安装时必须传 `conflictStrategy`。
|
||||
|
||||
`installImport()` 的结果:
|
||||
|
||||
```ts
|
||||
type action = 'installed' | 'replaced' | 'already_installed' | 'skipped';
|
||||
```
|
||||
|
||||
不同 hash 冲突没有显式 `skip` / `replace` 时会 reject,不会自动改名。
|
||||
|
||||
## 读取与导出
|
||||
|
||||
`readFile()`:
|
||||
|
||||
- 只能读取已安装 Skill 内的 UTF-8 文本文件。
|
||||
- `path` 必须是 Skill 相对路径。
|
||||
- `maxChars` 省略时默认 20000,后端最大 80000。
|
||||
- 二进制文件、非法路径、symlink escape、缺失文件都会 reject。
|
||||
|
||||
`export()` / `exportSkill()`:
|
||||
|
||||
- 返回 base64 编码的 `.ttskill` zip。
|
||||
- `.ttskill` 只包含 Skill 文件本身;不会写入会改变内容 hash 的导出诊断文件。
|
||||
|
||||
## 兼容边界
|
||||
|
||||
- Skill 管理不是 SillyTavern 上游 API。
|
||||
- Skill import/export 不触发上游 `GENERATION_*`、`TOOL_CALLS_*` 或 regex 事件。
|
||||
- Agent Mode off 时,Legacy Generate 不读取 Skill。
|
||||
- 模型不能通过 `api.skill` 安装或替换 Skill;当前 Skill 安装只由用户 UI / Host ABI 显式触发。
|
||||
|
|
@ -6,9 +6,9 @@
|
|||
|
||||
- MCP 是独立平台集成能力,不是 Agent Runtime 本体。
|
||||
- Skill 是渐进披露的文本/资源包,不是自动吞入 prompt 的大文件。
|
||||
- Agent 可以消费 MCP 和 Skill,但必须经过 ToolRegistry、ContextFrame、Policy 与 Journal。
|
||||
- Agent 可以消费 MCP 和 Skill,但必须经过 ToolRegistry、Policy 与 Journal;不得由 prompt 直接打开旁路。
|
||||
|
||||
当前状态(2026-05-02):尚未实现 `window.__TAURITAVERN__.api.mcp`,也未把 MCP/Skill 接入 Agent tool registry。当前 Agent registry 已有内建工具:`chat.search`、`chat.read_messages`、`worldinfo.read_activated`、`workspace.list_files`、`workspace.read_file`、`workspace.write_file`、`workspace.apply_patch`、`workspace.finish`。
|
||||
当前状态(2026-05-02):尚未实现 `window.__TAURITAVERN__.api.mcp`,MCP 也未接入 Agent tool registry。Skill 已落地 `window.__TAURITAVERN__.api.skill`,并已通过 `skill.list` / `skill.read` 接入 Agent tool registry。当前 Skill 细节以 `docs/Agent/Skill.md` 与 `docs/API/Skill.md` 为准。
|
||||
|
||||
## 1. MCP 边界
|
||||
|
||||
|
|
@ -120,20 +120,17 @@ skills/
|
|||
assets/
|
||||
```
|
||||
|
||||
`SKILL.md` frontmatter 示例:
|
||||
当前 `SKILL.md` frontmatter 最小示例:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: long-form-romance
|
||||
description: 长篇恋爱剧情写作技巧
|
||||
allowed-tools:
|
||||
- workspace.read_file
|
||||
- workspace.apply_patch
|
||||
when-to-use: 当需要扩写情感铺垫和细腻心理描写时
|
||||
context: lazy
|
||||
---
|
||||
```
|
||||
|
||||
工具权限、MCP server、预算与可见性不由 Skill frontmatter 直接授予;这些能力必须进入 profile/policy/approval 体系。TauriTavern 专属 metadata 可放在可选 `agents/tauritavern.json`,schema 无效时应 fail-fast。
|
||||
|
||||
## 8. Skill 读取策略
|
||||
|
||||
Agent 默认只看到 skill 索引:
|
||||
|
|
@ -141,17 +138,15 @@ Agent 默认只看到 skill 索引:
|
|||
```text
|
||||
name
|
||||
description
|
||||
when-to-use
|
||||
allowed-tools
|
||||
```
|
||||
|
||||
读取全文必须通过:
|
||||
|
||||
```text
|
||||
skill.read(name, section?, budget?)
|
||||
skill.read(name, path?, max_chars?)
|
||||
```
|
||||
|
||||
`skill.read` 是 tool call,必须写 journal。Preset 可以声明某些 skill 自动进入 context,但仍受 budget 管控。
|
||||
`skill.read` 是 tool call,必须写 journal。当前默认读取 `SKILL.md`,只支持 UTF-8 文本文件;profile/preset/character 的 visible、deny 与 read budget policy 仍是后续工作。
|
||||
|
||||
## 9. Skill 来源
|
||||
|
||||
|
|
@ -167,38 +162,44 @@ Skill 可以来自:
|
|||
|
||||
## 10. Skill 与 Workspace
|
||||
|
||||
Skill 可以表现为 workspace resource:
|
||||
当前 Skill 不挂进 Agent workspace 根,而是通过 `skill.read` 作为 tool result 回填模型。未来如果需要统一资源视图,可以把 Skill 表现为 read-only virtual resource:
|
||||
|
||||
```text
|
||||
skills/<name>/SKILL.md
|
||||
skills/<name>/examples/foo.md
|
||||
```
|
||||
|
||||
但它默认是 read-only virtual resource。Agent 不能修改原始 skill;如果需要摘录或改写,应写入 `scratch/` 或 `summaries/`。
|
||||
它必须保持 read-only。Agent 不能修改原始 skill;如果需要摘录或改写,应写入 `scratch/`、`summaries/` 或 `output/`。
|
||||
|
||||
## 11. Agent Context
|
||||
|
||||
Skill 进入 prompt 的路径:
|
||||
Skill 进入模型上下文的当前路径:
|
||||
|
||||
```text
|
||||
skill.list / preset auto include
|
||||
skill.list / skill.read
|
||||
-> SkillService
|
||||
-> WorkspaceResource or PromptComponent::Skill
|
||||
-> ContextAssemblyService
|
||||
-> ModelRequest
|
||||
-> Agent tool result
|
||||
-> ModelRequest next turn
|
||||
```
|
||||
|
||||
不要把所有 Skill 全文塞进 system prompt。
|
||||
|
||||
## 12. 后续最小实现
|
||||
## 12. 当前已落地与后续
|
||||
|
||||
Skill 最小实现:
|
||||
Skill 已落地:
|
||||
|
||||
- `SkillRepository` 列出本地 skill。
|
||||
- `SkillRepository` / `FileSkillRepository` / `SkillService`。
|
||||
- `api.skill` 管理入口。
|
||||
- `.ttskill` 导入导出。
|
||||
- Preset / Character embedded skill 导入确认与 source ref 清理。
|
||||
- `skill.list`。
|
||||
- `skill.read`。
|
||||
- profile/preset 控制可见 skill。
|
||||
- skill content 受 budget 限制。
|
||||
- tool result 写 journal。
|
||||
|
||||
Skill 后续只补:
|
||||
|
||||
- profile/preset/character 控制可见 skill。
|
||||
- skill content 受 read budget 限制。
|
||||
- recommended skill 与 embedded skill 在 profile resolver 中合流。
|
||||
|
||||
MCP 最小实现应晚于内置工具系统稳定之后。
|
||||
|
|
|
|||
|
|
@ -411,7 +411,9 @@ profile_switch_denied
|
|||
"workspace.finish",
|
||||
"chat.search",
|
||||
"chat.read_messages",
|
||||
"worldinfo.read_activated"
|
||||
"worldinfo.read_activated",
|
||||
"skill.list",
|
||||
"skill.read"
|
||||
],
|
||||
"deny": ["*"]
|
||||
},
|
||||
|
|
@ -423,4 +425,4 @@ profile_switch_denied
|
|||
}
|
||||
```
|
||||
|
||||
这个 profile 足以支撑当前上下文只读工具与 workspace 读改工具循环,同时给后续 Skill、profile routing 与 MCP 留出自然扩展点。
|
||||
这个 profile 足以支撑当前上下文只读工具、Skill 读取与 workspace 读改工具循环。后续 profile routing 应在此基础上收窄 Skill 可见性、deny policy 与 read budget,而不是新增平行工具入口。
|
||||
|
|
|
|||
|
|
@ -16,10 +16,11 @@
|
|||
| [ToolSystem.md](ToolSystem.md) | ToolSpec、ToolResult、Tool Registry、Policy、审批与 Legacy ToolManager 边界 |
|
||||
| [LlmGateway.md](LlmGateway.md) | provider-agnostic LLM gateway 与现有 `ChatCompletionService` 的复用边界 |
|
||||
| [McpSkill.md](McpSkill.md) | MCP 独立集成、Skill 渐进披露、安全边界 |
|
||||
| [Skill.md](Skill.md) | 当前 Skill 格式、存储、导入导出、Agent tool 与安全边界 |
|
||||
| [TestingStrategy.md](TestingStrategy.md) | Domain/Application/Frontend/Security/Performance 测试矩阵 |
|
||||
|
||||
## 进度跟踪
|
||||
|
||||
实时开发进度不写在本目录;请更新 `docs/CurrentState/AgentFramework.md`。
|
||||
|
||||
截至 2026-05-02,canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、workspace 读改工具循环与前端 dryRun adapter 已落地。当前真实能力边界以 `docs/CurrentState/AgentFramework.md` 与 `docs/CurrentState/AgentProviderState.md` 为准;本目录中的 profile、MCP、timeline、diff/rollback 等内容仍是后续设计。
|
||||
截至 2026-05-02,canonical model IR、provider native metadata 保真、provider_state continuation、上下文只读工具、Skill 管理与读取、workspace 读改工具循环、前端 dryRun adapter 已落地。当前真实能力边界以 `docs/CurrentState/AgentFramework.md` 与 `docs/CurrentState/AgentProviderState.md` 为准;本目录中的 profile、MCP、timeline、diff/rollback 等内容仍是后续设计。
|
||||
|
|
|
|||
131
docs/Agent/Skill.md
Normal file
131
docs/Agent/Skill.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# TauriTavern Agent Skill
|
||||
|
||||
本文档记录当前已落地的 Agent Skill 能力。长期开发以本文、`docs/CurrentState/AgentFramework.md`、`docs/Agent/ToolSystem.md` 与 `docs/API/Skill.md` 为准。
|
||||
|
||||
## 定义
|
||||
|
||||
Skill 是 Agent 按需读取的本地知识包,不是可执行插件,也不是默认注入 prompt 的大段文本。
|
||||
|
||||
一个 Skill 是一个目录:
|
||||
|
||||
```text
|
||||
my-skill/
|
||||
SKILL.md
|
||||
references/
|
||||
examples/
|
||||
assets/
|
||||
scripts/
|
||||
agents/tauritavern.json
|
||||
```
|
||||
|
||||
当前要求:
|
||||
|
||||
- `SKILL.md` 必须存在,并以 YAML frontmatter 开头。
|
||||
- frontmatter 必须包含 `name` 与 `description`。
|
||||
- `name` 使用小写 ASCII、数字、`-`、`_`,最长 128。
|
||||
- `agents/tauritavern.json` 可选;一旦存在,schema 无效就 fail-fast。
|
||||
- `scripts/` 会随 Skill 导入、导出,并在预览中提示风险;当前不会执行。
|
||||
|
||||
## 当前实现
|
||||
|
||||
已落地:
|
||||
|
||||
- Domain:`SkillIndexEntry`、`SkillImportInput`、`SkillImportPreview`、`SkillInstallRequest`、`SkillReadResult` 等模型。
|
||||
- Repository:`SkillRepository` trait 与文件实现 `FileSkillRepository`。
|
||||
- Service:`SkillService`。
|
||||
- Host ABI:`window.__TAURITAVERN__.api.skill`。
|
||||
- Agent tools:`skill.list` / `skill.read`,模型侧 alias 为 `skill_list` / `skill_read`。
|
||||
- Preset / Character embedded skill 扫描与导入确认 UI。
|
||||
- Preset / Character 删除时,删除仅由该来源引用的已安装 Skill。
|
||||
|
||||
本地存储:
|
||||
|
||||
```text
|
||||
data_root/_tauritavern/skills/
|
||||
installed/<skill-name>/
|
||||
index/skills.json
|
||||
.staging/
|
||||
```
|
||||
|
||||
`FileSkillRepository` 只负责文件系统、zip、staging、索引与原子安装;运行时可见性、profile policy、journal 由 Agent tool/runtime 层负责。
|
||||
|
||||
## 导入导出
|
||||
|
||||
支持输入:
|
||||
|
||||
- `inlineFiles`:Preset / 角色卡嵌入 Skill 的文件列表。
|
||||
- `directory`:本地 Skill 目录。
|
||||
- `archiveFile`:`.ttskill` zip 包。
|
||||
|
||||
导入流程:
|
||||
|
||||
```text
|
||||
materialize input into .staging
|
||||
-> validate package
|
||||
-> compute files/hash/warnings
|
||||
-> compare installed index
|
||||
-> install / replace / skip
|
||||
-> update index
|
||||
```
|
||||
|
||||
冲突策略:
|
||||
|
||||
- 同名不存在:安装。
|
||||
- 同名且 hash 相同:视为已安装,并合并 source refs。
|
||||
- 同名但 hash 不同:必须显式 `skip` 或 `replace`。
|
||||
|
||||
导出:
|
||||
|
||||
- `api.skill.export({ name })` 返回 `{ fileName, contentBase64, sha256 }`。
|
||||
- `.ttskill` 内只包含 Skill 文件本身,不写入会改变内容 hash 的诊断 sidecar。
|
||||
|
||||
## Agent 读取
|
||||
|
||||
`skill.list`:
|
||||
|
||||
- 只读。
|
||||
- 返回已安装 Skill 的索引摘要。
|
||||
- 当前尚未接入 profile visible/deny policy,因此列出全局已安装 Skill。
|
||||
|
||||
`skill.read`:
|
||||
|
||||
- 只读。
|
||||
- 参数:`name`、可选 `path`、可选 `max_chars`。
|
||||
- `path` 默认 `SKILL.md`。
|
||||
- 只能读取 UTF-8 文本文件;二进制文件返回可恢复 tool error。
|
||||
- `max_chars` 默认 20000,最大 80000。
|
||||
- 结果写入 Agent journal / tool result,并作为后续模型上下文的一部分回填。
|
||||
|
||||
Skill 文件对 Agent 是只读 virtual resource。Agent 不能修改 installed Skill;需要摘录、总结或改写时写入 workspace 的 `scratch/`、`summaries/` 或 `output/`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
导入、安装、导出与 repository 层必须 fail-fast:
|
||||
|
||||
- `SKILL.md` 缺失、frontmatter 无效、`name` / `description` 缺失。
|
||||
- path traversal、绝对路径、Windows drive prefix、NUL、symlink escape。
|
||||
- zip entry 超限、压缩比超限、总大小超限。
|
||||
- `agents/tauritavern.json` schema 无效。
|
||||
- 同名冲突但没有用户决策。
|
||||
- index 缺失但 installed 目录已存在。
|
||||
|
||||
Agent tool 层的模型可修正读取错误,例如缺失文件、二进制文件、非法 path 或超出 read budget,应返回 recoverable tool error;repository 内部 IO、index 损坏和安装一致性错误仍 fail-fast。
|
||||
|
||||
当前限制:
|
||||
|
||||
- 不执行 Skill 自带脚本。
|
||||
- 不让 Skill 自动安装 MCP server。
|
||||
- 不让 Skill 授予工具权限。
|
||||
- 不支持 marketplace、自动更新、多版本并存或依赖解析。
|
||||
- 不支持模型在 run 内安装/替换 Skill。
|
||||
|
||||
## 后续开发
|
||||
|
||||
下一步只做必要能力:
|
||||
|
||||
- profile/preset/character 的最小 visible / deny / read budget policy。
|
||||
- `skill.list` 按 visible policy 收窄结果。
|
||||
- `skill.read` 按 policy 与 budget 拒绝不可见或过量读取。
|
||||
- 明确 recommended skill 与 embedded skill 在 profile resolver 中的合流规则。
|
||||
|
||||
不要把 Skill 扩展成脚本执行、权限授予或 MCP 配置入口;这些能力必须走独立的 tool/policy/approval 体系。
|
||||
1127
docs/Agent/SkillImportExport.md
Normal file
1127
docs/Agent/SkillImportExport.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
# TauriTavern Agent Testing Strategy
|
||||
|
||||
本文档定义 Agent 系统的测试策略。Agent 涉及生成、文件、工具、外部协议、保存与兼容事件,测试必须从第一阶段就建立。
|
||||
本文档定义 Agent 系统的测试策略。Agent 涉及生成、文件、工具、外部协议、保存与兼容事件,测试必须持续作为开发入口的一部分。
|
||||
|
||||
## 1. 测试目标
|
||||
|
||||
|
|
@ -74,7 +74,9 @@ workspace repository rejects symlink escape
|
|||
workspace repository handles unicode relative paths
|
||||
file sizes and retention
|
||||
MCP config allowlist
|
||||
SkillRepository list/read
|
||||
SkillRepository preview/install/read/export/source refs
|
||||
Skill archive roundtrip hash
|
||||
Skill repository rejects symlink escape
|
||||
```
|
||||
|
||||
文件测试应使用临时目录,并覆盖 macOS/Linux/Windows path 差异。
|
||||
|
|
@ -109,10 +111,11 @@ recent workspace write/patch tool result hydration
|
|||
|
||||
```text
|
||||
window.__TAURITAVERN__.api.agent exists after ready
|
||||
window.__TAURITAVERN__.api.skill exists after ready
|
||||
window.__TAURITAVERN__.api.mcp exists after ready when MCP Host ABI lands
|
||||
subscribe returns idempotent unsubscribe
|
||||
Agent API uses safeInvoke, not raw command dependency in public caller
|
||||
types.d.ts includes agent types; mcp types land with MCP Host ABI
|
||||
types.d.ts includes agent and skill types; mcp types land with MCP Host ABI
|
||||
```
|
||||
|
||||
Legacy 回归:
|
||||
|
|
|
|||
|
|
@ -158,13 +158,15 @@ Policy violation 必须写 journal 并 fail-fast,除非这是模型可恢复
|
|||
|
||||
### 7.0 当前实现
|
||||
|
||||
截至 2026-05-02,当前 registry 开放 chat / world info / workspace 三类八个内建工具。Registry 只产 canonical `AgentToolSpec`,provider-facing schema/alias 由 gateway 边界转换:
|
||||
截至 2026-05-02,当前 registry 开放 chat / world info / skill / workspace 四类十个内建工具。Registry 只产 canonical `AgentToolSpec`,provider-facing schema/alias 由 gateway 边界转换:
|
||||
|
||||
| Canonical name | Model-facing alias | Side effect | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chat.search` | `chat_search` | 只读搜索当前聊天,返回 message index、snippet 与 ref | 已落地 |
|
||||
| `chat.read_messages` | `chat_read_messages` | 只读按 message index 读取当前聊天消息,可读取字符范围 | 已落地 |
|
||||
| `worldinfo.read_activated` | `worldinfo_read_activated` | 只读读取本轮 run 捕获的最终激活世界书条目 | 已落地 |
|
||||
| `skill.list` | `skill_list` | 只读列出已安装 Skill 索引摘要 | 已落地 |
|
||||
| `skill.read` | `skill_read` | 只读读取已安装 Skill 内的 UTF-8 文本文件 | 已落地 |
|
||||
| `workspace.list_files` | `workspace_list_files` | 只读列出模型可见 workspace 文件 | 已落地 |
|
||||
| `workspace.read_file` | `workspace_read_file` | 只读读取 UTF-8 文本,完整读取记录 read-state | 已落地 |
|
||||
| `workspace.write_file` | `workspace_write_file` | 写 run workspace 文件,成功后 checkpoint | 已落地 |
|
||||
|
|
@ -187,17 +189,14 @@ persist/
|
|||
|
||||
`workspace.apply_patch` 使用 Claude Code 风格的 `old_string` / `new_string` 单文件精确替换。未完整读取、版本变化、匹配 0 次或多次会作为 recoverable tool error 返回模型。模型传入的非法 path、空 path、不可见/不可写 path 也作为可恢复工具错误回填;repository 内部 escape/symlink/IO、journal、checkpoint、序列化、取消和模型响应结构错误仍 fail-fast。
|
||||
|
||||
当前没有 `skill.list`、`skill.read`、MCP、shell、extension bridge 或审批工具。
|
||||
当前没有 MCP、shell、extension bridge、profile routing 或审批工具。
|
||||
|
||||
### 7.1 后续内置工具候选
|
||||
|
||||
后续优先补齐 Skill-like 资源与策略工具:
|
||||
后续优先补齐策略与 checkpoint 工具:
|
||||
|
||||
```text
|
||||
workspace.create_checkpoint
|
||||
|
||||
skill.list
|
||||
skill.read
|
||||
```
|
||||
|
||||
### 7.2 Workspace Tools
|
||||
|
|
@ -266,13 +265,17 @@ skill.read
|
|||
`skill.list`
|
||||
|
||||
- Read-only。
|
||||
- 返回当前 profile 可见 skill 的摘要。
|
||||
- 返回已安装 Skill 的索引摘要。
|
||||
- 当前尚未接入 profile visible/deny policy,因此结果是全局安装索引;profile policy 落地后必须在 tool dispatcher 层收窄可见范围。
|
||||
- 不读取 `SKILL.md` 全文。
|
||||
|
||||
`skill.read`
|
||||
|
||||
- Read-only。
|
||||
- 支持 section/budget。
|
||||
- 结果可进入 ContextFrame。
|
||||
- 输入为 `name`、可选 `path`、可选 `max_chars`。
|
||||
- `path` 默认 `SKILL.md`,必须是 Skill 内相对路径。
|
||||
- 只能读取 UTF-8 文本;二进制、缺失文件、非法路径、symlink escape、不可见 Skill 或超预算读取都是 recoverable tool error,除非 repository 内部 IO / index 损坏等宿主级问题需要 fail-fast。
|
||||
- 结果进入 journal / tool result / 下一轮 model request。Skill 原始文件保持 read-only;模型需要摘录或改写时应写入 `scratch/`、`summaries/` 或 `output/`。
|
||||
|
||||
### 7.5 WorldInfo Tools
|
||||
|
||||
|
|
@ -427,11 +430,11 @@ result returns to backend journal
|
|||
- Rust-owned builtin registry。
|
||||
- canonical tool specs 与 provider-safe model alias。
|
||||
- Agent runtime 使用 canonical `ToolCall` / `ToolResult` part,provider 边界负责格式转换。
|
||||
- chat search/read_messages、worldinfo read_activated、workspace list/read/write/apply_patch/finish。
|
||||
- chat search/read_messages、worldinfo read_activated、skill list/read、workspace list/read/write/apply_patch/finish。
|
||||
- tool arguments / tool results resource refs。
|
||||
- recoverable tool error 回填模型。
|
||||
- 前 5 轮 workspace write/patch 成功结果会 hydrate 完整文件内容到下一轮模型上下文。
|
||||
- workspace mutation checkpoint。
|
||||
- journal events。
|
||||
|
||||
下一步新增 `skill.list`、`skill.read`、MCP 或 extension bridge 工具时,应复用这一套 registry/dispatcher/result/error 语义,而不是新建旁路。
|
||||
下一步新增 MCP 或 extension bridge 工具时,应复用这一套 registry/dispatcher/result/error 语义,而不是新建旁路。Skill policy 扩展也必须留在现有 `skill.list` / `skill.read` dispatcher 与 profile resolver 之间,不能新建第二套 Skill 读取入口。
|
||||
|
|
|
|||
|
|
@ -7,16 +7,18 @@
|
|||
1. `docs/CurrentState/AgentFramework.md`:当前真实实现状态与手动 smoke。
|
||||
2. `docs/AgentArchitecture.md`:系统边界、分层、数据流。
|
||||
3. `docs/AgentContract.md`:不可破坏的不变量与 fail-fast 约束。
|
||||
4. `docs/AgentImplementPlan.md`:分阶段实施计划与验收标准。
|
||||
4. `docs/AgentImplementPlan.md`:当前实施基线、后续顺序与验收命令。
|
||||
5. `docs/Agent/Workspace.md`:Workspace、Artifact、Checkpoint 的存储语义。
|
||||
6. `docs/Agent/RunEventJournal.md`:Run Event、状态机、恢复/取消语义。
|
||||
7. `docs/Agent/ProfilesAndPreset.md`:Preset、Agent Profile、Plan Policy。
|
||||
8. `docs/Agent/ToolSystem.md`:Tool Registry、Tool Result、权限与审批。
|
||||
9. `docs/Agent/LlmGateway.md`:provider-agnostic LLM gateway 与现有 ChatCompletionService 的复用边界。
|
||||
10. `docs/Agent/McpSkill.md`:MCP 与 Skill 的边界。
|
||||
11. `docs/API/Agent.md`:前端 Host ABI 与 Tauri command 草案。
|
||||
12. `docs/API/MCP.md`:非 Agent 模式下的 MCP Host ABI 草案。
|
||||
13. `docs/Agent/TestingStrategy.md`:测试矩阵与回归守护。
|
||||
10. `docs/Agent/Skill.md`:当前 Skill 格式、存储、导入导出、Agent tool 与安全边界。
|
||||
11. `docs/Agent/McpSkill.md`:MCP 与 Skill 的边界。
|
||||
12. `docs/API/Agent.md`:前端 Agent Host ABI。
|
||||
13. `docs/API/Skill.md`:前端 Skill 管理 Host ABI。
|
||||
14. `docs/API/MCP.md`:非 Agent 模式下的 MCP Host ABI 草案。
|
||||
15. `docs/Agent/TestingStrategy.md`:测试矩阵与回归守护。
|
||||
|
||||
## 1. 核心定义
|
||||
|
||||
|
|
@ -64,7 +66,7 @@ Agent 系统必须同时满足五个目标:
|
|||
|
||||
## 4. 非目标
|
||||
|
||||
第一阶段不追求这些事项:
|
||||
当前不追求这些事项:
|
||||
|
||||
- 不重写完整 SillyTavern PromptManager。
|
||||
- 不把所有历史消息复制到 workspace。
|
||||
|
|
@ -145,7 +147,7 @@ LLM Gateway / provider adapter
|
|||
- Public Host ABI 入口为 `api.agent.startRunFromLegacyGenerate()` 与 `api.agent.startRunWithPromptSnapshot()`,没有 `startRun()` alias。
|
||||
- `startRunFromLegacyGenerate()` 是当前推荐的兼容桥;它捕获 Legacy prompt 语义与本轮最终 `worldInfoActivation`,同时禁用 Legacy ToolManager tools。
|
||||
- `startRunWithPromptSnapshot()` 是低层测试/集成入口;调用方必须提供不含 external tools/tool turns 的 chat completion payload。
|
||||
- 后端当前开放 `chat.search`、`chat.read_messages`、`worldinfo.read_activated`、`workspace.list_files`、`workspace.read_file`、`workspace.write_file`、`workspace.apply_patch`、`workspace.finish` 八个内建工具,对模型暴露为 provider-safe alias。
|
||||
- 后端当前开放 `chat.search`、`chat.read_messages`、`worldinfo.read_activated`、`skill.list`、`skill.read`、`workspace.list_files`、`workspace.read_file`、`workspace.write_file`、`workspace.apply_patch`、`workspace.finish` 十个内建工具,对模型暴露为 provider-safe alias。
|
||||
- Agent runtime 当前使用 `AgentModelRequest` / `AgentModelResponse` / `AgentModelContentPart` 作为内部模型语义,不再直接读写 OpenAI-compatible raw JSON。
|
||||
- `AgentModelGateway` 仍复用 `ChatCompletionService::generate_exchange_with_cancel()`,在 canonical IR 与现有 provider payload pipeline 之间转换。
|
||||
- Claude / Gemini / OpenAI Responses / Gemini Interactions 的 native metadata 以 opaque `Native` part 保存和回放;tool call id 缺失会 fail-fast。
|
||||
|
|
@ -156,7 +158,7 @@ LLM Gateway / provider adapter
|
|||
- 当前模型可见 / 可写 workspace 根由 run manifest roots 驱动,默认包含 `output/`、`scratch/`、`plan/`、`summaries/`、`persist/`;`persist/` 是 chat workspace 级持久 root 的 run projection,只有 `finalizeCommit()` 成功后才 promote 回稳定 chat workspace;`input/`、`tool-args/`、`tool-results/`、`model-responses/`、`checkpoints/` 与 `events.jsonl` 不作为模型工具资源暴露。
|
||||
- 工具循环最多 80 轮,必须以 `workspace.finish` 结束;模型直接输出文本会 fail-fast。
|
||||
- 模型可修正的工具错误以 `is_error = true` tool result 回填下一轮;宿主级 IO、journal、checkpoint、序列化、取消和模型响应结构错误仍 fail-fast。
|
||||
- `skill.list`、`skill.read`、`readDiff`、`rollback`、`resume-run`、tool approval、profile routing、MCP、timeline UI、streaming Agent loop、主发送按钮 Agent toggle 仍未实现。
|
||||
- Skill profile policy、readDiff、rollback、resume-run、tool approval、profile routing、MCP、timeline UI、streaming Agent loop、主发送按钮 Agent toggle 仍未实现。
|
||||
|
||||
### 5.2 Run 与 Workspace 身份
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# TauriTavern Agent Implementation Plan
|
||||
|
||||
本文档记录当前可继续开发的实施基线与后续顺序。旧阶段性施工计划已经收敛为当前架构、测试与契约;后续开发不应再从旧阶段文档倒推当前行为。
|
||||
本文档记录当前可继续开发的实施基线与后续顺序。历史施工计划已经收敛为当前架构、测试与契约;后续开发不应再从旧计划倒推当前行为。
|
||||
|
||||
当前事实以 `docs/CurrentState/AgentFramework.md` 为准,架构边界以 `docs/AgentArchitecture.md` 与 `docs/AgentContract.md` 为准。
|
||||
|
||||
|
|
@ -16,8 +16,9 @@
|
|||
- Tool loop 由 Rust runtime 独占推进,不递归调用前端 `Generate()`。
|
||||
- Agent runtime 已使用 canonical model IR,不再把 OpenAI-compatible raw JSON 当作运行时事实。
|
||||
- `provider_state` 已用于 run-scoped continuation。OpenAI Responses 通过它驱动 persistent WebSocket、incremental input 与 `previous_response_id`。
|
||||
- Agent Skill repository/service、导入导出、embedded skill 导入确认、`api.skill`、`skill.list` / `skill.read` 已落地。
|
||||
|
||||
旧阶段只保留为这些不变量:
|
||||
历史计划只保留为这些不变量:
|
||||
|
||||
- Agent Mode off 时,上游 SillyTavern `Generate()`、ToolManager、事件顺序与 chat 保存语义不变。
|
||||
- `stableChatId` 是长期聊天身份;`workspaceId` 由 `kind + stableChatId` 派生;`runId` 表示单次执行。
|
||||
|
|
@ -128,6 +129,8 @@ Tool registry 只产 canonical `AgentToolSpec`,不再暴露 OpenAI-shaped `ope
|
|||
| `chat.search` | `chat_search` | read-only |
|
||||
| `chat.read_messages` | `chat_read_messages` | read-only |
|
||||
| `worldinfo.read_activated` | `worldinfo_read_activated` | read-only |
|
||||
| `skill.list` | `skill_list` | read-only |
|
||||
| `skill.read` | `skill_read` | read-only |
|
||||
| `workspace.list_files` | `workspace_list_files` | read-only |
|
||||
| `workspace.read_file` | `workspace_read_file` | read-only |
|
||||
| `workspace.write_file` | `workspace_write_file` | mutating |
|
||||
|
|
@ -136,7 +139,7 @@ Tool registry 只产 canonical `AgentToolSpec`,不再暴露 OpenAI-shaped `ope
|
|||
|
||||
当前尚未落地:
|
||||
|
||||
- `skill.list` / `skill.read`
|
||||
- Skill profile visible / deny / read budget policy
|
||||
- MCP 工具
|
||||
- shell 工具
|
||||
- 外部 extension tools
|
||||
|
|
@ -174,7 +177,7 @@ initialize_run 写 manifest / prompt snapshot / workspace root
|
|||
↓
|
||||
prepare_agent_tool_request 生成 AgentModelRequest
|
||||
↓
|
||||
model -> read-only context tools / workspace tools -> model -> ... -> workspace.finish
|
||||
model -> read-only context tools / skill tools / workspace tools -> model -> ... -> workspace.finish
|
||||
↓
|
||||
工具调用参数与结果写入 workspace refs
|
||||
↓
|
||||
|
|
@ -224,15 +227,17 @@ prepareCommit / saveReply / finalizeCommit
|
|||
- profile 显式声明 ContextFrame 资源预算。
|
||||
- Plan node 若锁定 profile,runtime 必须拒绝模型自行切换。
|
||||
|
||||
### 8.3 剩余只读上下文资源
|
||||
### 8.3 Skill Policy 与创作者资源合流
|
||||
|
||||
目标:在不膨胀 prompt snapshot 的前提下,把创作者资源变成按需读取的工具/virtual resource。
|
||||
目标:在不膨胀 prompt snapshot 的前提下,把已安装 Skill 与创作者资源变成可审计、可预算的按需读取资源。
|
||||
|
||||
优先级:
|
||||
内容:
|
||||
|
||||
- `skill.list` / `skill.read`
|
||||
- preset / character author resources 的统一 Skill-like 入口
|
||||
- 可审计的 context budget 与 resource refs
|
||||
- profile / preset / character 声明 visible skills、deny skills 与 read budget。
|
||||
- `skill.list` 根据 policy 只返回当前 run 可见 Skill。
|
||||
- `skill.read` 对不可见 Skill、超预算读取和非法资源返回 recoverable tool error。
|
||||
- preset / character author resources 复用 Skill-like 索引与 source ref 语义,不另建平行资源系统。
|
||||
- journal 记录 Skill read 的 tool args、resource refs 与失败原因。
|
||||
|
||||
### 8.4 Timeline UI 与人工控制
|
||||
|
||||
|
|
@ -276,6 +281,8 @@ Agent 相关 Rust 变更至少运行:
|
|||
cargo fmt --manifest-path src-tauri/Cargo.toml
|
||||
cargo check --manifest-path src-tauri/Cargo.toml
|
||||
cargo test --manifest-path src-tauri/Cargo.toml agent --lib
|
||||
cargo test --manifest-path src-tauri/Cargo.toml skill --lib
|
||||
cargo test --manifest-path src-tauri/Cargo.toml agent_tools --lib
|
||||
git diff --check
|
||||
```
|
||||
|
||||
|
|
@ -304,6 +311,7 @@ pnpm run check:contracts
|
|||
- LLM gateway:`docs/Agent/LlmGateway.md`
|
||||
- 工具语义:`docs/Agent/ToolSystem.md`
|
||||
- workspace 语义:`docs/Agent/Workspace.md`
|
||||
- Skill 语义:`docs/Agent/Skill.md`
|
||||
- 事件语义:`docs/Agent/RunEventJournal.md`
|
||||
- 测试矩阵:`docs/Agent/TestingStrategy.md`
|
||||
- Host ABI:`docs/API/Agent.md`、`docs/FrontendHostContract.md`、`src/types.d.ts`
|
||||
- Host ABI:`docs/API/Agent.md`、`docs/API/Skill.md`、`docs/FrontendHostContract.md`、`src/types.d.ts`
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
本文档是 Agent 框架的当前事实入口。后续开发先读本文,再读 `docs/AgentArchitecture.md`、`docs/AgentContract.md`、`docs/AgentImplementPlan.md` 与 `docs/Agent/` 下的专题文档。
|
||||
|
||||
旧阶段性施工说明已经吸收为当前基线,不再作为开发入口;需要历史背景时只看 git history。
|
||||
历史施工说明已经吸收为当前基线,不再作为开发入口;需要历史背景时只看 git history。
|
||||
|
||||
## 当前基线
|
||||
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
- Agent runtime 已不再把 OpenAI-compatible raw JSON 当作内部事实;运行时使用 canonical `AgentModelRequest` / `AgentModelResponse` / `AgentModelMessage` / `AgentModelContentPart`。
|
||||
- `AgentModelGateway` 在 Agent canonical IR 与现有 `ChatCompletionGenerateRequestDto` 之间转换;provider-native metadata 作为 opaque `Native` part 保留。
|
||||
- `provider_state` 已是 run-scoped continuation contract;OpenAI Responses 使用它驱动 persistent WebSocket、incremental input 与 `previous_response_id`。
|
||||
- Agent Skill 管理、导入导出、embedded skill 提示导入、`skill.list` / `skill.read` 已落地。
|
||||
- 当前工具循环是非 streaming;provider stream 仍不是 Agent timeline event。
|
||||
- Legacy Generate 尚未默认切到 Agent;Agent Mode off 时上游 SillyTavern 生成、事件和保存语义不变。
|
||||
|
||||
|
|
@ -34,6 +35,19 @@ api.agent.commit(input)
|
|||
api.agent.finalizeCommit(input)
|
||||
```
|
||||
|
||||
Skill 管理 API 已落地:
|
||||
|
||||
```ts
|
||||
api.skill.list()
|
||||
api.skill.previewImport(input)
|
||||
api.skill.installImport(request)
|
||||
api.skill.readFile(input)
|
||||
api.skill.export(input)
|
||||
api.skill.exportSkill(input)
|
||||
```
|
||||
|
||||
`api.skill` 是用户/UI/扩展侧的 Skill 管理入口;Agent run 内只通过 `skill.list` / `skill.read` 工具消费已安装 Skill。
|
||||
|
||||
明确不存在公共 `api.agent.startRun()` alias。启动入口必须表达 prompt 来源:
|
||||
|
||||
- `startRunFromLegacyGenerate()`:调用 Legacy `Generate(..., dryRun = true)`,捕获 `GENERATE_AFTER_DATA` 中的 `generate_data` 与本轮最终 `worldInfoActivation`。
|
||||
|
|
@ -56,13 +70,15 @@ Tool registry 只产 canonical `AgentToolSpec`。Provider-facing alias 由 gatew
|
|||
| `chat.search` | `chat_search` | read-only | 搜索当前 run 绑定的聊天。只有 `query` 必填;可选 `limit`、`role`、`start_message`、`end_message`、`scan_limit`。 |
|
||||
| `chat.read_messages` | `chat_read_messages` | read-only | 按 0-based message index 读取当前聊天消息;每项可选 `start_char`、`max_chars`。JSONL header 不计入 index。 |
|
||||
| `worldinfo.read_activated` | `worldinfo_read_activated` | read-only | 读取本次 Agent run 捕获的最终激活世界书条目,不读取全局 last activation。 |
|
||||
| `skill.list` | `skill_list` | read-only | 列出当前已安装 Skill 的索引摘要。profile visible/deny policy 尚未接入,因此目前是全局安装索引。 |
|
||||
| `skill.read` | `skill_read` | read-only | 读取已安装 Skill 内的 UTF-8 文本文件;默认 `SKILL.md`,支持 `path` 与 `max_chars`。 |
|
||||
| `workspace.list_files` | `workspace_list_files` | read-only | 列出模型可见 workspace 文件。`path` 省略、空字符串、`.`、`./` 表示 workspace root。 |
|
||||
| `workspace.read_file` | `workspace_read_file` | read-only | 读取 UTF-8 文本文件并返回行号;完整读取会记录 read-state。 |
|
||||
| `workspace.write_file` | `workspace_write_file` | mutating | 写完整 UTF-8 文件;成功后记录 read-state 并创建 checkpoint。 |
|
||||
| `workspace.apply_patch` | `workspace_apply_patch` | mutating | 单文件 `old_string` / `new_string` 精确替换;要求已完整读取或由本 run 创建/修改。 |
|
||||
| `workspace.finish` | `workspace_finish` | control | 结束工具循环;默认 final artifact 是 `output/main.md`。 |
|
||||
|
||||
当前没有 `skill.list`、`skill.read`、MCP 工具、shell 工具、外部 extension tools、tool approval 或 profile routing。
|
||||
当前没有 MCP 工具、shell 工具、外部 extension tools、tool approval 或 profile routing。
|
||||
|
||||
## Model Gateway 当前事实
|
||||
|
||||
|
|
@ -271,6 +287,14 @@ _tauritavern/agent-workspaces/
|
|||
<checkpoint-id>/
|
||||
checkpoint.json
|
||||
<snapshotted files...>
|
||||
_tauritavern/skills/
|
||||
installed/
|
||||
<skill-name>/
|
||||
SKILL.md
|
||||
<skill files...>
|
||||
index/
|
||||
skills.json
|
||||
.staging/
|
||||
```
|
||||
|
||||
Workspace path 必须是相对路径。绝对路径、Windows drive prefix、NUL、`..` 会被拒绝。工具参数层可修正的问题返回 recoverable tool error;repository 内部 IO、journal、checkpoint、chat JSONL 损坏、序列化、取消和模型响应结构错误是 fatal runtime error。
|
||||
|
|
@ -298,13 +322,11 @@ const stop = agent.subscribe(run.runId, event => console.log(event));
|
|||
|
||||
最近一次 Rust 侧验证基线:
|
||||
|
||||
- `cargo check`
|
||||
- `cargo test agent_model_gateway`
|
||||
- `cargo test agent_loop`
|
||||
- `cargo test openai_responses_payload`
|
||||
- `cargo test claude_native_content_blocks_are_replayed`
|
||||
- `cargo test normalize_`
|
||||
- `cargo test`:470 passed
|
||||
- `cargo fmt --manifest-path src-tauri/Cargo.toml`
|
||||
- `cargo check --manifest-path src-tauri/Cargo.toml`
|
||||
- `cargo test --manifest-path src-tauri/Cargo.toml skill --lib`:9 passed
|
||||
- `cargo test --manifest-path src-tauri/Cargo.toml agent_tools --lib`:5 passed
|
||||
- `cargo test --manifest-path src-tauri/Cargo.toml agent --lib`:31 passed
|
||||
- `git diff --check`
|
||||
|
||||
前端 ABI 本次未修改,未重新运行前端检查。
|
||||
|
|
@ -317,7 +339,7 @@ const stop = agent.subscribe(run.runId, event => console.log(event));
|
|||
- 将 `PromptSnapshot` 过渡输入逐步替换为 `GenerationIntent + ContextFrame`。
|
||||
- 把 `AgentModelGateway` 进一步拆成明确的 provider adapter 模块,减少单文件体积。
|
||||
- 完成 profile policy:allowed tools、tool budget、tool call mode、provider switch policy。
|
||||
- 实现 `skill.list` / `skill.read`。
|
||||
- 将 Skill 可见性、deny policy 与 read budget 接入 profile / preset / character resolver。
|
||||
- 实现 readDiff、rollback、listRuns、resume-run、autoCommit/streaming 的明确策略。
|
||||
|
||||
## 每次 Agent 相关变更必须更新
|
||||
|
|
@ -329,9 +351,10 @@ const stop = agent.subscribe(run.runId, event => console.log(event));
|
|||
- `docs/AgentImplementPlan.md`
|
||||
- `docs/Agent/LlmGateway.md`
|
||||
- `docs/Agent/ToolSystem.md`
|
||||
- `docs/Agent/Skill.md`
|
||||
- `docs/Agent/RunEventJournal.md`
|
||||
- `docs/Agent/TestingStrategy.md`
|
||||
- 涉及 Host ABI 时同步 `docs/API/Agent.md`、`docs/FrontendHostContract.md`、`src/types.d.ts`
|
||||
- 涉及 Host ABI 时同步 `docs/API/Agent.md`、`docs/API/Skill.md`、`docs/FrontendHostContract.md`、`src/types.d.ts`
|
||||
|
||||
## 守护契约
|
||||
|
||||
|
|
|
|||
|
|
@ -151,19 +151,26 @@
|
|||
- `openEntry()` 必须复用上游 World Info 模块自身的导航能力;宿主 ABI 层不得直接依赖 `#WorldInfo`、`#world_editor_select`、`[uid=\"...\"]` 等 DOM 细节。
|
||||
|
||||
- `api.agent`:TauriTavern Agent Run API。用于启动 Agent Run、订阅 run event、取消、审批工具、读取 workspace 文件/diff、rollback/commit。
|
||||
- 详细草案见:`docs/API/Agent.md`。
|
||||
- 详细参考见:`docs/API/Agent.md`。
|
||||
- 当前已落地 Host ABI:`startRunFromLegacyGenerate()`、`startRunWithPromptSnapshot()`、`cancel()`、`readEvents()`、`readWorkspaceFile()`、`subscribe()`、`prepareCommit()`、`finalizeCommit()`、`commit()`。
|
||||
- `startRunFromLegacyGenerate()` 是当前兼容入口:使用 Legacy dryRun 生成 `promptSnapshot`,再进入 Rust-owned Agent loop。
|
||||
- `startRunWithPromptSnapshot()` 必须在调用 backend 前解析 `stableChatId`;`workspaceId` 由 `kind + stableChatId` 派生,`runId` 仍表示单次执行。
|
||||
- 不存在公共 `startRun()` alias;启动入口必须通过名称表达来源和职责。
|
||||
- Legacy `Generate(..., dryRun = true)` 不返回 payload;Agent adapter 必须通过 `GENERATE_AFTER_DATA` 事件捕获 `generate_data`。
|
||||
- 当前模型可见工具为 `workspace_list_files`、`workspace_read_file`、`workspace_write_file`、`workspace_apply_patch`、`workspace_finish`;工具注册由 Rust runtime 独占,前端 Legacy ToolManager tools 必须禁用。
|
||||
- 当前模型可见工具为 `chat_search`、`chat_read_messages`、`worldinfo_read_activated`、`skill_list`、`skill_read`、`workspace_list_files`、`workspace_read_file`、`workspace_write_file`、`workspace_apply_patch`、`workspace_finish`;工具注册由 Rust runtime 独占,前端 Legacy ToolManager tools 必须禁用。
|
||||
- 当前显式拒绝 `stream: true`、`autoCommit: true`、external tools、external tool choice 和已有 tool turns。
|
||||
- 可恢复工具错误会写入 Agent journal 并回填下一轮模型;宿主级错误仍让 run failed。
|
||||
- Agent event 属于 Agent Run journal/timeline 投影,不得伪装成上游 SillyTavern `GENERATION_*` / `TOOL_CALLS_*` 事件。
|
||||
- `subscribe()` 当前是 polling wrapper,必须返回幂等 `unsubscribe`;底层 Tauri 事件名与 Rust command 名属于 Internal,不是第三方 Public Contract。
|
||||
- Agent Mode off 时,Legacy `Generate()`、`ToolManager`、`api.chat` 行为必须不变。
|
||||
|
||||
- `api.skill`:TauriTavern Agent Skill 管理 API。用于列出、预览导入、安装、读取和导出本地 Skill。
|
||||
- 详细参考见:`docs/API/Skill.md` 与 `docs/Agent/Skill.md`。
|
||||
- 当前已落地 Host ABI:`list()`、`previewImport()`、`installImport()`、`readFile()`、`export()`、`exportSkill()`。
|
||||
- `api.skill` 是 UI / 扩展侧管理入口,不是 Agent run 内的工具入口;模型只能通过 Rust runtime 注册的 `skill.list` / `skill.read` 消费已安装 Skill。
|
||||
- Preset / Character embedded skill 导入必须经过用户确认;同名不同 hash 必须显式 skip 或 replace,不自动改名。
|
||||
- Skill import/export 不触发上游 SillyTavern `GENERATION_*`、`TOOL_CALLS_*` 或 regex 事件。
|
||||
|
||||
- `api.mcp`(规划中):MCP Server/Tool/Resource/Prompt 的独立平台 API。Agent Mode 可以消费 MCP,但 MCP 不依附 Agent Mode。
|
||||
- 详细草案见:`docs/API/MCP.md`。
|
||||
- MCP stdio command/config 不得由 Agent/Preset/角色卡/世界书直接写入;危险工具调用必须经过 capability policy 与审批。
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use crate::application::services::preset_service::PresetService;
|
|||
use crate::application::services::quick_reply_service::QuickReplyService;
|
||||
use crate::application::services::secret_service::SecretService;
|
||||
use crate::application::services::settings_service::SettingsService;
|
||||
use crate::application::services::skill_service::SkillService;
|
||||
use crate::application::services::stable_diffusion_service::StableDiffusionService;
|
||||
use crate::application::services::theme_service::ThemeService;
|
||||
use crate::application::services::tokenization_service::TokenizationService;
|
||||
|
|
@ -42,6 +43,7 @@ pub struct AppState {
|
|||
pub settings_service: Arc<SettingsService>,
|
||||
pub user_directory_service: Arc<UserDirectoryService>,
|
||||
pub secret_service: Arc<SecretService>,
|
||||
pub skill_service: Arc<SkillService>,
|
||||
pub content_service: Arc<ContentService>,
|
||||
pub extension_service: Arc<ExtensionService>,
|
||||
pub extension_store_service: Arc<ExtensionStoreService>,
|
||||
|
|
@ -89,6 +91,7 @@ impl AppState {
|
|||
settings_service: services.settings_service,
|
||||
user_directory_service: services.user_directory_service,
|
||||
secret_service: services.secret_service,
|
||||
skill_service: services.skill_service,
|
||||
content_service: services.content_service,
|
||||
extension_service: services.extension_service,
|
||||
extension_store_service: services.extension_store_service,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use crate::application::services::preset_service::PresetService;
|
|||
use crate::application::services::quick_reply_service::QuickReplyService;
|
||||
use crate::application::services::secret_service::SecretService;
|
||||
use crate::application::services::settings_service::SettingsService;
|
||||
use crate::application::services::skill_service::SkillService;
|
||||
use crate::application::services::stable_diffusion_service::StableDiffusionService;
|
||||
use crate::application::services::theme_service::ThemeService;
|
||||
use crate::application::services::tokenization_service::TokenizationService;
|
||||
|
|
@ -49,6 +50,7 @@ use crate::domain::repositories::prompt_cache_repository::PromptCacheRepository;
|
|||
use crate::domain::repositories::quick_reply_repository::QuickReplyRepository;
|
||||
use crate::domain::repositories::secret_repository::SecretRepository;
|
||||
use crate::domain::repositories::settings_repository::SettingsRepository;
|
||||
use crate::domain::repositories::skill_repository::SkillRepository;
|
||||
use crate::domain::repositories::stable_diffusion_repository::StableDiffusionRepository;
|
||||
use crate::domain::repositories::theme_repository::ThemeRepository;
|
||||
use crate::domain::repositories::tokenizer_repository::TokenizerRepository;
|
||||
|
|
@ -84,6 +86,7 @@ use crate::infrastructure::repositories::file_prompt_cache_repository::FilePromp
|
|||
use crate::infrastructure::repositories::file_quick_reply_repository::FileQuickReplyRepository;
|
||||
use crate::infrastructure::repositories::file_secret_repository::FileSecretRepository;
|
||||
use crate::infrastructure::repositories::file_settings_repository::FileSettingsRepository;
|
||||
use crate::infrastructure::repositories::file_skill_repository::FileSkillRepository;
|
||||
use crate::infrastructure::repositories::file_theme_repository::FileThemeRepository;
|
||||
use crate::infrastructure::repositories::file_user_directory_repository::FileUserDirectoryRepository;
|
||||
use crate::infrastructure::repositories::file_user_repository::FileUserRepository;
|
||||
|
|
@ -97,6 +100,7 @@ pub(super) struct AppServices {
|
|||
pub settings_service: Arc<SettingsService>,
|
||||
pub user_directory_service: Arc<UserDirectoryService>,
|
||||
pub secret_service: Arc<SecretService>,
|
||||
pub skill_service: Arc<SkillService>,
|
||||
pub content_service: Arc<ContentService>,
|
||||
pub extension_service: Arc<ExtensionService>,
|
||||
pub extension_store_service: Arc<ExtensionStoreService>,
|
||||
|
|
@ -128,6 +132,7 @@ struct AppRepositories {
|
|||
prompt_cache_repository: Arc<dyn PromptCacheRepository>,
|
||||
user_directory_repository: Arc<dyn UserDirectoryRepository>,
|
||||
secret_repository: Arc<dyn SecretRepository>,
|
||||
skill_repository: Arc<dyn SkillRepository>,
|
||||
content_repository: Arc<dyn ContentRepository>,
|
||||
extension_repository: Arc<dyn ExtensionRepository>,
|
||||
extension_store_repository: Arc<dyn ExtensionStoreRepository>,
|
||||
|
|
@ -201,6 +206,7 @@ pub(super) async fn build_services(
|
|||
let quick_reply_service = Arc::new(QuickReplyService::new(
|
||||
repositories.quick_reply_repository.clone(),
|
||||
));
|
||||
let skill_service = Arc::new(SkillService::new(repositories.skill_repository.clone()));
|
||||
let chat_completion_service = Arc::new(ChatCompletionService::new(
|
||||
repositories.chat_completion_repository,
|
||||
repositories.secret_repository.clone(),
|
||||
|
|
@ -214,6 +220,7 @@ pub(super) async fn build_services(
|
|||
repositories.checkpoint_repository.clone(),
|
||||
repositories.chat_repository.clone(),
|
||||
repositories.group_chat_repository.clone(),
|
||||
skill_service.clone(),
|
||||
Arc::new(ChatCompletionAgentModelGateway::new(
|
||||
chat_completion_service.clone(),
|
||||
)),
|
||||
|
|
@ -280,6 +287,7 @@ pub(super) async fn build_services(
|
|||
settings_service,
|
||||
user_directory_service,
|
||||
secret_service,
|
||||
skill_service,
|
||||
content_service,
|
||||
extension_service,
|
||||
extension_store_service,
|
||||
|
|
@ -345,6 +353,9 @@ fn build_repositories(
|
|||
let secret_repository: Arc<dyn SecretRepository> = Arc::new(FileSecretRepository::new(
|
||||
default_user_dir.join("secrets.json"),
|
||||
));
|
||||
let skill_repository: Arc<dyn SkillRepository> = Arc::new(FileSkillRepository::new(
|
||||
data_root.join("_tauritavern").join("skills"),
|
||||
));
|
||||
|
||||
let content_repository: Arc<dyn ContentRepository> = Arc::new(FileContentRepository::new(
|
||||
app_handle.clone(),
|
||||
|
|
@ -435,6 +446,7 @@ fn build_repositories(
|
|||
prompt_cache_repository,
|
||||
user_directory_repository,
|
||||
secret_repository,
|
||||
skill_repository,
|
||||
content_repository,
|
||||
extension_repository,
|
||||
extension_store_repository,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use tokio::sync::{RwLock, watch};
|
|||
|
||||
use crate::application::services::agent_model_gateway::AgentModelGateway;
|
||||
use crate::application::services::agent_tools::{AgentToolDispatcher, BuiltinAgentToolRegistry};
|
||||
use crate::application::services::skill_service::SkillService;
|
||||
use crate::domain::repositories::agent_run_repository::AgentRunRepository;
|
||||
use crate::domain::repositories::chat_repository::ChatRepository;
|
||||
use crate::domain::repositories::checkpoint_repository::CheckpointRepository;
|
||||
|
|
@ -46,6 +47,7 @@ impl AgentRuntimeService {
|
|||
checkpoint_repository: Arc<dyn CheckpointRepository>,
|
||||
chat_repository: Arc<dyn ChatRepository>,
|
||||
group_chat_repository: Arc<dyn GroupChatRepository>,
|
||||
skill_service: Arc<SkillService>,
|
||||
model_gateway: Arc<dyn AgentModelGateway>,
|
||||
) -> Self {
|
||||
let tool_registry = BuiltinAgentToolRegistry::phase2c();
|
||||
|
|
@ -54,6 +56,7 @@ impl AgentRuntimeService {
|
|||
chat_repository,
|
||||
group_chat_repository,
|
||||
workspace_repository.clone(),
|
||||
skill_service,
|
||||
);
|
||||
Self {
|
||||
run_repository,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ pub(super) fn prepare_agent_tool_request(
|
|||
"Use chat_search to find relevant prior messages when you need more context. Only query is required.",
|
||||
"Use chat_read_messages with message indexes from chat_search, or exact indexes you already know. For long messages, read smaller ranges with start_char and max_chars.",
|
||||
"Use worldinfo_read_activated when active lore for this run matters.",
|
||||
"Use skill_list to discover installed Agent Skills when reusable writing, editing, planning, style, or character guidance may help.",
|
||||
"Use skill_read to read SKILL.md first, then read referenced Skill files only when needed.",
|
||||
"Use workspace_list_files to inspect visible workspace files.",
|
||||
"Use workspace_read_file before modifying an existing file. Read output has line numbers; never include line number prefixes in old_string or new_string.",
|
||||
"Use workspace_apply_patch for precise edits to existing files. The old_string must match exactly and uniquely unless replace_all is true.",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use crate::application::services::agent_model_gateway::{
|
|||
use crate::application::services::agent_tools::{
|
||||
AgentToolDispatcher, AgentToolEffect, AgentToolSession,
|
||||
};
|
||||
use crate::application::services::skill_service::SkillService;
|
||||
use crate::domain::models::agent::{
|
||||
AgentChatRef, AgentModelContentPart, AgentModelRequest, AgentModelRole, AgentRun,
|
||||
AgentRunEventLevel, AgentRunStatus, AgentToolCall, WorkspacePath,
|
||||
|
|
@ -32,6 +33,7 @@ use crate::domain::repositories::chat_repository::ChatRepository;
|
|||
use crate::domain::repositories::workspace_repository::WorkspaceRepository;
|
||||
use crate::infrastructure::repositories::file_agent_repository::FileAgentRepository;
|
||||
use crate::infrastructure::repositories::file_chat_repository::FileChatRepository;
|
||||
use crate::infrastructure::repositories::file_skill_repository::FileSkillRepository;
|
||||
|
||||
#[test]
|
||||
fn workspace_id_uses_stable_chat_id_not_character_chat_file_name() {
|
||||
|
|
@ -115,6 +117,7 @@ async fn agent_loop_writes_artifact_and_reaches_awaiting_commit() {
|
|||
repository.clone(),
|
||||
test_chat_repository(&root),
|
||||
test_chat_repository(&root),
|
||||
test_skill_service(&root),
|
||||
model_gateway,
|
||||
);
|
||||
let request = ChatCompletionGenerateRequestDto {
|
||||
|
|
@ -297,6 +300,7 @@ async fn agent_loop_reads_and_patches_workspace_artifact() {
|
|||
repository.clone(),
|
||||
test_chat_repository(&root),
|
||||
test_chat_repository(&root),
|
||||
test_skill_service(&root),
|
||||
model_gateway,
|
||||
);
|
||||
let request = ChatCompletionGenerateRequestDto {
|
||||
|
|
@ -420,6 +424,7 @@ async fn finalize_commit_promotes_persistent_workspace_projection() {
|
|||
repository.clone(),
|
||||
test_chat_repository(&root),
|
||||
test_chat_repository(&root),
|
||||
test_skill_service(&root),
|
||||
model_gateway,
|
||||
);
|
||||
let request = ChatCompletionGenerateRequestDto {
|
||||
|
|
@ -581,6 +586,7 @@ async fn agent_loop_returns_recoverable_tool_errors_to_model() {
|
|||
repository.clone(),
|
||||
test_chat_repository(&root),
|
||||
test_chat_repository(&root),
|
||||
test_skill_service(&root),
|
||||
model_gateway,
|
||||
);
|
||||
let request = ChatCompletionGenerateRequestDto {
|
||||
|
|
@ -680,6 +686,7 @@ async fn workspace_patch_requires_full_read_state() {
|
|||
chat_repository.clone(),
|
||||
chat_repository,
|
||||
repository.clone(),
|
||||
test_skill_service(&root),
|
||||
);
|
||||
let mut session = AgentToolSession::default();
|
||||
let patch_call = AgentToolCall {
|
||||
|
|
@ -799,6 +806,7 @@ async fn dispatcher_searches_and_reads_current_chat_messages() {
|
|||
chat_repository.clone(),
|
||||
chat_repository,
|
||||
repository.clone(),
|
||||
test_skill_service(&root),
|
||||
);
|
||||
let mut session = AgentToolSession::default();
|
||||
let search_call = AgentToolCall {
|
||||
|
|
@ -890,6 +898,7 @@ async fn dispatcher_reads_worldinfo_activation_from_run_snapshot() {
|
|||
chat_repository.clone(),
|
||||
chat_repository,
|
||||
repository.clone(),
|
||||
test_skill_service(&root),
|
||||
);
|
||||
let mut session = AgentToolSession::default();
|
||||
let call = AgentToolCall {
|
||||
|
|
@ -925,6 +934,12 @@ fn test_chat_repository(root: &Path) -> Arc<FileChatRepository> {
|
|||
))
|
||||
}
|
||||
|
||||
fn test_skill_service(root: &Path) -> Arc<SkillService> {
|
||||
Arc::new(SkillService::new(Arc::new(FileSkillRepository::new(
|
||||
root.join("skills"),
|
||||
))))
|
||||
}
|
||||
|
||||
async fn save_character_payload(
|
||||
repository: &FileChatRepository,
|
||||
root: &Path,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod common;
|
|||
mod dispatcher;
|
||||
mod registry;
|
||||
mod session;
|
||||
mod skill;
|
||||
mod workspace;
|
||||
mod world_info;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ use serde_json::json;
|
|||
|
||||
use super::chat;
|
||||
use super::session::AgentToolSession;
|
||||
use super::skill;
|
||||
use super::workspace;
|
||||
use super::world_info;
|
||||
use crate::application::errors::ApplicationError;
|
||||
use crate::application::services::skill_service::SkillService;
|
||||
use crate::domain::models::agent::{AgentToolCall, AgentToolResult, WorkspacePath};
|
||||
use crate::domain::repositories::agent_run_repository::AgentRunRepository;
|
||||
use crate::domain::repositories::chat_repository::ChatRepository;
|
||||
|
|
@ -42,6 +44,7 @@ pub struct AgentToolDispatcher {
|
|||
chat_repository: Arc<dyn ChatRepository>,
|
||||
group_chat_repository: Arc<dyn GroupChatRepository>,
|
||||
workspace_repository: Arc<dyn WorkspaceRepository>,
|
||||
skill_service: Arc<SkillService>,
|
||||
}
|
||||
|
||||
impl AgentToolDispatcher {
|
||||
|
|
@ -50,12 +53,14 @@ impl AgentToolDispatcher {
|
|||
chat_repository: Arc<dyn ChatRepository>,
|
||||
group_chat_repository: Arc<dyn GroupChatRepository>,
|
||||
workspace_repository: Arc<dyn WorkspaceRepository>,
|
||||
skill_service: Arc<SkillService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
run_repository,
|
||||
chat_repository,
|
||||
group_chat_repository,
|
||||
workspace_repository,
|
||||
skill_service,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +95,8 @@ impl AgentToolDispatcher {
|
|||
world_info::WORLDINFO_READ_ACTIVATED => {
|
||||
world_info::read_activated(self.workspace_repository.as_ref(), run_id, call).await?
|
||||
}
|
||||
skill::SKILL_LIST => skill::list(self.skill_service.as_ref(), call).await?,
|
||||
skill::SKILL_READ => skill::read(self.skill_service.as_ref(), call).await?,
|
||||
workspace::WORKSPACE_LIST_FILES => {
|
||||
workspace::list_files(self.workspace_repository.as_ref(), run_id, call).await?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use super::chat::{chat_read_messages_spec, chat_search_spec};
|
||||
use super::skill::{skill_list_spec, skill_read_spec};
|
||||
use super::workspace::{
|
||||
workspace_apply_patch_spec, workspace_finish_spec, workspace_list_files_spec,
|
||||
workspace_read_file_spec, workspace_write_file_spec,
|
||||
|
|
@ -18,6 +19,8 @@ impl BuiltinAgentToolRegistry {
|
|||
chat_search_spec(),
|
||||
chat_read_messages_spec(),
|
||||
worldinfo_read_activated_spec(),
|
||||
skill_list_spec(),
|
||||
skill_read_spec(),
|
||||
workspace_list_files_spec(),
|
||||
workspace_read_file_spec(),
|
||||
workspace_write_file_spec(),
|
||||
|
|
@ -43,6 +46,13 @@ mod tests {
|
|||
let tools = registry.specs();
|
||||
|
||||
assert_eq!(tools[0].model_name, "chat_search");
|
||||
assert_eq!(
|
||||
tools
|
||||
.iter()
|
||||
.find(|spec| spec.model_name == "skill_read")
|
||||
.map(|spec| spec.name.as_str()),
|
||||
Some("skill.read")
|
||||
);
|
||||
assert_eq!(
|
||||
tools
|
||||
.iter()
|
||||
|
|
|
|||
51
src-tauri/src/application/services/agent_tools/skill/list.rs
Normal file
51
src-tauri/src/application/services/agent_tools/skill/list.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
use serde_json::json;
|
||||
|
||||
use super::super::dispatcher::AgentToolEffect;
|
||||
use crate::application::errors::ApplicationError;
|
||||
use crate::application::services::skill_service::SkillService;
|
||||
use crate::domain::models::agent::{AgentToolCall, AgentToolResult};
|
||||
|
||||
pub(in crate::application::services::agent_tools) async fn list(
|
||||
skill_service: &SkillService,
|
||||
call: &AgentToolCall,
|
||||
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
|
||||
let skills = skill_service.list_skills().await?;
|
||||
let content = if skills.is_empty() {
|
||||
"No Agent Skills are installed.".to_string()
|
||||
} else {
|
||||
skills
|
||||
.iter()
|
||||
.map(|skill| {
|
||||
let display = skill
|
||||
.display_name
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(skill.name.as_str());
|
||||
format!(
|
||||
"- {}: {}{}",
|
||||
skill.name,
|
||||
skill.description,
|
||||
if display == skill.name {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (display name: {display})")
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
Ok((
|
||||
AgentToolResult {
|
||||
call_id: call.id.clone(),
|
||||
name: call.name.clone(),
|
||||
content,
|
||||
structured: json!({ "skills": skills }),
|
||||
is_error: false,
|
||||
error_code: None,
|
||||
resource_refs: Vec::new(),
|
||||
},
|
||||
AgentToolEffect::None,
|
||||
))
|
||||
}
|
||||
10
src-tauri/src/application/services/agent_tools/skill/mod.rs
Normal file
10
src-tauri/src/application/services/agent_tools/skill/mod.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
mod list;
|
||||
mod read;
|
||||
mod specs;
|
||||
|
||||
pub(super) use self::list::list;
|
||||
pub(super) use self::read::read;
|
||||
pub(super) use self::specs::{skill_list_spec, skill_read_spec};
|
||||
|
||||
pub(super) const SKILL_LIST: &str = "skill.list";
|
||||
pub(super) const SKILL_READ: &str = "skill.read";
|
||||
92
src-tauri/src/application/services/agent_tools/skill/read.rs
Normal file
92
src-tauri/src/application/services/agent_tools/skill/read.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use serde_json::json;
|
||||
|
||||
use super::super::common::{
|
||||
object_args, optional_usize_arg, required_trimmed_string_arg, tool_error,
|
||||
};
|
||||
use super::super::dispatcher::AgentToolEffect;
|
||||
use crate::application::errors::ApplicationError;
|
||||
use crate::application::services::skill_service::SkillService;
|
||||
use crate::domain::models::agent::{AgentToolCall, AgentToolResult};
|
||||
|
||||
pub(in crate::application::services::agent_tools) async fn read(
|
||||
skill_service: &SkillService,
|
||||
call: &AgentToolCall,
|
||||
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
|
||||
let Some(args) = object_args(call) else {
|
||||
return Ok((
|
||||
tool_error(
|
||||
call,
|
||||
"tool.invalid_arguments",
|
||||
"arguments must be an object",
|
||||
),
|
||||
AgentToolEffect::None,
|
||||
));
|
||||
};
|
||||
let Some(name) = required_trimmed_string_arg(args, "name") else {
|
||||
return Ok((
|
||||
tool_error(call, "tool.invalid_arguments", "name is required"),
|
||||
AgentToolEffect::None,
|
||||
));
|
||||
};
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("SKILL.md");
|
||||
let max_chars = match optional_usize_arg(args, "max_chars") {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
return Ok((
|
||||
tool_error(call, "tool.invalid_arguments", &message),
|
||||
AgentToolEffect::None,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let read = match skill_service.read_skill_file(name, path, max_chars).await {
|
||||
Ok(read) => read,
|
||||
Err(ApplicationError::ValidationError(message)) => {
|
||||
return Ok((
|
||||
tool_error(call, "skill.invalid_request", &message),
|
||||
AgentToolEffect::None,
|
||||
));
|
||||
}
|
||||
Err(ApplicationError::NotFound(message)) => {
|
||||
return Ok((
|
||||
tool_error(call, "skill.not_found", &message),
|
||||
AgentToolEffect::None,
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
let content = format!(
|
||||
"{} chars from {}, sha256 {}{}\n{}",
|
||||
read.chars,
|
||||
read.resource_ref,
|
||||
read.sha256,
|
||||
if read.truncated { " (truncated)" } else { "" },
|
||||
read.content
|
||||
);
|
||||
Ok((
|
||||
AgentToolResult {
|
||||
call_id: call.id.clone(),
|
||||
name: call.name.clone(),
|
||||
content,
|
||||
structured: json!({
|
||||
"name": read.name,
|
||||
"path": read.path,
|
||||
"bytes": read.bytes,
|
||||
"sha256": read.sha256,
|
||||
"chars": read.chars,
|
||||
"truncated": read.truncated,
|
||||
"resourceRef": read.resource_ref,
|
||||
}),
|
||||
is_error: false,
|
||||
error_code: None,
|
||||
resource_refs: vec![read.resource_ref],
|
||||
},
|
||||
AgentToolEffect::None,
|
||||
))
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
use serde_json::json;
|
||||
|
||||
use super::{SKILL_LIST, SKILL_READ};
|
||||
use crate::domain::models::agent::AgentToolSpec;
|
||||
|
||||
const MODEL_SKILL_LIST: &str = "skill_list";
|
||||
const MODEL_SKILL_READ: &str = "skill_read";
|
||||
|
||||
pub(in crate::application::services::agent_tools) fn skill_list_spec() -> AgentToolSpec {
|
||||
AgentToolSpec {
|
||||
name: SKILL_LIST.to_string(),
|
||||
model_name: MODEL_SKILL_LIST.to_string(),
|
||||
title: "Skill List".to_string(),
|
||||
description: "List installed Agent Skills by name and description. Use this before skill_read when reusable writing, editing, planning, or character guidance may help.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}),
|
||||
output_schema: None,
|
||||
annotations: json!({ "readOnly": true, "sourceKind": "skill" }),
|
||||
source: "builtin".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::application::services::agent_tools) fn skill_read_spec() -> AgentToolSpec {
|
||||
AgentToolSpec {
|
||||
name: SKILL_READ.to_string(),
|
||||
model_name: MODEL_SKILL_READ.to_string(),
|
||||
title: "Skill Read".to_string(),
|
||||
description: "Read a UTF-8 file from an installed Agent Skill. Start with SKILL.md, then follow referenced paths such as references/*.md or examples/*.md as needed.".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Installed Skill name from skill_list."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Skill package relative file path. Defaults to SKILL.md."
|
||||
},
|
||||
"max_chars": {
|
||||
"type": "integer",
|
||||
"description": "Maximum characters to read. Defaults to 20000; maximum is 80000."
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
output_schema: None,
|
||||
annotations: json!({ "readOnly": true, "sourceKind": "skill" }),
|
||||
source: "builtin".to_string(),
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ pub mod preset_service;
|
|||
pub mod quick_reply_service;
|
||||
pub mod secret_service;
|
||||
pub mod settings_service;
|
||||
pub mod skill_service;
|
||||
pub mod stable_diffusion_service;
|
||||
pub mod theme_service;
|
||||
pub mod tokenization_service;
|
||||
|
|
|
|||
63
src-tauri/src/application/services/skill_service.rs
Normal file
63
src-tauri/src/application/services/skill_service.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::application::errors::ApplicationError;
|
||||
use crate::domain::models::skill::{
|
||||
SkillExportResult, SkillImportInput, SkillImportPreview, SkillIndexEntry, SkillInstallRequest,
|
||||
SkillInstallResult, SkillReadResult,
|
||||
};
|
||||
use crate::domain::repositories::skill_repository::SkillRepository;
|
||||
|
||||
pub struct SkillService {
|
||||
repository: Arc<dyn SkillRepository>,
|
||||
}
|
||||
|
||||
impl SkillService {
|
||||
pub fn new(repository: Arc<dyn SkillRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
pub async fn list_skills(&self) -> Result<Vec<SkillIndexEntry>, ApplicationError> {
|
||||
Ok(self.repository.list_skills().await?)
|
||||
}
|
||||
|
||||
pub async fn preview_import(
|
||||
&self,
|
||||
input: SkillImportInput,
|
||||
) -> Result<SkillImportPreview, ApplicationError> {
|
||||
Ok(self.repository.preview_import(input).await?)
|
||||
}
|
||||
|
||||
pub async fn install_import(
|
||||
&self,
|
||||
request: SkillInstallRequest,
|
||||
) -> Result<SkillInstallResult, ApplicationError> {
|
||||
Ok(self.repository.install_import(request).await?)
|
||||
}
|
||||
|
||||
pub async fn read_skill_file(
|
||||
&self,
|
||||
name: &str,
|
||||
path: &str,
|
||||
max_chars: Option<usize>,
|
||||
) -> Result<SkillReadResult, ApplicationError> {
|
||||
Ok(self
|
||||
.repository
|
||||
.read_skill_file(name, path, max_chars)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn export_skill(&self, name: &str) -> Result<SkillExportResult, ApplicationError> {
|
||||
Ok(self.repository.export_skill(name).await?)
|
||||
}
|
||||
|
||||
pub async fn delete_skills_for_source(
|
||||
&self,
|
||||
source_kind: &str,
|
||||
source_id: &str,
|
||||
) -> Result<Vec<String>, ApplicationError> {
|
||||
Ok(self
|
||||
.repository
|
||||
.delete_skills_for_source(source_kind, source_id)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ pub mod preset;
|
|||
pub mod quick_reply;
|
||||
pub mod secret;
|
||||
pub mod settings;
|
||||
pub mod skill;
|
||||
pub mod theme;
|
||||
pub mod tt_sync;
|
||||
pub mod update;
|
||||
|
|
|
|||
177
src-tauri/src/domain/models/skill.rs
Normal file
177
src-tauri/src/domain/models/skill.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillIndexEntry {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_kind: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub license: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub installed_hash: String,
|
||||
pub file_count: usize,
|
||||
pub total_bytes: u64,
|
||||
pub has_scripts: bool,
|
||||
pub has_binary: bool,
|
||||
pub installed_at: DateTime<Utc>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub source_refs: Vec<SkillSourceRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillSourceRef {
|
||||
pub kind: String,
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub installed_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillFileRef {
|
||||
pub path: String,
|
||||
pub kind: SkillFileKind,
|
||||
pub media_type: String,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkillFileKind {
|
||||
Text,
|
||||
Binary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillImportPreview {
|
||||
pub skill: SkillIndexEntry,
|
||||
pub files: Vec<SkillFileRef>,
|
||||
pub conflict: SkillImportConflict,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub source: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillImportConflict {
|
||||
pub kind: SkillImportConflictKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub installed_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkillImportConflictKind {
|
||||
New,
|
||||
Same,
|
||||
Different,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkillInstallConflictStrategy {
|
||||
Skip,
|
||||
Replace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillInstallRequest {
|
||||
pub input: SkillImportInput,
|
||||
#[serde(default)]
|
||||
pub conflict_strategy: Option<SkillInstallConflictStrategy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillInstallResult {
|
||||
pub name: String,
|
||||
pub action: SkillInstallAction,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skill: Option<SkillIndexEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkillInstallAction {
|
||||
Installed,
|
||||
Replaced,
|
||||
AlreadyInstalled,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum SkillImportInput {
|
||||
InlineFiles {
|
||||
files: Vec<SkillInlineFile>,
|
||||
#[serde(default)]
|
||||
source: Value,
|
||||
},
|
||||
Directory {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
source: Value,
|
||||
},
|
||||
ArchiveFile {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
source: Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillInlineFile {
|
||||
pub path: String,
|
||||
#[serde(default = "default_inline_encoding")]
|
||||
pub encoding: String,
|
||||
pub content: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub media_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size_bytes: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillReadResult {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub chars: usize,
|
||||
pub bytes: u64,
|
||||
pub sha256: String,
|
||||
pub truncated: bool,
|
||||
pub resource_ref: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillExportResult {
|
||||
pub file_name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
fn default_inline_encoding() -> String {
|
||||
"utf8".to_string()
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ pub mod prompt_cache_repository;
|
|||
pub mod quick_reply_repository;
|
||||
pub mod secret_repository;
|
||||
pub mod settings_repository;
|
||||
pub mod skill_repository;
|
||||
pub mod stable_diffusion_repository;
|
||||
pub mod theme_repository;
|
||||
pub mod tokenizer_repository;
|
||||
|
|
|
|||
37
src-tauri/src/domain/repositories/skill_repository.rs
Normal file
37
src-tauri/src/domain/repositories/skill_repository.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::models::skill::{
|
||||
SkillExportResult, SkillImportInput, SkillImportPreview, SkillIndexEntry, SkillInstallRequest,
|
||||
SkillInstallResult, SkillReadResult,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait SkillRepository: Send + Sync {
|
||||
async fn list_skills(&self) -> Result<Vec<SkillIndexEntry>, DomainError>;
|
||||
|
||||
async fn preview_import(
|
||||
&self,
|
||||
input: SkillImportInput,
|
||||
) -> Result<SkillImportPreview, DomainError>;
|
||||
|
||||
async fn install_import(
|
||||
&self,
|
||||
request: SkillInstallRequest,
|
||||
) -> Result<SkillInstallResult, DomainError>;
|
||||
|
||||
async fn read_skill_file(
|
||||
&self,
|
||||
name: &str,
|
||||
path: &str,
|
||||
max_chars: Option<usize>,
|
||||
) -> Result<SkillReadResult, DomainError>;
|
||||
|
||||
async fn export_skill(&self, name: &str) -> Result<SkillExportResult, DomainError>;
|
||||
|
||||
async fn delete_skills_for_source(
|
||||
&self,
|
||||
source_kind: &str,
|
||||
source_id: &str,
|
||||
) -> Result<Vec<String>, DomainError>;
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
use std::fs;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use zip::ZipWriter;
|
||||
use zip::read::ZipFile;
|
||||
|
||||
use super::package::collect_skill_files;
|
||||
use super::paths::normalize_skill_path;
|
||||
use super::{MAX_FILES, MAX_SINGLE_FILE_BYTES, MAX_TOTAL_BYTES, MAX_ZIP_COMPRESSION_RATIO};
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::infrastructure::zipkit;
|
||||
|
||||
pub(super) fn extract_archive(archive_path: &Path, destination: &Path) -> Result<(), DomainError> {
|
||||
let file = fs::File::open(archive_path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to open Skill archive '{}': {}",
|
||||
archive_path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
|
||||
DomainError::InvalidData(format!(
|
||||
"Failed to read Skill archive '{}': {}",
|
||||
archive_path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
if archive.len() > MAX_FILES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill archive must contain <= {MAX_FILES} entries"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut total_bytes = 0u64;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive.by_index(index).map_err(|error| {
|
||||
DomainError::InvalidData(format!("Failed to read Skill archive entry: {error}"))
|
||||
})?;
|
||||
if zip_entry_is_symlink(&entry) {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill archive entry cannot be a symlink: {}",
|
||||
entry.name()
|
||||
)));
|
||||
}
|
||||
if entry.size() > MAX_SINGLE_FILE_BYTES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill archive entry '{}' exceeds {} bytes",
|
||||
entry.name(),
|
||||
MAX_SINGLE_FILE_BYTES
|
||||
)));
|
||||
}
|
||||
if entry.compressed_size() > 0
|
||||
&& entry.size() / entry.compressed_size() > MAX_ZIP_COMPRESSION_RATIO
|
||||
{
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill archive entry '{}' has an excessive compression ratio",
|
||||
entry.name()
|
||||
)));
|
||||
}
|
||||
total_bytes = total_bytes
|
||||
.checked_add(entry.size())
|
||||
.ok_or_else(|| DomainError::InvalidData("Skill archive is too large".to_string()))?;
|
||||
if total_bytes > MAX_TOTAL_BYTES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill archive exceeds {} bytes",
|
||||
MAX_TOTAL_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
let (entry_path, display_name) = zipkit::enclosed_zip_entry_path_with_name(&entry)?;
|
||||
let raw_path = entry_path.to_string_lossy().to_string();
|
||||
if is_ignored_archive_path(&raw_path) {
|
||||
continue;
|
||||
}
|
||||
let path = normalize_skill_path(&raw_path)?;
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(destination.join(path)).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill archive directory '{}': {}",
|
||||
display_name, error
|
||||
))
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let target = destination.join(path);
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill archive parent '{}': {}",
|
||||
parent.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
}
|
||||
let mut output = fs::File::create(&target).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill archive file '{}': {}",
|
||||
target.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
std::io::copy(&mut entry, &mut output).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to extract Skill archive file '{}': {}",
|
||||
target.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn export_skill_dir(root: &Path) -> Result<Vec<u8>, DomainError> {
|
||||
let files = collect_skill_files(root)?;
|
||||
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for file in &files {
|
||||
let path = root.join(&file.path);
|
||||
writer
|
||||
.start_file(file.path.as_str(), zipkit::export_file_options(&path))
|
||||
.map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to add Skill export file '{}': {}",
|
||||
file.path, error
|
||||
))
|
||||
})?;
|
||||
let bytes = fs::read(&path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill export file '{}': {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
writer.write_all(&bytes).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to write Skill export file '{}': {}",
|
||||
file.path, error
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
let cursor = writer.finish().map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to finish Skill export archive: {error}"))
|
||||
})?;
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
|
||||
fn zip_entry_is_symlink<R: Read + ?Sized>(entry: &ZipFile<'_, R>) -> bool {
|
||||
entry
|
||||
.unix_mode()
|
||||
.is_some_and(|mode| mode & 0o170000 == 0o120000)
|
||||
}
|
||||
|
||||
fn is_ignored_archive_path(path: &str) -> bool {
|
||||
path.split('/').any(|segment| segment == "__MACOSX")
|
||||
|| path.ends_with(".DS_Store")
|
||||
|| path
|
||||
.split('/')
|
||||
.next_back()
|
||||
.is_some_and(|name| name.starts_with("._"))
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::MAX_SINGLE_FILE_BYTES;
|
||||
use super::paths::normalize_skill_path;
|
||||
use crate::domain::errors::DomainError;
|
||||
|
||||
pub(super) fn copy_dir_contents(source: &Path, destination: &Path) -> Result<(), DomainError> {
|
||||
fs::create_dir_all(destination).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill package directory '{}': {}",
|
||||
destination.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
|
||||
for entry in fs::read_dir(source).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill package directory '{}': {}",
|
||||
source.display(),
|
||||
error
|
||||
))
|
||||
})? {
|
||||
let entry = entry.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to read Skill package entry: {error}"))
|
||||
})?;
|
||||
let source_path = entry.path();
|
||||
let metadata = fs::symlink_metadata(&source_path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill package entry metadata '{}': {}",
|
||||
source_path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill package cannot contain symlink: {}",
|
||||
source_path.display()
|
||||
)));
|
||||
}
|
||||
let relative = source_path.strip_prefix(source).map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to compute Skill relative path: {error}"))
|
||||
})?;
|
||||
let normalized = normalize_skill_path(&relative.to_string_lossy())?;
|
||||
let target_path = destination.join(normalized);
|
||||
if metadata.is_dir() {
|
||||
copy_dir_contents(&source_path, &target_path)?;
|
||||
} else if metadata.is_file() {
|
||||
if metadata.len() > MAX_SINGLE_FILE_BYTES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill file '{}' exceeds {} bytes",
|
||||
source_path.display(),
|
||||
MAX_SINGLE_FILE_BYTES
|
||||
)));
|
||||
}
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill package parent '{}': {}",
|
||||
parent.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
}
|
||||
fs::copy(&source_path, &target_path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to copy Skill package file '{}' -> '{}': {}",
|
||||
source_path.display(),
|
||||
target_path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn activate_package_dir(source: &Path, target: &Path) -> Result<(), DomainError> {
|
||||
let backup = target.with_file_name(format!(
|
||||
".backup-{}-{}",
|
||||
target
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("skill"),
|
||||
Uuid::new_v4().simple()
|
||||
));
|
||||
let had_target = target.exists();
|
||||
if had_target {
|
||||
fs::rename(target, &backup).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to move existing Skill '{}' to backup '{}': {}",
|
||||
target.display(),
|
||||
backup.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
let result = fs::rename(source, target).or_else(|rename_error| {
|
||||
copy_dir_contents(source, target)?;
|
||||
fs::remove_dir_all(source).map_err(|remove_error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to remove Skill staging directory after copy fallback. Rename error: {}; remove error: {}",
|
||||
rename_error, remove_error
|
||||
))
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if had_target {
|
||||
cleanup_dir(&backup);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
if had_target {
|
||||
let _ = fs::rename(&backup, target);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn delete_installed_skill_dir(path: &Path, name: &str) -> Result<(), DomainError> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
DomainError::NotFound(format!("Skill directory not found: {name}"))
|
||||
} else {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill directory metadata '{}': {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
}
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill directory cannot be a symlink: {name}"
|
||||
)));
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill installed path is not a directory: {name}"
|
||||
)));
|
||||
}
|
||||
|
||||
fs::remove_dir_all(path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to delete Skill directory '{}': {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn cleanup_dir(path: &Path) {
|
||||
if path.exists() {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio::fs as tokio_fs;
|
||||
|
||||
use super::paths::validate_skill_name;
|
||||
use super::{FileSkillRepository, INDEX_VERSION};
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::models::skill::SkillIndexEntry;
|
||||
use crate::infrastructure::persistence::file_system::{
|
||||
replace_file_with_fallback, unique_temp_path,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct SkillIndexFile {
|
||||
pub(super) version: u32,
|
||||
pub(super) skills: Vec<SkillIndexEntry>,
|
||||
}
|
||||
|
||||
impl FileSkillRepository {
|
||||
pub(super) async fn load_index(&self) -> Result<SkillIndexFile, DomainError> {
|
||||
self.ensure_layout().await?;
|
||||
let path = self.index_path();
|
||||
let text = match tokio_fs::read_to_string(&path).await {
|
||||
Ok(text) => text,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
if self.has_installed_skill_directories().await? {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill index is missing while installed skills exist".to_string(),
|
||||
));
|
||||
}
|
||||
let index = SkillIndexFile {
|
||||
version: INDEX_VERSION,
|
||||
skills: Vec::new(),
|
||||
};
|
||||
self.save_index(&index).await?;
|
||||
return Ok(index);
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(DomainError::InternalError(format!(
|
||||
"Failed to read Skill index '{}': {}",
|
||||
path.display(),
|
||||
error
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let index: SkillIndexFile = serde_json::from_str(&text).map_err(|error| {
|
||||
DomainError::InvalidData(format!(
|
||||
"Invalid Skill index '{}': {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
if index.version != INDEX_VERSION {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Unsupported Skill index version {}",
|
||||
index.version
|
||||
)));
|
||||
}
|
||||
for skill in &index.skills {
|
||||
validate_skill_name(&skill.name)?;
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
pub(super) async fn save_index(&self, index: &SkillIndexFile) -> Result<(), DomainError> {
|
||||
self.ensure_layout().await?;
|
||||
let path = self.index_path();
|
||||
let text = serde_json::to_string_pretty(index).map_err(|error| {
|
||||
DomainError::InvalidData(format!("Failed to serialize Skill index: {error}"))
|
||||
})?;
|
||||
let temp = unique_temp_path(&path, "skills.json");
|
||||
tokio_fs::write(&temp, text).await.map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to write temporary Skill index '{}': {}",
|
||||
temp.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
replace_file_with_fallback(&temp, &path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn has_installed_skill_directories(&self) -> Result<bool, DomainError> {
|
||||
let mut entries = match tokio_fs::read_dir(self.installed_root()).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => {
|
||||
return Err(DomainError::InternalError(format!(
|
||||
"Failed to read Skill installed directory: {error}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
while let Some(entry) = entries.next_entry().await.map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill installed directory entry: {error}"
|
||||
))
|
||||
})? {
|
||||
let metadata = entry.metadata().await.map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill installed entry metadata: {error}"
|
||||
))
|
||||
})?;
|
||||
if metadata.is_dir() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
use super::FileSkillRepository;
|
||||
use super::fs_ops::{activate_package_dir, cleanup_dir};
|
||||
use super::materialize::PreparedImport;
|
||||
use super::package::{ValidatedSkill, validate_skill_root};
|
||||
use super::source_refs::merge_source_refs;
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::models::skill::{
|
||||
SkillImportConflict, SkillImportConflictKind, SkillInstallAction, SkillInstallConflictStrategy,
|
||||
SkillInstallResult,
|
||||
};
|
||||
|
||||
impl FileSkillRepository {
|
||||
pub(super) async fn preview_prepared(
|
||||
&self,
|
||||
prepared: &PreparedImport,
|
||||
) -> Result<ValidatedSkill, DomainError> {
|
||||
let mut validated = validate_skill_root(&prepared.package_root, prepared.source.clone())?;
|
||||
let index = self.load_index().await?;
|
||||
let installed = index
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == validated.entry.name);
|
||||
validated.preview.conflict = match installed {
|
||||
None => SkillImportConflict {
|
||||
kind: SkillImportConflictKind::New,
|
||||
installed_hash: None,
|
||||
},
|
||||
Some(entry) if entry.installed_hash == validated.entry.installed_hash => {
|
||||
SkillImportConflict {
|
||||
kind: SkillImportConflictKind::Same,
|
||||
installed_hash: Some(entry.installed_hash.clone()),
|
||||
}
|
||||
}
|
||||
Some(entry) => SkillImportConflict {
|
||||
kind: SkillImportConflictKind::Different,
|
||||
installed_hash: Some(entry.installed_hash.clone()),
|
||||
},
|
||||
};
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
pub(super) async fn install_validated(
|
||||
&self,
|
||||
prepared: PreparedImport,
|
||||
validated: ValidatedSkill,
|
||||
strategy: Option<SkillInstallConflictStrategy>,
|
||||
) -> Result<SkillInstallResult, DomainError> {
|
||||
let mut index = self.load_index().await?;
|
||||
let existing_position = index
|
||||
.skills
|
||||
.iter()
|
||||
.position(|skill| skill.name == validated.entry.name);
|
||||
|
||||
match validated.preview.conflict.kind {
|
||||
SkillImportConflictKind::Same => {
|
||||
let skill = match existing_position {
|
||||
Some(position) => {
|
||||
merge_source_refs(
|
||||
&mut index.skills[position].source_refs,
|
||||
validated.entry.source_refs,
|
||||
);
|
||||
index.skills[position].source_refs.sort_by(|left, right| {
|
||||
left.kind.cmp(&right.kind).then(left.id.cmp(&right.id))
|
||||
});
|
||||
self.save_index(&index).await?;
|
||||
Some(index.skills[position].clone())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
cleanup_dir(&prepared.cleanup_root);
|
||||
return Ok(SkillInstallResult {
|
||||
name: validated.entry.name,
|
||||
action: SkillInstallAction::AlreadyInstalled,
|
||||
skill,
|
||||
});
|
||||
}
|
||||
SkillImportConflictKind::Different => match strategy {
|
||||
Some(SkillInstallConflictStrategy::Skip) => {
|
||||
cleanup_dir(&prepared.cleanup_root);
|
||||
return Ok(SkillInstallResult {
|
||||
name: validated.entry.name,
|
||||
action: SkillInstallAction::Skipped,
|
||||
skill: existing_position.map(|position| index.skills[position].clone()),
|
||||
});
|
||||
}
|
||||
Some(SkillInstallConflictStrategy::Replace) => {}
|
||||
None => {
|
||||
cleanup_dir(&prepared.cleanup_root);
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill '{}' already exists with different content; conflict_strategy is required",
|
||||
validated.entry.name
|
||||
)));
|
||||
}
|
||||
},
|
||||
SkillImportConflictKind::New => {}
|
||||
}
|
||||
|
||||
let target = self.installed_root().join(&validated.entry.name);
|
||||
let replaced = target.exists();
|
||||
activate_package_dir(&prepared.package_root, &target)?;
|
||||
cleanup_dir(&prepared.cleanup_root);
|
||||
|
||||
match existing_position {
|
||||
Some(position) => index.skills[position] = validated.entry.clone(),
|
||||
None => index.skills.push(validated.entry.clone()),
|
||||
}
|
||||
index
|
||||
.skills
|
||||
.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
self.save_index(&index).await?;
|
||||
|
||||
Ok(SkillInstallResult {
|
||||
name: validated.entry.name.clone(),
|
||||
action: if replaced {
|
||||
SkillInstallAction::Replaced
|
||||
} else {
|
||||
SkillInstallAction::Installed
|
||||
},
|
||||
skill: Some(validated.entry),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use tokio::fs as tokio_fs;
|
||||
|
||||
use super::FileSkillRepository;
|
||||
use crate::domain::errors::DomainError;
|
||||
|
||||
impl FileSkillRepository {
|
||||
pub(super) fn installed_root(&self) -> PathBuf {
|
||||
self.root.join("installed")
|
||||
}
|
||||
|
||||
pub(super) fn staging_root(&self) -> PathBuf {
|
||||
self.root.join(".staging")
|
||||
}
|
||||
|
||||
pub(super) fn index_dir(&self) -> PathBuf {
|
||||
self.root.join("index")
|
||||
}
|
||||
|
||||
pub(super) fn index_path(&self) -> PathBuf {
|
||||
self.index_dir().join("skills.json")
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_layout(&self) -> Result<(), DomainError> {
|
||||
tokio_fs::create_dir_all(self.installed_root())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill installed directory: {error}"
|
||||
))
|
||||
})?;
|
||||
tokio_fs::create_dir_all(self.staging_root())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill staging directory: {error}"
|
||||
))
|
||||
})?;
|
||||
tokio_fs::create_dir_all(self.index_dir())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill index directory: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{MAX_SKILL_MD_BYTES, SIDECAR_VERSION};
|
||||
use crate::domain::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct SkillFrontmatter {
|
||||
pub(super) name: String,
|
||||
pub(super) description: String,
|
||||
pub(super) license: Option<String>,
|
||||
pub(super) author: Option<String>,
|
||||
pub(super) version: Option<String>,
|
||||
pub(super) tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct TauriTavernSidecar {
|
||||
pub(super) version: u32,
|
||||
#[serde(default)]
|
||||
pub(super) display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) source_kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) allow_implicit_invocation: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(super) recommended_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(super) recommended_context: Option<TauriTavernRecommendedContext>,
|
||||
#[serde(default)]
|
||||
pub(super) tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(super) notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct TauriTavernRecommendedContext {
|
||||
#[serde(default)]
|
||||
pub(super) default_budget_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub(super) preferred_mode: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn read_skill_frontmatter(root: &Path) -> Result<SkillFrontmatter, DomainError> {
|
||||
let skill_md_path = root.join("SKILL.md");
|
||||
let skill_md_metadata = fs::symlink_metadata(&skill_md_path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
DomainError::InvalidData("Skill package must contain SKILL.md".to_string())
|
||||
} else {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read SKILL.md metadata '{}': {}",
|
||||
skill_md_path.display(),
|
||||
error
|
||||
))
|
||||
}
|
||||
})?;
|
||||
if skill_md_metadata.file_type().is_symlink() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"SKILL.md cannot be a symlink".to_string(),
|
||||
));
|
||||
}
|
||||
if !skill_md_metadata.is_file() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"SKILL.md must be a file".to_string(),
|
||||
));
|
||||
}
|
||||
if skill_md_metadata.len() > MAX_SKILL_MD_BYTES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"SKILL.md must be <= {MAX_SKILL_MD_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
|
||||
let skill_md = fs::read_to_string(&skill_md_path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read SKILL.md '{}': {}",
|
||||
skill_md_path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
parse_skill_frontmatter(&skill_md)
|
||||
}
|
||||
|
||||
pub(super) fn parse_skill_frontmatter(text: &str) -> Result<SkillFrontmatter, DomainError> {
|
||||
let normalized = text.replace("\r\n", "\n");
|
||||
let Some(rest) = normalized.strip_prefix("---\n") else {
|
||||
return Err(DomainError::InvalidData(
|
||||
"SKILL.md must start with YAML frontmatter".to_string(),
|
||||
));
|
||||
};
|
||||
let Some(end) = rest.find("\n---") else {
|
||||
return Err(DomainError::InvalidData(
|
||||
"SKILL.md frontmatter is not closed".to_string(),
|
||||
));
|
||||
};
|
||||
let yaml = &rest[..end];
|
||||
let value: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|error| {
|
||||
DomainError::InvalidData(format!("Invalid SKILL.md frontmatter: {error}"))
|
||||
})?;
|
||||
let name = yaml_string(&value, "name").ok_or_else(|| {
|
||||
DomainError::InvalidData("SKILL.md frontmatter must include name".to_string())
|
||||
})?;
|
||||
let description = yaml_string(&value, "description").ok_or_else(|| {
|
||||
DomainError::InvalidData("SKILL.md frontmatter must include description".to_string())
|
||||
})?;
|
||||
if description.trim().is_empty() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"SKILL.md description cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let metadata = yaml_mapping_child(&value, "metadata");
|
||||
Ok(SkillFrontmatter {
|
||||
name,
|
||||
description,
|
||||
license: yaml_string(&value, "license"),
|
||||
author: metadata.and_then(|value| yaml_string(value, "author")),
|
||||
version: metadata.and_then(|value| yaml_string(value, "version")),
|
||||
tags: metadata
|
||||
.map(|value| yaml_string_array(value, "tags"))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn read_sidecar(root: &Path) -> Result<Option<TauriTavernSidecar>, DomainError> {
|
||||
let path = root.join("agents").join("tauritavern.json");
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(DomainError::InternalError(format!(
|
||||
"Failed to read Skill sidecar metadata '{}': {}",
|
||||
path.display(),
|
||||
error
|
||||
)));
|
||||
}
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"agents/tauritavern.json cannot be a symlink".to_string(),
|
||||
));
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill sidecar '{}': {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
let sidecar: TauriTavernSidecar = serde_json::from_str(&text).map_err(|error| {
|
||||
DomainError::InvalidData(format!("Invalid agents/tauritavern.json: {error}"))
|
||||
})?;
|
||||
if sidecar.version != SIDECAR_VERSION {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Unsupported agents/tauritavern.json version {}",
|
||||
sidecar.version
|
||||
)));
|
||||
}
|
||||
let _ = sidecar.allow_implicit_invocation;
|
||||
let _ = &sidecar.recommended_tools;
|
||||
if let Some(context) = &sidecar.recommended_context {
|
||||
let _ = context.default_budget_tokens;
|
||||
let _ = &context.preferred_mode;
|
||||
}
|
||||
let _ = &sidecar.notes;
|
||||
Ok(Some(sidecar))
|
||||
}
|
||||
|
||||
fn yaml_string(value: &serde_yaml::Value, key: &str) -> Option<String> {
|
||||
let map = value.as_mapping()?;
|
||||
let raw = map.get(serde_yaml::Value::String(key.to_string()))?;
|
||||
raw.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn yaml_string_array(value: &serde_yaml::Value, key: &str) -> Vec<String> {
|
||||
let Some(map) = value.as_mapping() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(raw) = map.get(serde_yaml::Value::String(key.to_string())) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(items) = raw.as_sequence() else {
|
||||
return Vec::new();
|
||||
};
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_yaml::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn yaml_mapping_child<'a>(
|
||||
value: &'a serde_yaml::Value,
|
||||
key: &str,
|
||||
) -> Option<&'a serde_yaml::Value> {
|
||||
value
|
||||
.as_mapping()?
|
||||
.get(serde_yaml::Value::String(key.to_string()))
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::archive::extract_archive;
|
||||
use super::fs_ops::{cleanup_dir, copy_dir_contents};
|
||||
use super::package::sha256_hex;
|
||||
use super::paths::normalize_skill_path;
|
||||
use super::{FileSkillRepository, MAX_FILES, MAX_SINGLE_FILE_BYTES, MAX_TOTAL_BYTES};
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::models::skill::{SkillImportInput, SkillInlineFile};
|
||||
|
||||
pub(super) struct PreparedImport {
|
||||
pub(super) cleanup_root: PathBuf,
|
||||
pub(super) package_root: PathBuf,
|
||||
pub(super) source: Value,
|
||||
}
|
||||
|
||||
impl FileSkillRepository {
|
||||
pub(super) async fn materialize_input(
|
||||
&self,
|
||||
input: &SkillImportInput,
|
||||
) -> Result<PreparedImport, DomainError> {
|
||||
self.ensure_layout().await?;
|
||||
let staging_dir = self
|
||||
.staging_root()
|
||||
.join(format!("import-{}", Uuid::new_v4().simple()));
|
||||
tokio::fs::create_dir_all(&staging_dir)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill staging directory '{}': {}",
|
||||
staging_dir.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
|
||||
let result = match input {
|
||||
SkillImportInput::InlineFiles { files, source } => {
|
||||
let package_root = staging_dir.join("package");
|
||||
fs::create_dir_all(&package_root).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill inline package root '{}': {}",
|
||||
package_root.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
write_inline_files(files, &package_root)?;
|
||||
Ok(PreparedImport {
|
||||
cleanup_root: staging_dir.clone(),
|
||||
package_root,
|
||||
source: source.clone(),
|
||||
})
|
||||
}
|
||||
SkillImportInput::Directory { path, source } => {
|
||||
let source_root = PathBuf::from(path);
|
||||
let selected_root = select_skill_root(&source_root)?;
|
||||
let package_root = staging_dir.join("package");
|
||||
copy_dir_contents(&selected_root, &package_root)?;
|
||||
Ok(PreparedImport {
|
||||
cleanup_root: staging_dir.clone(),
|
||||
package_root,
|
||||
source: source.clone(),
|
||||
})
|
||||
}
|
||||
SkillImportInput::ArchiveFile { path, source } => {
|
||||
let archive_root = staging_dir.join("archive");
|
||||
fs::create_dir_all(&archive_root).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill archive extraction root '{}': {}",
|
||||
archive_root.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
extract_archive(Path::new(path), &archive_root)?;
|
||||
let selected_root = select_skill_root(&archive_root)?;
|
||||
let package_root = staging_dir.join("package");
|
||||
copy_dir_contents(&selected_root, &package_root)?;
|
||||
Ok(PreparedImport {
|
||||
cleanup_root: staging_dir.clone(),
|
||||
package_root,
|
||||
source: source.clone(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
if result.is_err() {
|
||||
cleanup_dir(&staging_dir);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn write_inline_files(files: &[SkillInlineFile], root: &Path) -> Result<(), DomainError> {
|
||||
if files.is_empty() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill inline import must include at least one file".to_string(),
|
||||
));
|
||||
}
|
||||
if files.len() > MAX_FILES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill inline import must include <= {MAX_FILES} files"
|
||||
)));
|
||||
}
|
||||
let mut total_bytes = 0u64;
|
||||
for file in files {
|
||||
let path = normalize_skill_path(&file.path)?;
|
||||
let bytes = match file.encoding.trim().to_ascii_lowercase().as_str() {
|
||||
"utf8" | "utf-8" => file.content.as_bytes().to_vec(),
|
||||
"base64" => BASE64_STANDARD
|
||||
.decode(file.content.as_bytes())
|
||||
.map_err(|error| {
|
||||
DomainError::InvalidData(format!(
|
||||
"Invalid base64 content for '{}': {error}",
|
||||
path
|
||||
))
|
||||
})?,
|
||||
encoding => {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Unsupported Skill inline encoding '{encoding}' for '{path}'"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if let Some(expected_size) = file.size_bytes {
|
||||
if expected_size != bytes.len() as u64 {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Inline Skill file '{}' size mismatch: expected {}, got {}",
|
||||
path,
|
||||
expected_size,
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(expected_hash) = file.sha256.as_deref() {
|
||||
if expected_hash.trim().to_ascii_lowercase() != sha256_hex(&bytes) {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Inline Skill file '{}' sha256 mismatch",
|
||||
path
|
||||
)));
|
||||
}
|
||||
}
|
||||
if bytes.len() as u64 > MAX_SINGLE_FILE_BYTES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill file '{}' exceeds {} bytes",
|
||||
path, MAX_SINGLE_FILE_BYTES
|
||||
)));
|
||||
}
|
||||
total_bytes = total_bytes
|
||||
.checked_add(bytes.len() as u64)
|
||||
.ok_or_else(|| DomainError::InvalidData("Skill package is too large".to_string()))?;
|
||||
if total_bytes > MAX_TOTAL_BYTES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill package exceeds {} bytes",
|
||||
MAX_TOTAL_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
let target = root.join(&path);
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create Skill inline parent '{}': {}",
|
||||
parent.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
}
|
||||
fs::write(&target, bytes).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to write Skill inline file '{}': {}",
|
||||
target.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_skill_root(root: &Path) -> Result<PathBuf, DomainError> {
|
||||
let metadata = fs::symlink_metadata(root).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill import root '{}': {}",
|
||||
root.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill import root cannot be a symlink".to_string(),
|
||||
));
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill import root is not a directory: {}",
|
||||
root.display()
|
||||
)));
|
||||
}
|
||||
let mut candidates = Vec::new();
|
||||
for entry in fs::read_dir(root).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill import root '{}': {}",
|
||||
root.display(),
|
||||
error
|
||||
))
|
||||
})? {
|
||||
let entry = entry.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to read Skill import entry: {error}"))
|
||||
})?;
|
||||
let metadata = fs::symlink_metadata(entry.path()).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill import entry metadata '{}': {}",
|
||||
entry.path().display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill import package cannot contain symlink roots".to_string(),
|
||||
));
|
||||
}
|
||||
if metadata.is_dir() && entry.path().join("SKILL.md").is_file() {
|
||||
candidates.push(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
if root.join("SKILL.md").is_file() {
|
||||
if !candidates.is_empty() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill package contains multiple candidate SKILL.md roots".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(root.to_path_buf());
|
||||
}
|
||||
|
||||
match candidates.len() {
|
||||
1 => Ok(candidates.remove(0)),
|
||||
0 => Err(DomainError::InvalidData(
|
||||
"Skill package must contain exactly one SKILL.md at root or one top-level folder"
|
||||
.to_string(),
|
||||
)),
|
||||
_ => Err(DomainError::InvalidData(
|
||||
"Skill package contains multiple candidate SKILL.md roots".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
mod archive;
|
||||
mod fs_ops;
|
||||
mod index;
|
||||
mod install;
|
||||
mod layout;
|
||||
mod manifest;
|
||||
mod materialize;
|
||||
mod package;
|
||||
mod paths;
|
||||
mod read;
|
||||
mod source_refs;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::models::skill::{
|
||||
SkillExportResult, SkillImportInput, SkillImportPreview, SkillIndexEntry, SkillInstallRequest,
|
||||
SkillInstallResult, SkillReadResult,
|
||||
};
|
||||
use crate::domain::repositories::skill_repository::SkillRepository;
|
||||
|
||||
const INDEX_VERSION: u32 = 1;
|
||||
const SIDECAR_VERSION: u32 = 1;
|
||||
const MAX_FILES: usize = 1000;
|
||||
const MAX_SINGLE_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
|
||||
const MAX_SKILL_MD_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_ZIP_COMPRESSION_RATIO: u64 = 100;
|
||||
const DEFAULT_READ_MAX_CHARS: usize = 20_000;
|
||||
const MAX_READ_CHARS: usize = 80_000;
|
||||
|
||||
pub struct FileSkillRepository {
|
||||
pub(super) root: PathBuf,
|
||||
mutation_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl FileSkillRepository {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self {
|
||||
root,
|
||||
mutation_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SkillRepository for FileSkillRepository {
|
||||
async fn list_skills(&self) -> Result<Vec<SkillIndexEntry>, DomainError> {
|
||||
Ok(self.load_index().await?.skills)
|
||||
}
|
||||
|
||||
async fn preview_import(
|
||||
&self,
|
||||
input: SkillImportInput,
|
||||
) -> Result<SkillImportPreview, DomainError> {
|
||||
let prepared = self.materialize_input(&input).await?;
|
||||
let result = self
|
||||
.preview_prepared(&prepared)
|
||||
.await
|
||||
.map(|validated| validated.preview);
|
||||
fs_ops::cleanup_dir(&prepared.cleanup_root);
|
||||
result
|
||||
}
|
||||
|
||||
async fn install_import(
|
||||
&self,
|
||||
request: SkillInstallRequest,
|
||||
) -> Result<SkillInstallResult, DomainError> {
|
||||
let _guard = self.mutation_lock.lock().await;
|
||||
let prepared = self.materialize_input(&request.input).await?;
|
||||
let validated = match self.preview_prepared(&prepared).await {
|
||||
Ok(validated) => validated,
|
||||
Err(error) => {
|
||||
fs_ops::cleanup_dir(&prepared.cleanup_root);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.install_validated(prepared, validated, request.conflict_strategy)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_skill_file(
|
||||
&self,
|
||||
name: &str,
|
||||
path: &str,
|
||||
max_chars: Option<usize>,
|
||||
) -> Result<SkillReadResult, DomainError> {
|
||||
read::read_skill_file(self, name, path, max_chars).await
|
||||
}
|
||||
|
||||
async fn export_skill(&self, name: &str) -> Result<SkillExportResult, DomainError> {
|
||||
let name = paths::validate_skill_name(name)?;
|
||||
let index = self.load_index().await?;
|
||||
if !index.skills.iter().any(|skill| skill.name == name) {
|
||||
return Err(DomainError::NotFound(format!("Skill not found: {name}")));
|
||||
}
|
||||
|
||||
let root = self.installed_root().join(&name);
|
||||
let bytes = archive::export_skill_dir(&root)?;
|
||||
let sha256 = package::sha256_hex(&bytes);
|
||||
Ok(SkillExportResult {
|
||||
file_name: format!("{name}.ttskill"),
|
||||
bytes,
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_skills_for_source(
|
||||
&self,
|
||||
source_kind: &str,
|
||||
source_id: &str,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let _guard = self.mutation_lock.lock().await;
|
||||
source_refs::delete_skills_for_source(self, source_kind, source_id).await
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::manifest::{read_sidecar, read_skill_frontmatter};
|
||||
use super::paths::{normalize_optional_string, normalize_skill_path, validate_skill_name};
|
||||
use super::source_refs::skill_source_ref_from_import_source;
|
||||
use super::{MAX_FILES, MAX_SINGLE_FILE_BYTES, MAX_TOTAL_BYTES};
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::models::skill::{
|
||||
SkillFileKind, SkillFileRef, SkillImportConflict, SkillImportConflictKind, SkillImportPreview,
|
||||
SkillIndexEntry,
|
||||
};
|
||||
|
||||
pub(super) struct ValidatedSkill {
|
||||
pub(super) entry: SkillIndexEntry,
|
||||
pub(super) preview: SkillImportPreview,
|
||||
}
|
||||
|
||||
pub(super) fn validate_skill_root(
|
||||
root: &Path,
|
||||
source: Value,
|
||||
) -> Result<ValidatedSkill, DomainError> {
|
||||
let frontmatter = read_skill_frontmatter(root)?;
|
||||
let name = validate_skill_name(&frontmatter.name)?;
|
||||
|
||||
let sidecar = read_sidecar(root)?;
|
||||
let files = collect_skill_files(root)?;
|
||||
let file_count = files.len();
|
||||
let total_bytes = files.iter().map(|file| file.size_bytes).sum();
|
||||
let has_scripts = files
|
||||
.iter()
|
||||
.any(|file| file.path == "scripts" || file.path.starts_with("scripts/"));
|
||||
let has_binary = files.iter().any(|file| file.kind == SkillFileKind::Binary);
|
||||
let installed_hash = directory_hash(root, &files)?;
|
||||
let mut tags = BTreeSet::new();
|
||||
tags.extend(frontmatter.tags.iter().cloned());
|
||||
if let Some(sidecar) = &sidecar {
|
||||
tags.extend(
|
||||
sidecar
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| tag.trim().to_string())
|
||||
.filter(|tag| !tag.is_empty()),
|
||||
);
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if has_scripts {
|
||||
warnings.push(
|
||||
"Skill contains scripts/ files; TauriTavern stores them but does not execute them."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if has_binary {
|
||||
warnings.push(
|
||||
"Skill contains binary files; Agent skill.read can only read UTF-8 text files."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if frontmatter.license.is_none() {
|
||||
warnings.push("Skill has no license field.".to_string());
|
||||
}
|
||||
|
||||
let entry = SkillIndexEntry {
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
display_name: sidecar
|
||||
.as_ref()
|
||||
.and_then(|sidecar| normalize_optional_string(sidecar.display_name.as_deref())),
|
||||
source_kind: sidecar
|
||||
.as_ref()
|
||||
.and_then(|sidecar| normalize_optional_string(sidecar.source_kind.as_deref())),
|
||||
license: frontmatter.license,
|
||||
author: frontmatter.author,
|
||||
version: frontmatter.version,
|
||||
tags: tags.into_iter().collect(),
|
||||
installed_hash,
|
||||
file_count,
|
||||
total_bytes,
|
||||
has_scripts,
|
||||
has_binary,
|
||||
installed_at: Utc::now(),
|
||||
source_refs: Vec::new(),
|
||||
};
|
||||
let mut entry = entry;
|
||||
if let Some(source_ref) = skill_source_ref_from_import_source(&source, &entry.installed_hash)? {
|
||||
entry.source_refs.push(source_ref);
|
||||
}
|
||||
|
||||
Ok(ValidatedSkill {
|
||||
preview: SkillImportPreview {
|
||||
skill: entry.clone(),
|
||||
files,
|
||||
conflict: SkillImportConflict {
|
||||
kind: SkillImportConflictKind::New,
|
||||
installed_hash: None,
|
||||
},
|
||||
warnings,
|
||||
source,
|
||||
},
|
||||
entry,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn collect_skill_files(root: &Path) -> Result<Vec<SkillFileRef>, DomainError> {
|
||||
let mut files = Vec::new();
|
||||
collect_skill_files_inner(root, root, &mut files)?;
|
||||
files.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
if files.is_empty() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill package cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if files.len() > MAX_FILES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill package must contain <= {MAX_FILES} files"
|
||||
)));
|
||||
}
|
||||
let total = files.iter().map(|file| file.size_bytes).sum::<u64>();
|
||||
if total > MAX_TOTAL_BYTES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill package exceeds {} bytes",
|
||||
MAX_TOTAL_BYTES
|
||||
)));
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn collect_skill_files_inner(
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
files: &mut Vec<SkillFileRef>,
|
||||
) -> Result<(), DomainError> {
|
||||
for entry in fs::read_dir(current).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill directory '{}': {}",
|
||||
current.display(),
|
||||
error
|
||||
))
|
||||
})? {
|
||||
let entry = entry.map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to read Skill directory entry: {error}"))
|
||||
})?;
|
||||
let path = entry.path();
|
||||
let metadata = fs::symlink_metadata(&path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill file metadata '{}': {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill package cannot contain symlink: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
collect_skill_files_inner(root, &path, files)?;
|
||||
continue;
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
if metadata.len() > MAX_SINGLE_FILE_BYTES {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill file '{}' exceeds {} bytes",
|
||||
path.display(),
|
||||
MAX_SINGLE_FILE_BYTES
|
||||
)));
|
||||
}
|
||||
let relative = path.strip_prefix(root).map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to compute Skill file path: {error}"))
|
||||
})?;
|
||||
let normalized = normalize_skill_path(&relative.to_string_lossy())?;
|
||||
let bytes = fs::read(&path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill file '{}': {}",
|
||||
path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
let kind = if std::str::from_utf8(&bytes).is_ok() {
|
||||
SkillFileKind::Text
|
||||
} else {
|
||||
SkillFileKind::Binary
|
||||
};
|
||||
let media_type = mime_guess::from_path(&normalized)
|
||||
.first_or_octet_stream()
|
||||
.essence_str()
|
||||
.to_string();
|
||||
files.push(SkillFileRef {
|
||||
path: normalized,
|
||||
kind,
|
||||
media_type,
|
||||
size_bytes: metadata.len(),
|
||||
sha256: sha256_hex(&bytes),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn directory_hash(root: &Path, files: &[SkillFileRef]) -> Result<String, DomainError> {
|
||||
let mut hasher = Sha256::new();
|
||||
for file in files {
|
||||
hasher.update(file.path.as_bytes());
|
||||
hasher.update([0]);
|
||||
let bytes = fs::read(root.join(&file.path)).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill file '{}' for hashing: {}",
|
||||
file.path, error
|
||||
))
|
||||
})?;
|
||||
hasher.update(bytes);
|
||||
hasher.update([0]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
pub(super) fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
use crate::domain::errors::DomainError;
|
||||
|
||||
pub(super) fn validate_skill_name(raw: &str) -> Result<String, DomainError> {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if value.len() > 128 {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill name must be <= 128 characters".to_string(),
|
||||
));
|
||||
}
|
||||
if matches!(value, "." | "..") {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill name cannot be '.' or '..'".to_string(),
|
||||
));
|
||||
}
|
||||
if !value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_'))
|
||||
{
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill name must use lowercase ASCII letters, digits, '-' or '_'".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_skill_path(raw: &str) -> Result<String, DomainError> {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill file path cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if value.contains('\0') {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill file path cannot contain NUL".to_string(),
|
||||
));
|
||||
}
|
||||
if value.starts_with('/') || value.starts_with('\\') {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill file path must be relative".to_string(),
|
||||
));
|
||||
}
|
||||
if value.len() >= 2 && value.as_bytes()[1] == b':' && value.as_bytes()[0].is_ascii_alphabetic()
|
||||
{
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill file path cannot use a Windows drive prefix".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let normalized = value.replace('\\', "/");
|
||||
let mut parts = Vec::new();
|
||||
for segment in normalized.split('/') {
|
||||
if segment.is_empty() || segment == "." {
|
||||
continue;
|
||||
}
|
||||
if segment == ".." {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill file path cannot contain ..".to_string(),
|
||||
));
|
||||
}
|
||||
if matches!(segment, ".git" | ".ssh" | ".env") {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill file path contains forbidden segment: {segment}"
|
||||
)));
|
||||
}
|
||||
parts.push(segment);
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return Err(DomainError::InvalidData(
|
||||
"Skill file path cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(parts.join("/"))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_source_string(raw: &str, label: &str) -> Result<String, DomainError> {
|
||||
let value = raw.trim();
|
||||
if value.is_empty() {
|
||||
return Err(DomainError::InvalidData(format!("{label} cannot be empty")));
|
||||
}
|
||||
if value.contains('\0') {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"{label} cannot contain NUL"
|
||||
)));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
use std::fs;
|
||||
|
||||
use super::paths::{normalize_skill_path, validate_skill_name};
|
||||
use super::{DEFAULT_READ_MAX_CHARS, FileSkillRepository, MAX_READ_CHARS};
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::models::skill::SkillReadResult;
|
||||
|
||||
pub(super) async fn read_skill_file(
|
||||
repository: &FileSkillRepository,
|
||||
name: &str,
|
||||
path: &str,
|
||||
max_chars: Option<usize>,
|
||||
) -> Result<SkillReadResult, DomainError> {
|
||||
let name = validate_skill_name(name)?;
|
||||
let path = normalize_skill_path(path)?;
|
||||
let index = repository.load_index().await?;
|
||||
if !index.skills.iter().any(|skill| skill.name == name) {
|
||||
return Err(DomainError::NotFound(format!("Skill not found: {name}")));
|
||||
}
|
||||
|
||||
let skill_root = repository.installed_root().join(&name);
|
||||
let root_metadata = fs::symlink_metadata(&skill_root).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
DomainError::NotFound(format!("Skill directory not found: {name}"))
|
||||
} else {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill directory metadata '{}': {}",
|
||||
skill_root.display(),
|
||||
error
|
||||
))
|
||||
}
|
||||
})?;
|
||||
if root_metadata.file_type().is_symlink() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill directory cannot be a symlink: {name}"
|
||||
)));
|
||||
}
|
||||
if !root_metadata.is_dir() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill installed path is not a directory: {name}"
|
||||
)));
|
||||
}
|
||||
|
||||
let full_path = skill_root.join(&path);
|
||||
let metadata = fs::symlink_metadata(&full_path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
DomainError::NotFound(format!("Skill file not found: skills/{name}/{path}"))
|
||||
} else {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill file metadata '{}': {}",
|
||||
full_path.display(),
|
||||
error
|
||||
))
|
||||
}
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill file cannot be a symlink: skills/{name}/{path}"
|
||||
)));
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill path is not a file: skills/{name}/{path}"
|
||||
)));
|
||||
}
|
||||
|
||||
let canonical_root = fs::canonicalize(&skill_root).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to resolve Skill directory '{}': {}",
|
||||
skill_root.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
let canonical_file = fs::canonicalize(&full_path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to resolve Skill file '{}': {}",
|
||||
full_path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
if !canonical_file.starts_with(&canonical_root) {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill file escapes installed directory: skills/{name}/{path}"
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = fs::read(&full_path).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to read Skill file '{}': {}",
|
||||
full_path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| {
|
||||
DomainError::InvalidData(format!(
|
||||
"Skill file is not UTF-8 text: skills/{name}/{path}"
|
||||
))
|
||||
})?;
|
||||
let requested_chars = max_chars.unwrap_or(DEFAULT_READ_MAX_CHARS);
|
||||
if requested_chars == 0 {
|
||||
return Err(DomainError::InvalidData(
|
||||
"max_chars must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
if requested_chars > MAX_READ_CHARS {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"max_chars must be <= {MAX_READ_CHARS}"
|
||||
)));
|
||||
}
|
||||
|
||||
let total_chars = text.chars().count();
|
||||
let truncated = total_chars > requested_chars;
|
||||
let content = if truncated {
|
||||
text.chars().take(requested_chars).collect()
|
||||
} else {
|
||||
text.to_string()
|
||||
};
|
||||
let sha256 = super::package::sha256_hex(&bytes);
|
||||
|
||||
Ok(SkillReadResult {
|
||||
name: name.clone(),
|
||||
path: path.clone(),
|
||||
content,
|
||||
chars: total_chars.min(requested_chars),
|
||||
bytes: bytes.len() as u64,
|
||||
sha256,
|
||||
truncated,
|
||||
resource_ref: format!("skills/{name}/{path}"),
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
use serde_json::Value;
|
||||
|
||||
use super::FileSkillRepository;
|
||||
use super::fs_ops::delete_installed_skill_dir;
|
||||
use super::paths::normalize_source_string;
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::models::skill::SkillSourceRef;
|
||||
|
||||
pub(super) fn skill_source_ref_from_import_source(
|
||||
source: &Value,
|
||||
installed_hash: &str,
|
||||
) -> Result<Option<SkillSourceRef>, DomainError> {
|
||||
let Some(kind) = source
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let id = source
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let Some(id) = id else {
|
||||
if matches!(kind, "preset" | "character") {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Skill import source.id is required for source kind '{kind}'"
|
||||
)));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let kind = normalize_source_string(kind, "source kind")?;
|
||||
let id = normalize_source_string(id, "source id")?;
|
||||
let label = source
|
||||
.get("label")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
Ok(Some(SkillSourceRef {
|
||||
kind,
|
||||
id,
|
||||
label,
|
||||
installed_hash: installed_hash.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn merge_source_refs(
|
||||
target: &mut Vec<SkillSourceRef>,
|
||||
source_refs: Vec<SkillSourceRef>,
|
||||
) {
|
||||
for source_ref in source_refs {
|
||||
target.retain(|existing| existing.kind != source_ref.kind || existing.id != source_ref.id);
|
||||
target.push(source_ref);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn delete_skills_for_source(
|
||||
repository: &FileSkillRepository,
|
||||
source_kind: &str,
|
||||
source_id: &str,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let source_kind = normalize_source_string(source_kind, "source kind")?;
|
||||
let source_id = normalize_source_string(source_id, "source id")?;
|
||||
let mut index = repository.load_index().await?;
|
||||
let mut next_skills = Vec::with_capacity(index.skills.len());
|
||||
let mut deleted = Vec::new();
|
||||
let mut changed = false;
|
||||
|
||||
for mut skill in index.skills {
|
||||
let original_len = skill.source_refs.len();
|
||||
skill
|
||||
.source_refs
|
||||
.retain(|source| source.kind != source_kind || source.id != source_id);
|
||||
|
||||
if skill.source_refs.len() == original_len {
|
||||
next_skills.push(skill);
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
if skill.source_refs.is_empty() {
|
||||
delete_installed_skill_dir(
|
||||
&repository.installed_root().join(&skill.name),
|
||||
&skill.name,
|
||||
)?;
|
||||
deleted.push(skill.name);
|
||||
} else {
|
||||
next_skills.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
next_skills.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
index.skills = next_skills;
|
||||
repository.save_index(&index).await?;
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::fs as tokio_fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
use crate::domain::models::skill::{
|
||||
SkillImportConflictKind, SkillImportInput, SkillInlineFile, SkillInstallAction,
|
||||
SkillInstallConflictStrategy, SkillInstallRequest,
|
||||
};
|
||||
use crate::domain::repositories::skill_repository::SkillRepository;
|
||||
|
||||
fn temp_root(label: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"tauritavern-skill-{label}-{}",
|
||||
Uuid::new_v4().simple()
|
||||
))
|
||||
}
|
||||
|
||||
fn inline_skill(name: &str, extra_files: Vec<(&str, &str)>) -> SkillImportInput {
|
||||
inline_skill_with_source(name, extra_files, json!({"kind": "test"}))
|
||||
}
|
||||
|
||||
fn inline_skill_with_source(
|
||||
name: &str,
|
||||
extra_files: Vec<(&str, &str)>,
|
||||
source: Value,
|
||||
) -> SkillImportInput {
|
||||
let mut files = vec![SkillInlineFile {
|
||||
path: "SKILL.md".to_string(),
|
||||
encoding: "utf8".to_string(),
|
||||
content: format!(
|
||||
"---\nname: {name}\ndescription: Use for testing Skill imports.\nmetadata:\n tags:\n - tests\n---\n\n# Test\n"
|
||||
),
|
||||
media_type: None,
|
||||
size_bytes: None,
|
||||
sha256: None,
|
||||
}];
|
||||
files.extend(
|
||||
extra_files
|
||||
.into_iter()
|
||||
.map(|(path, content)| SkillInlineFile {
|
||||
path: path.to_string(),
|
||||
encoding: "utf8".to_string(),
|
||||
content: content.to_string(),
|
||||
media_type: None,
|
||||
size_bytes: None,
|
||||
sha256: None,
|
||||
}),
|
||||
);
|
||||
SkillImportInput::InlineFiles { files, source }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installs_inline_skill_and_reads_file() {
|
||||
let root = temp_root("install");
|
||||
let repository = FileSkillRepository::new(root.clone());
|
||||
let result = repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill("test-skill", vec![("references/a.md", "hello")]),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect("install skill");
|
||||
|
||||
assert_eq!(result.action, SkillInstallAction::Installed);
|
||||
let listed = repository.list_skills().await.expect("list skills");
|
||||
assert_eq!(listed[0].name, "test-skill");
|
||||
assert_eq!(listed[0].tags, vec!["tests"]);
|
||||
|
||||
let read = repository
|
||||
.read_skill_file("test-skill", "references/a.md", None)
|
||||
.await
|
||||
.expect("read skill file");
|
||||
assert_eq!(read.content, "hello");
|
||||
assert_eq!(read.resource_ref, "skills/test-skill/references/a.md");
|
||||
|
||||
tokio_fs::remove_dir_all(root).await.expect("cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requires_explicit_replace_on_conflict() {
|
||||
let root = temp_root("conflict");
|
||||
let repository = FileSkillRepository::new(root.clone());
|
||||
repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill("test-skill", vec![("references/a.md", "one")]),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect("install initial");
|
||||
|
||||
let error = repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill("test-skill", vec![("references/a.md", "two")]),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("conflict should fail");
|
||||
assert!(error.to_string().contains("conflict_strategy is required"));
|
||||
|
||||
let replaced = repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill("test-skill", vec![("references/a.md", "two")]),
|
||||
conflict_strategy: Some(SkillInstallConflictStrategy::Replace),
|
||||
})
|
||||
.await
|
||||
.expect("replace skill");
|
||||
assert_eq!(replaced.action, SkillInstallAction::Replaced);
|
||||
|
||||
tokio_fs::remove_dir_all(root).await.expect("cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_skill_when_last_linked_source_is_deleted() {
|
||||
let root = temp_root("delete-source-last");
|
||||
let repository = FileSkillRepository::new(root.clone());
|
||||
repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill_with_source(
|
||||
"test-skill",
|
||||
vec![],
|
||||
json!({"kind":"preset","id":"preset:openai:One","label":"One"}),
|
||||
),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect("install skill");
|
||||
|
||||
let deleted = repository
|
||||
.delete_skills_for_source("preset", "preset:openai:One")
|
||||
.await
|
||||
.expect("delete linked skills");
|
||||
assert_eq!(deleted, vec!["test-skill"]);
|
||||
assert!(repository.list_skills().await.expect("list").is_empty());
|
||||
assert!(!root.join("installed").join("test-skill").exists());
|
||||
|
||||
tokio_fs::remove_dir_all(root).await.expect("cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keeps_skill_until_all_linked_sources_are_deleted() {
|
||||
let root = temp_root("delete-source-shared");
|
||||
let repository = FileSkillRepository::new(root.clone());
|
||||
repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill_with_source(
|
||||
"test-skill",
|
||||
vec![],
|
||||
json!({"kind":"preset","id":"preset:openai:One","label":"One"}),
|
||||
),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect("install first source");
|
||||
repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill_with_source(
|
||||
"test-skill",
|
||||
vec![],
|
||||
json!({"kind":"character","id":"character:Aurelia","label":"Aurelia"}),
|
||||
),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect("link same skill to second source");
|
||||
|
||||
let deleted = repository
|
||||
.delete_skills_for_source("preset", "preset:openai:One")
|
||||
.await
|
||||
.expect("delete first source");
|
||||
assert!(deleted.is_empty());
|
||||
let listed = repository.list_skills().await.expect("list");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].source_refs.len(), 1);
|
||||
assert_eq!(listed[0].source_refs[0].id, "character:Aurelia");
|
||||
|
||||
let deleted = repository
|
||||
.delete_skills_for_source("character", "character:Aurelia")
|
||||
.await
|
||||
.expect("delete second source");
|
||||
assert_eq!(deleted, vec!["test-skill"]);
|
||||
assert!(repository.list_skills().await.expect("list").is_empty());
|
||||
|
||||
tokio_fs::remove_dir_all(root).await.expect("cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacing_skill_drops_previous_source_links() {
|
||||
let root = temp_root("replace-source-links");
|
||||
let repository = FileSkillRepository::new(root.clone());
|
||||
repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill_with_source(
|
||||
"test-skill",
|
||||
vec![("references/a.md", "one")],
|
||||
json!({"kind":"preset","id":"preset:openai:One","label":"One"}),
|
||||
),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect("install first source");
|
||||
repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill_with_source(
|
||||
"test-skill",
|
||||
vec![("references/a.md", "two")],
|
||||
json!({"kind":"character","id":"character:Aurelia","label":"Aurelia"}),
|
||||
),
|
||||
conflict_strategy: Some(SkillInstallConflictStrategy::Replace),
|
||||
})
|
||||
.await
|
||||
.expect("replace skill");
|
||||
|
||||
let deleted = repository
|
||||
.delete_skills_for_source("preset", "preset:openai:One")
|
||||
.await
|
||||
.expect("delete old source");
|
||||
assert!(deleted.is_empty());
|
||||
let listed = repository.list_skills().await.expect("list");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].source_refs.len(), 1);
|
||||
assert_eq!(listed[0].source_refs[0].id, "character:Aurelia");
|
||||
|
||||
tokio_fs::remove_dir_all(root).await.expect("cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_sidecar_schema() {
|
||||
let root = temp_root("sidecar");
|
||||
let repository = FileSkillRepository::new(root.clone());
|
||||
let error = repository
|
||||
.preview_import(inline_skill(
|
||||
"test-skill",
|
||||
vec![(
|
||||
"agents/tauritavern.json",
|
||||
r#"{"version":1,"unexpected":true}"#,
|
||||
)],
|
||||
))
|
||||
.await
|
||||
.expect_err("invalid sidecar should fail");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Invalid agents/tauritavern.json")
|
||||
);
|
||||
|
||||
tokio_fs::remove_dir_all(root).await.expect("cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exported_skill_archive_can_be_reimported() {
|
||||
let root = temp_root("export");
|
||||
let repository = FileSkillRepository::new(root.clone());
|
||||
repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill("test-skill", vec![("references/a.md", "hello")]),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect("install skill");
|
||||
|
||||
let exported = repository
|
||||
.export_skill("test-skill")
|
||||
.await
|
||||
.expect("export skill");
|
||||
let archive_path = root.join("test-skill.ttskill");
|
||||
tokio_fs::write(&archive_path, exported.bytes)
|
||||
.await
|
||||
.expect("write archive");
|
||||
|
||||
let second_root = temp_root("reimport");
|
||||
let second_repository = FileSkillRepository::new(second_root.clone());
|
||||
let preview = second_repository
|
||||
.preview_import(SkillImportInput::ArchiveFile {
|
||||
path: archive_path.to_string_lossy().to_string(),
|
||||
source: json!({"kind": "test"}),
|
||||
})
|
||||
.await
|
||||
.expect("preview exported archive");
|
||||
assert_eq!(preview.skill.name, "test-skill");
|
||||
assert_eq!(preview.conflict.kind, SkillImportConflictKind::New);
|
||||
|
||||
tokio_fs::remove_dir_all(root).await.expect("cleanup");
|
||||
tokio_fs::remove_dir_all(second_root)
|
||||
.await
|
||||
.expect("cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exported_skill_roundtrip_preserves_hash() {
|
||||
let root = temp_root("export-same");
|
||||
let repository = FileSkillRepository::new(root.clone());
|
||||
repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill("test-skill", vec![("references/a.md", "hello")]),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect("install skill");
|
||||
|
||||
let installed_hash = repository.list_skills().await.expect("list skills")[0]
|
||||
.installed_hash
|
||||
.clone();
|
||||
let exported = repository
|
||||
.export_skill("test-skill")
|
||||
.await
|
||||
.expect("export skill");
|
||||
let archive_path = root.join("test-skill.ttskill");
|
||||
tokio_fs::write(&archive_path, exported.bytes)
|
||||
.await
|
||||
.expect("write archive");
|
||||
|
||||
let preview = repository
|
||||
.preview_import(SkillImportInput::ArchiveFile {
|
||||
path: archive_path.to_string_lossy().to_string(),
|
||||
source: json!({"kind": "test"}),
|
||||
})
|
||||
.await
|
||||
.expect("preview exported archive");
|
||||
|
||||
assert_eq!(preview.conflict.kind, SkillImportConflictKind::Same);
|
||||
assert_eq!(preview.skill.installed_hash, installed_hash);
|
||||
|
||||
tokio_fs::remove_dir_all(root).await.expect("cleanup");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn read_rejects_symlink_escape_inside_installed_skill() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = temp_root("symlink-escape");
|
||||
let repository = FileSkillRepository::new(root.clone());
|
||||
repository
|
||||
.install_import(SkillInstallRequest {
|
||||
input: inline_skill("test-skill", vec![("references/a.md", "hello")]),
|
||||
conflict_strategy: None,
|
||||
})
|
||||
.await
|
||||
.expect("install skill");
|
||||
|
||||
let skill_root = root.join("installed").join("test-skill");
|
||||
let outside = root.join("outside");
|
||||
tokio_fs::create_dir_all(&outside)
|
||||
.await
|
||||
.expect("create outside dir");
|
||||
tokio_fs::write(outside.join("a.md"), "outside")
|
||||
.await
|
||||
.expect("write outside file");
|
||||
tokio_fs::remove_dir_all(skill_root.join("references"))
|
||||
.await
|
||||
.expect("remove references dir");
|
||||
symlink(&outside, skill_root.join("references")).expect("create symlink");
|
||||
|
||||
let error = repository
|
||||
.read_skill_file("test-skill", "references/a.md", None)
|
||||
.await
|
||||
.expect_err("symlink escape should fail");
|
||||
assert!(error.to_string().contains("escapes installed directory"));
|
||||
|
||||
tokio_fs::remove_dir_all(root).await.expect("cleanup");
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ pub mod file_prompt_cache_repository;
|
|||
pub mod file_quick_reply_repository;
|
||||
pub mod file_secret_repository;
|
||||
pub mod file_settings_repository;
|
||||
pub mod file_skill_repository;
|
||||
pub mod file_theme_repository;
|
||||
pub mod file_user_directory_repository;
|
||||
pub mod file_user_repository;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use crate::application::dto::character_dto::{
|
|||
use crate::presentation::commands::helpers::{log_command, map_command_error};
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
const SKILL_SOURCE_KIND_CHARACTER: &str = "character";
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_all_characters(
|
||||
shallow: bool,
|
||||
|
|
@ -126,11 +128,25 @@ pub async fn delete_character(
|
|||
) -> Result<(), CommandError> {
|
||||
log_command(format!("delete_character {}", dto.name));
|
||||
|
||||
let name = dto.name.clone();
|
||||
app_state
|
||||
.character_service
|
||||
.delete_character(dto)
|
||||
.await
|
||||
.map_err(map_command_error("Failed to delete character"))
|
||||
.map_err(map_command_error("Failed to delete character"))?;
|
||||
|
||||
app_state
|
||||
.skill_service
|
||||
.delete_skills_for_source(
|
||||
SKILL_SOURCE_KIND_CHARACTER,
|
||||
&character_skill_source_id(&name),
|
||||
)
|
||||
.await
|
||||
.map_err(map_command_error(
|
||||
"Failed to delete Agent Skills linked to character",
|
||||
))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -238,3 +254,7 @@ pub async fn clear_character_cache(
|
|||
.await
|
||||
.map_err(map_command_error("Failed to clear character cache"))
|
||||
}
|
||||
|
||||
fn character_skill_source_id(name: &str) -> String {
|
||||
format!("character:{}", name.trim())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ pub mod registry;
|
|||
pub mod runtime_paths_commands;
|
||||
pub mod secret_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod skill_commands;
|
||||
pub mod stable_diffusion_commands;
|
||||
pub mod theme_commands;
|
||||
pub mod tokenizer_commands;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ use crate::domain::models::preset::PresetType;
|
|||
use crate::infrastructure::logging::logger;
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
const SKILL_SOURCE_KIND_PRESET: &str = "preset";
|
||||
|
||||
/// Save a preset
|
||||
#[tauri::command]
|
||||
pub async fn save_preset(
|
||||
|
|
@ -95,6 +97,27 @@ pub async fn delete_preset(
|
|||
CommandError::from(e)
|
||||
})?;
|
||||
|
||||
let source_id = preset_skill_source_id(preset_type.to_api_id(), &dto.name);
|
||||
let deleted_skills = app_state
|
||||
.skill_service
|
||||
.delete_skills_for_source(SKILL_SOURCE_KIND_PRESET, &source_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
logger::error(&format!(
|
||||
"Failed to delete Agent Skills linked to preset '{}': {}",
|
||||
dto.name, e
|
||||
));
|
||||
CommandError::from(e)
|
||||
})?;
|
||||
if !deleted_skills.is_empty() {
|
||||
logger::info(&format!(
|
||||
"Deleted {} Agent Skill(s) linked to preset '{}': {}",
|
||||
deleted_skills.len(),
|
||||
dto.name,
|
||||
deleted_skills.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
logger::info(&format!("Preset deleted successfully: {}", dto.name));
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -217,6 +240,18 @@ pub async fn delete_openai_preset(
|
|||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let source_id = preset_skill_source_id(PresetType::OpenAI.to_api_id(), &dto.name);
|
||||
if let Err(e) = app_state
|
||||
.skill_service
|
||||
.delete_skills_for_source(SKILL_SOURCE_KIND_PRESET, &source_id)
|
||||
.await
|
||||
{
|
||||
logger::error(&format!(
|
||||
"Failed to delete Agent Skills linked to OpenAI preset '{}': {}",
|
||||
dto.name, e
|
||||
));
|
||||
return Ok(DeleteOpenAIPresetResponseDto::error());
|
||||
}
|
||||
logger::info(&format!("OpenAI preset deleted successfully: {}", dto.name));
|
||||
Ok(DeleteOpenAIPresetResponseDto::success())
|
||||
}
|
||||
|
|
@ -227,6 +262,10 @@ pub async fn delete_openai_preset(
|
|||
}
|
||||
}
|
||||
|
||||
fn preset_skill_source_id(api_id: &str, name: &str) -> String {
|
||||
format!("preset:{}:{}", api_id.trim(), name.trim())
|
||||
}
|
||||
|
||||
/// List presets of a specific type
|
||||
#[tauri::command]
|
||||
pub async fn list_presets(
|
||||
|
|
|
|||
|
|
@ -241,6 +241,12 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
|
|||
super::agent_commands::read_agent_workspace_file,
|
||||
super::agent_commands::prepare_agent_run_commit,
|
||||
super::agent_commands::finalize_agent_run_commit,
|
||||
// Agent Skill commands
|
||||
super::skill_commands::list_skills,
|
||||
super::skill_commands::preview_skill_import,
|
||||
super::skill_commands::install_skill_import,
|
||||
super::skill_commands::read_skill_file,
|
||||
super::skill_commands::export_skill,
|
||||
// Chat completion commands
|
||||
super::chat_completion_commands::get_chat_completions_status,
|
||||
super::chat_completion_commands::generate_chat_completion,
|
||||
|
|
|
|||
99
src-tauri/src/presentation/commands/skill_commands.rs
Normal file
99
src-tauri/src/presentation/commands/skill_commands.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::domain::models::skill::{
|
||||
SkillImportInput, SkillImportPreview, SkillIndexEntry, SkillInstallRequest, SkillInstallResult,
|
||||
SkillReadResult,
|
||||
};
|
||||
use crate::presentation::commands::helpers::{log_command, map_command_error};
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillExportPayload {
|
||||
pub file_name: String,
|
||||
pub content_base64: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_skills(
|
||||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<Vec<SkillIndexEntry>, CommandError> {
|
||||
log_command("list_skills");
|
||||
|
||||
app_state
|
||||
.skill_service
|
||||
.list_skills()
|
||||
.await
|
||||
.map_err(map_command_error("Failed to list Agent Skills"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn preview_skill_import(
|
||||
input: SkillImportInput,
|
||||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<SkillImportPreview, CommandError> {
|
||||
log_command("preview_skill_import");
|
||||
|
||||
app_state
|
||||
.skill_service
|
||||
.preview_import(input)
|
||||
.await
|
||||
.map_err(map_command_error("Failed to preview Agent Skill import"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_skill_import(
|
||||
request: SkillInstallRequest,
|
||||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<SkillInstallResult, CommandError> {
|
||||
log_command("install_skill_import");
|
||||
|
||||
app_state
|
||||
.skill_service
|
||||
.install_import(request)
|
||||
.await
|
||||
.map_err(map_command_error("Failed to install Agent Skill"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_skill_file(
|
||||
name: String,
|
||||
path: String,
|
||||
max_chars: Option<usize>,
|
||||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<SkillReadResult, CommandError> {
|
||||
log_command(format!("read_skill_file {}/{}", name, path));
|
||||
|
||||
app_state
|
||||
.skill_service
|
||||
.read_skill_file(&name, &path, max_chars)
|
||||
.await
|
||||
.map_err(map_command_error("Failed to read Agent Skill file"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_skill(
|
||||
name: String,
|
||||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<SkillExportPayload, CommandError> {
|
||||
log_command(format!("export_skill {}", name));
|
||||
|
||||
let exported = app_state
|
||||
.skill_service
|
||||
.export_skill(&name)
|
||||
.await
|
||||
.map_err(map_command_error("Failed to export Agent Skill"))?;
|
||||
|
||||
Ok(SkillExportPayload {
|
||||
file_name: exported.file_name,
|
||||
content_base64: BASE64_STANDARD.encode(exported.bytes),
|
||||
sha256: exported.sha256,
|
||||
})
|
||||
}
|
||||
|
|
@ -11125,6 +11125,48 @@ function selectImportedChar(charId) {
|
|||
select_rm_info('char_import_no_toast', charId, oldSelectedChar);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ avatarFileName: string; label: string }} options
|
||||
*/
|
||||
async function maybePromptForImportedCharacterSkills({ avatarFileName, label }) {
|
||||
const hostAbi = window.__TAURITAVERN__;
|
||||
if (!hostAbi) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!hostAbi.api?.skill) {
|
||||
throw new Error('TauriTavern Agent Skill API is not available');
|
||||
}
|
||||
|
||||
const { maybePromptForCharacterEmbeddedSkills } = await import('./scripts/tauri/agent-skills/embedded-import.js');
|
||||
await maybePromptForCharacterEmbeddedSkills({
|
||||
avatarFileName,
|
||||
label,
|
||||
loadCharacter: async () => {
|
||||
const response = await fetch('/api/characters/get', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ avatar_url: avatarFileName }),
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const details = String(await response.text()).trim();
|
||||
const reason = details || response.statusText || `HTTP ${response.status}`;
|
||||
throw new Error(`Failed to read imported character for Agent Skill scan: ${reason}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start Agent Skill import prompt for character', error);
|
||||
const message = error instanceof Error ? error.message : String(error || t`Unknown error`);
|
||||
toastr.error(message, t`Agent Skill import failed`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a character from a file.
|
||||
* @param {File} file File to import
|
||||
|
|
@ -11206,6 +11248,10 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
|
|||
await importCharactersTags([avatarFileName]);
|
||||
selectImportedChar(data.file_name);
|
||||
}
|
||||
await maybePromptForImportedCharacterSkills({
|
||||
avatarFileName,
|
||||
label: String(data.file_name).replace('.png', ''),
|
||||
});
|
||||
return avatarFileName;
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -11518,6 +11564,8 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
|
|||
worldsToDelete.add(primaryWorldName);
|
||||
}
|
||||
|
||||
const { clearCharacterSkillImportReminders } = await import('./scripts/tauri/agent-skills/reminders.js');
|
||||
clearCharacterSkillImportReminders(character.avatar);
|
||||
accountStorage.removeItem(`AlertWI_${character.avatar}`);
|
||||
accountStorage.removeItem(`AlertRegex_${character.avatar}`);
|
||||
accountStorage.removeItem(`mediaWarningShown:${character.avatar}`);
|
||||
|
|
|
|||
|
|
@ -4761,6 +4761,15 @@ async function onPresetImportFileChange(e) {
|
|||
option.innerText = data.name;
|
||||
$('#settings_preset_openai').append(option).trigger('change');
|
||||
}
|
||||
|
||||
try {
|
||||
const { maybePromptForPresetEmbeddedSkills } = await import('./tauri/agent-skills/embedded-import.js');
|
||||
await maybePromptForPresetEmbeddedSkills({ apiId: 'openai', name: data.name, preset: presetBody });
|
||||
} catch (error) {
|
||||
console.error('Failed to start Agent Skill import prompt for OpenAI preset', error);
|
||||
const message = error instanceof Error ? error.message : String(error || t`Unknown error`);
|
||||
toastr.error(message, t`Agent Skill import failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function onExportPresetClick() {
|
||||
|
|
@ -4889,6 +4898,8 @@ async function onDeletePresetClick() {
|
|||
toastr.warning(t`Preset was not deleted from server`);
|
||||
} else {
|
||||
toastr.success(t`Preset deleted`);
|
||||
const { clearPresetSkillImportReminders } = await import('./tauri/agent-skills/reminders.js');
|
||||
clearPresetSkillImportReminders('openai', nameToDelete);
|
||||
await eventSource.emit(event_types.PRESET_DELETED, { apiId: 'openai', name: nameToDelete });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1122,7 +1122,16 @@ export async function initPresetManager() {
|
|||
await presetManager.savePreset(name, data);
|
||||
const successToast = !presetManager.isAdvancedFormatting() ? t`Preset imported` : t`Template imported`;
|
||||
toastr.success(successToast);
|
||||
e.target.value = null;
|
||||
try {
|
||||
const { maybePromptForPresetEmbeddedSkills } = await import('./tauri/agent-skills/embedded-import.js');
|
||||
await maybePromptForPresetEmbeddedSkills({ apiId, name, preset: data });
|
||||
} catch (error) {
|
||||
console.error('Failed to start Agent Skill import prompt for preset', error);
|
||||
const message = error instanceof Error ? error.message : String(error || t`Unknown error`);
|
||||
toastr.error(message, t`Agent Skill import failed`);
|
||||
} finally {
|
||||
e.target.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('click', '[data-preset-manager-delete]', async function () {
|
||||
|
|
@ -1146,6 +1155,8 @@ export async function initPresetManager() {
|
|||
if (result) {
|
||||
const successToast = !presetManager.isAdvancedFormatting() ? t`Preset deleted` : t`Template deleted`;
|
||||
toastr.success(successToast);
|
||||
const { clearPresetSkillImportReminders } = await import('./tauri/agent-skills/reminders.js');
|
||||
clearPresetSkillImportReminders(apiId, name);
|
||||
await eventSource.emit(event_types.PRESET_DELETED, { apiId, name });
|
||||
} else {
|
||||
const warningToast = !presetManager.isAdvancedFormatting() ? t`Preset was not deleted from server` : t`Template was not deleted from server`;
|
||||
|
|
|
|||
655
src/scripts/tauri/agent-skills/embedded-import.js
Normal file
655
src/scripts/tauri/agent-skills/embedded-import.js
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
// @ts-check
|
||||
|
||||
import { t } from '../../i18n.js';
|
||||
import { POPUP_RESULT, POPUP_TYPE, Popup } from '../../popup.js';
|
||||
import { SURFACE, applySurface } from '../../tauritavern/layout-kit.js';
|
||||
import { buildSkillImportReminderKey, hasSkillImportReminder, setSkillImportReminder } from './reminders.js';
|
||||
|
||||
const EMBEDDED_SKILLS_VERSION = 1;
|
||||
const INLINE_FILES_BUNDLE_FORMAT = 'inline-files-v1';
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function requirePlainObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Embedded Agent Skill payload must be an object');
|
||||
}
|
||||
|
||||
return /** @type {Record<string, any>} */ (value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
*/
|
||||
function requireNonEmptyString(value, label) {
|
||||
const resolved = String(value || '').trim();
|
||||
if (!resolved) {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function normalizeEncoding(value) {
|
||||
const encoding = String(value || 'utf8').trim().toLowerCase();
|
||||
if (!['utf8', 'utf-8', 'base64'].includes(encoding)) {
|
||||
throw new Error(`Unsupported embedded Agent Skill file encoding: ${encoding}`);
|
||||
}
|
||||
|
||||
return encoding;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
*/
|
||||
function normalizeOptionalNonNegativeInteger(value, label) {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number) || number < 0) {
|
||||
throw new Error(`${label} must be a non-negative integer`);
|
||||
}
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {{ kind: string; id: string; label: string }} fallbackSource
|
||||
*/
|
||||
function normalizeEmbeddedSource(value, fallbackSource) {
|
||||
const source = value === null || value === undefined ? {} : requirePlainObject(value);
|
||||
const kind = requireNonEmptyString(fallbackSource.kind || source.kind, 'source.kind');
|
||||
const id = requireNonEmptyString(fallbackSource.id || source.id, 'source.id');
|
||||
return {
|
||||
...source,
|
||||
kind,
|
||||
id,
|
||||
label: String(source.label || fallbackSource.label || source.kind || fallbackSource.kind).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function normalizeEmbeddedFile(value) {
|
||||
const file = requirePlainObject(value);
|
||||
|
||||
/** @type {Record<string, any>} */
|
||||
const output = {
|
||||
path: requireNonEmptyString(file.path, 'skill file path'),
|
||||
encoding: normalizeEncoding(file.encoding),
|
||||
content: file.content,
|
||||
};
|
||||
|
||||
if (typeof output.content !== 'string') {
|
||||
throw new Error('skill file content must be a string');
|
||||
}
|
||||
|
||||
const mediaType = String(file.mediaType || '').trim();
|
||||
if (mediaType) {
|
||||
output.mediaType = mediaType;
|
||||
}
|
||||
|
||||
const sizeBytes = normalizeOptionalNonNegativeInteger(file.sizeBytes, 'sizeBytes');
|
||||
if (sizeBytes !== undefined) {
|
||||
output.sizeBytes = sizeBytes;
|
||||
}
|
||||
|
||||
const sha256 = String(file.sha256 || '').trim();
|
||||
if (sha256) {
|
||||
output.sha256 = sha256;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {number} index
|
||||
* @param {{ kind: string; id: string; label: string }} fallbackSource
|
||||
*/
|
||||
function normalizeEmbeddedItem(value, index, fallbackSource) {
|
||||
const item = requirePlainObject(value);
|
||||
const bundleFormat = requireNonEmptyString(item.bundleFormat, `items[${index}].bundleFormat`);
|
||||
if (bundleFormat !== INLINE_FILES_BUNDLE_FORMAT) {
|
||||
throw new Error(`Unsupported embedded Agent Skill bundle format: ${bundleFormat}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(item.files) || item.files.length === 0) {
|
||||
throw new Error(`Embedded Agent Skill item ${index + 1} requires at least one file`);
|
||||
}
|
||||
|
||||
const source = normalizeEmbeddedSource(item.source, fallbackSource);
|
||||
return {
|
||||
index,
|
||||
input: {
|
||||
kind: 'inlineFiles',
|
||||
files: item.files.map(normalizeEmbeddedFile),
|
||||
source,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} embedded
|
||||
* @param {{ kind: string; id: string; label: string }} fallbackSource
|
||||
*/
|
||||
function normalizeEmbeddedSkills(embedded, fallbackSource) {
|
||||
if (embedded === null || embedded === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const payload = requirePlainObject(embedded);
|
||||
if (Number(payload.version) !== EMBEDDED_SKILLS_VERSION) {
|
||||
throw new Error(`Unsupported embedded Agent Skill schema version: ${payload.version}`);
|
||||
}
|
||||
|
||||
if (payload.items === null || payload.items === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload.items)) {
|
||||
throw new Error('Embedded Agent Skill items must be an array');
|
||||
}
|
||||
|
||||
return payload.items.map((item, index) => normalizeEmbeddedItem(item, index, fallbackSource));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} preset
|
||||
* @param {{ apiId: string; name: string }} source
|
||||
*/
|
||||
export function extractPresetEmbeddedSkills(preset, source) {
|
||||
const label = String(source.name || '').trim() || t`Imported preset`;
|
||||
return normalizeEmbeddedSkills(preset?.extensions?.tauritavern?.skills, {
|
||||
kind: 'preset',
|
||||
id: buildPresetSkillSourceId(source.apiId, label),
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} character
|
||||
* @param {{ label: string; avatarFileName?: string; characterId?: string }} source
|
||||
*/
|
||||
export function extractCharacterEmbeddedSkills(character, source) {
|
||||
const embedded = character?.data?.extensions?.tauritavern?.skills
|
||||
?? character?.extensions?.tauritavern?.skills;
|
||||
const label = String(source.label || character?.name || character?.data?.name || '').trim() || t`Imported character`;
|
||||
return normalizeEmbeddedSkills(embedded, {
|
||||
kind: 'character',
|
||||
id: buildCharacterSkillSourceId(source.avatarFileName || source.characterId || label),
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} apiId
|
||||
* @param {unknown} name
|
||||
*/
|
||||
function buildPresetSkillSourceId(apiId, name) {
|
||||
return `preset:${requireNonEmptyString(apiId, 'apiId')}:${requireNonEmptyString(name, 'preset name')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function buildCharacterSkillSourceId(value) {
|
||||
const characterId = requireNonEmptyString(value, 'character id').replace(/\.png$/i, '');
|
||||
return `character:${characterId}`;
|
||||
}
|
||||
|
||||
function getSkillApi() {
|
||||
const hostAbi = window.__TAURITAVERN__;
|
||||
if (!hostAbi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skillApi = hostAbi.api?.skill;
|
||||
if (!skillApi || typeof skillApi !== 'object') {
|
||||
throw new Error('TauriTavern Agent Skill API is not available');
|
||||
}
|
||||
|
||||
if (typeof skillApi.previewImport !== 'function' || typeof skillApi.installImport !== 'function') {
|
||||
throw new Error('TauriTavern Agent Skill API is incomplete');
|
||||
}
|
||||
|
||||
return skillApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function stableStringify(value) {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
const primitive = JSON.stringify(value);
|
||||
return primitive === undefined ? 'undefined' : primitive;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(',')}]`;
|
||||
}
|
||||
|
||||
const object = /** @type {Record<string, unknown>} */ (value);
|
||||
return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
function fnv1aHex(text) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} bytes
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} parent
|
||||
* @param {string} tagName
|
||||
* @param {string} text
|
||||
*/
|
||||
function appendText(parent, tagName, text) {
|
||||
const element = document.createElement(tagName);
|
||||
element.textContent = text;
|
||||
parent.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} preview
|
||||
*/
|
||||
function getConflictLabel(preview) {
|
||||
const kind = String(preview?.conflict?.kind || '');
|
||||
if (kind === 'new') {
|
||||
return t`New`;
|
||||
}
|
||||
if (kind === 'same') {
|
||||
return t`Already installed`;
|
||||
}
|
||||
if (kind === 'different') {
|
||||
return t`Different installed version`;
|
||||
}
|
||||
|
||||
return kind || t`Unknown`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ preview: any; item: any }[]} previews
|
||||
* @param {string} sourceLabel
|
||||
*/
|
||||
function buildPreviewPopupContent(previews, sourceLabel) {
|
||||
const root = document.createElement('div');
|
||||
root.classList.add('tauritavern-agent-skill-import');
|
||||
|
||||
appendText(root, 'h3', t`Import embedded Agent Skills?`);
|
||||
appendText(root, 'p', t`Source: ${sourceLabel}`);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.style.display = 'flex';
|
||||
list.style.flexDirection = 'column';
|
||||
list.style.gap = '0.75rem';
|
||||
root.append(list);
|
||||
|
||||
for (const entry of previews) {
|
||||
const preview = entry.preview;
|
||||
const skill = preview?.skill || {};
|
||||
const conflictKind = String(preview?.conflict?.kind || '');
|
||||
|
||||
const item = document.createElement('section');
|
||||
item.style.border = '1px solid var(--SmartThemeBorderColor)';
|
||||
item.style.borderRadius = '6px';
|
||||
item.style.padding = '0.75rem';
|
||||
item.style.display = 'flex';
|
||||
item.style.flexDirection = 'column';
|
||||
item.style.gap = '0.45rem';
|
||||
list.append(item);
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.display = 'flex';
|
||||
header.style.gap = '0.6rem';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.justifyContent = 'space-between';
|
||||
item.append(header);
|
||||
|
||||
const title = appendText(header, 'strong', String(skill.displayName || skill.name || t`Unnamed Skill`));
|
||||
title.style.overflowWrap = 'anywhere';
|
||||
|
||||
const conflict = appendText(header, 'span', getConflictLabel(preview));
|
||||
conflict.style.fontSize = '0.9em';
|
||||
conflict.style.opacity = '0.8';
|
||||
|
||||
const description = appendText(item, 'div', String(skill.description || ''));
|
||||
description.style.overflowWrap = 'anywhere';
|
||||
|
||||
const totalBytes = Number(skill.totalBytes || 0);
|
||||
const metadata = [
|
||||
t`${Number(skill.fileCount || 0)} files`,
|
||||
formatBytes(totalBytes),
|
||||
];
|
||||
if (skill.hasScripts) {
|
||||
metadata.push(t`scripts stored only`);
|
||||
}
|
||||
if (skill.hasBinary) {
|
||||
metadata.push(t`binary files`);
|
||||
}
|
||||
|
||||
const meta = appendText(item, 'div', metadata.join(' · '));
|
||||
meta.style.fontSize = '0.9em';
|
||||
meta.style.opacity = '0.8';
|
||||
|
||||
if (Array.isArray(preview.warnings) && preview.warnings.length > 0) {
|
||||
const warnings = document.createElement('ul');
|
||||
warnings.style.margin = '0.25rem 0 0 1rem';
|
||||
for (const warning of preview.warnings) {
|
||||
appendText(warnings, 'li', String(warning));
|
||||
}
|
||||
item.append(warnings);
|
||||
}
|
||||
|
||||
const details = document.createElement('details');
|
||||
const summary = appendText(details, 'summary', t`Files`);
|
||||
summary.style.cursor = 'pointer';
|
||||
const files = document.createElement('ul');
|
||||
files.style.margin = '0.25rem 0 0 1rem';
|
||||
for (const file of Array.isArray(preview.files) ? preview.files : []) {
|
||||
appendText(files, 'li', `${String(file.path || '')} (${formatBytes(Number(file.sizeBytes || 0))})`);
|
||||
}
|
||||
details.append(files);
|
||||
item.append(details);
|
||||
|
||||
const action = document.createElement('div');
|
||||
action.style.display = 'flex';
|
||||
action.style.justifyContent = 'flex-end';
|
||||
action.style.alignItems = 'center';
|
||||
action.style.gap = '0.5rem';
|
||||
item.append(action);
|
||||
|
||||
if (conflictKind === 'new') {
|
||||
const label = document.createElement('label');
|
||||
label.classList.add('checkbox_label');
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = true;
|
||||
checkbox.dataset.skillImportIndex = String(entry.item.index);
|
||||
label.append(checkbox);
|
||||
const text = document.createElement('span');
|
||||
text.textContent = t`Import`;
|
||||
label.append(text);
|
||||
action.append(label);
|
||||
} else if (conflictKind === 'different') {
|
||||
const select = document.createElement('select');
|
||||
select.classList.add('text_pole');
|
||||
select.dataset.skillConflictIndex = String(entry.item.index);
|
||||
|
||||
const skip = document.createElement('option');
|
||||
skip.value = 'skip';
|
||||
skip.textContent = t`Skip`;
|
||||
select.append(skip);
|
||||
|
||||
const replace = document.createElement('option');
|
||||
replace.value = 'replace';
|
||||
replace.textContent = t`Replace`;
|
||||
select.append(replace);
|
||||
|
||||
action.append(select);
|
||||
} else {
|
||||
appendText(action, 'span', t`Link source`);
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
* @param {{ preview: any; item: any }[]} previews
|
||||
*/
|
||||
function collectImportDecisions(root, previews) {
|
||||
const decisions = [];
|
||||
for (const entry of previews) {
|
||||
const conflictKind = String(entry.preview?.conflict?.kind || '');
|
||||
if (conflictKind === 'new') {
|
||||
const checkbox = root.querySelector(`[data-skill-import-index="${entry.item.index}"]`);
|
||||
if (checkbox instanceof HTMLInputElement && checkbox.checked) {
|
||||
decisions.push({ item: entry.item });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (conflictKind === 'different') {
|
||||
const select = root.querySelector(`[data-skill-conflict-index="${entry.item.index}"]`);
|
||||
if (select instanceof HTMLSelectElement && select.value === 'replace') {
|
||||
decisions.push({ item: entry.item, conflictStrategy: 'replace' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (conflictKind === 'same') {
|
||||
decisions.push({ item: entry.item });
|
||||
}
|
||||
}
|
||||
|
||||
return decisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ preview: any; item: any }[]} previews
|
||||
* @param {string} sourceLabel
|
||||
*/
|
||||
async function showImportPopup(previews, sourceLabel) {
|
||||
const root = buildPreviewPopupContent(previews, sourceLabel);
|
||||
const hasImportableSkill = hasImportablePreviews(previews);
|
||||
|
||||
const popup = new Popup(root, hasImportableSkill ? POPUP_TYPE.CONFIRM : POPUP_TYPE.TEXT, '', {
|
||||
okButton: hasImportableSkill ? t`Import or link selected Skills` : t`OK`,
|
||||
cancelButton: hasImportableSkill ? t`Not now` : false,
|
||||
wide: true,
|
||||
allowVerticalScrolling: true,
|
||||
leftAlign: true,
|
||||
});
|
||||
popup.dlg.classList.add('tauritavern-agent-skill-import-popup');
|
||||
applySurface(popup.dlg, SURFACE.FullscreenWindow);
|
||||
|
||||
const result = await popup.show();
|
||||
if (result !== POPUP_RESULT.AFFIRMATIVE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasImportableSkill) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collectImportDecisions(root, previews);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ preview: any; item: any }[]} previews
|
||||
*/
|
||||
function hasImportablePreviews(previews) {
|
||||
return previews.some(entry => {
|
||||
const kind = String(entry.preview?.conflict?.kind || '');
|
||||
return kind === 'new' || kind === 'different' || kind === 'same';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {any}
|
||||
*/
|
||||
function getToastr() {
|
||||
return /** @type {any} */ (toastr);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any[]} results
|
||||
*/
|
||||
function reportInstallResults(results) {
|
||||
const installed = results.filter(result => result?.action === 'installed').length;
|
||||
const replaced = results.filter(result => result?.action === 'replaced').length;
|
||||
const unchanged = results.filter(result => result?.action === 'already_installed').length;
|
||||
|
||||
const parts = [];
|
||||
if (installed) {
|
||||
parts.push(t`${installed} installed`);
|
||||
}
|
||||
if (replaced) {
|
||||
parts.push(t`${replaced} replaced`);
|
||||
}
|
||||
if (unchanged) {
|
||||
parts.push(t`${unchanged} unchanged`);
|
||||
}
|
||||
|
||||
getToastr().success(parts.join(', ') || t`Agent Skills imported`, t`Agent Skills`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} error
|
||||
*/
|
||||
function reportEmbeddedSkillError(error) {
|
||||
console.error('Agent Skill embedded import failed', error);
|
||||
const message = error instanceof Error ? error.message : String(error || t`Unknown error`);
|
||||
getToastr().error(message, t`Agent Skill import failed`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ items: any[]; sourceLabel: string; storageKey: string }} options
|
||||
*/
|
||||
async function previewAndPromptEmbeddedSkills({ items, sourceLabel, storageKey }) {
|
||||
const skillApi = getSkillApi();
|
||||
if (!skillApi || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSkillImportReminder(storageKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previews = [];
|
||||
for (const item of items) {
|
||||
previews.push({
|
||||
item,
|
||||
preview: await skillApi.previewImport(item.input),
|
||||
});
|
||||
}
|
||||
|
||||
const decisions = await showImportPopup(previews, sourceLabel);
|
||||
if (decisions === null) {
|
||||
setSkillImportReminder(storageKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decisions.length === 0) {
|
||||
setSkillImportReminder(storageKey);
|
||||
if (hasImportablePreviews(previews)) {
|
||||
getToastr().info(t`No Agent Skills imported`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const decision of decisions) {
|
||||
const request = /** @type {any} */ ({
|
||||
input: decision.item.input,
|
||||
});
|
||||
if (decision.conflictStrategy !== undefined) {
|
||||
request.conflictStrategy = decision.conflictStrategy;
|
||||
}
|
||||
results.push(await skillApi.installImport(request));
|
||||
}
|
||||
|
||||
reportInstallResults(results);
|
||||
setSkillImportReminder(storageKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ apiId: string; name: string; preset: any }} options
|
||||
*/
|
||||
export async function maybePromptForPresetEmbeddedSkills({ apiId, name, preset }) {
|
||||
try {
|
||||
if (!getSkillApi()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const embedded = preset?.extensions?.tauritavern?.skills;
|
||||
const items = extractPresetEmbeddedSkills(preset, { apiId, name });
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = fnv1aHex(stableStringify(embedded));
|
||||
const storageKey = buildSkillImportReminderKey(['AlertSkillPreset', apiId, name, hash]);
|
||||
await previewAndPromptEmbeddedSkills({
|
||||
items,
|
||||
sourceLabel: String(name || t`Imported preset`),
|
||||
storageKey,
|
||||
});
|
||||
} catch (error) {
|
||||
reportEmbeddedSkillError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ avatarFileName: string; label: string; loadCharacter: () => Promise<any> }} options
|
||||
*/
|
||||
export async function maybePromptForCharacterEmbeddedSkills({ avatarFileName, label, loadCharacter }) {
|
||||
try {
|
||||
if (!getSkillApi()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof loadCharacter !== 'function') {
|
||||
throw new Error('loadCharacter is required');
|
||||
}
|
||||
|
||||
const character = await loadCharacter();
|
||||
const embedded = character?.data?.extensions?.tauritavern?.skills
|
||||
?? character?.extensions?.tauritavern?.skills;
|
||||
const items = extractCharacterEmbeddedSkills(character, { label, avatarFileName });
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = fnv1aHex(stableStringify(embedded));
|
||||
const storageKey = buildSkillImportReminderKey(['AlertSkillCharacter', avatarFileName, hash]);
|
||||
await previewAndPromptEmbeddedSkills({
|
||||
items,
|
||||
sourceLabel: String(label || character?.name || character?.data?.name || t`Imported character`),
|
||||
storageKey,
|
||||
});
|
||||
} catch (error) {
|
||||
reportEmbeddedSkillError(error);
|
||||
}
|
||||
}
|
||||
57
src/scripts/tauri/agent-skills/reminders.js
Normal file
57
src/scripts/tauri/agent-skills/reminders.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// @ts-check
|
||||
|
||||
import { accountStorage } from '../../util/AccountStorage.js';
|
||||
|
||||
export const SKILL_IMPORT_REMINDER_VALUE = '1';
|
||||
|
||||
/**
|
||||
* @param {unknown[]} parts
|
||||
*/
|
||||
export function buildSkillImportReminderKey(parts) {
|
||||
const sanitizedParts = parts
|
||||
.map(part => String(part || 'unknown').replace(/[^\w.-]+/g, '_').slice(0, 96))
|
||||
.filter(Boolean);
|
||||
return sanitizedParts.join('_');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function hasSkillImportReminder(key) {
|
||||
return accountStorage.getItem(key) === SKILL_IMPORT_REMINDER_VALUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
export function setSkillImportReminder(key) {
|
||||
accountStorage.setItem(key, SKILL_IMPORT_REMINDER_VALUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown[]} parts
|
||||
*/
|
||||
function clearSkillImportReminderPrefix(parts) {
|
||||
const prefix = buildSkillImportReminderKey(parts);
|
||||
const state = accountStorage.getState();
|
||||
for (const key of Object.keys(state)) {
|
||||
if (key === prefix || key.startsWith(`${prefix}_`)) {
|
||||
accountStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} apiId
|
||||
* @param {unknown} name
|
||||
*/
|
||||
export function clearPresetSkillImportReminders(apiId, name) {
|
||||
clearSkillImportReminderPrefix(['AlertSkillPreset', apiId, name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} avatarFileName
|
||||
*/
|
||||
export function clearCharacterSkillImportReminders(avatarFileName) {
|
||||
clearSkillImportReminderPrefix(['AlertSkillCharacter', avatarFileName]);
|
||||
}
|
||||
229
src/tauri/main/api/skill.js
Normal file
229
src/tauri/main/api/skill.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
*/
|
||||
function requireNonEmptyString(value, label) {
|
||||
const resolved = String(value || '').trim();
|
||||
if (!resolved) {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function requirePlainObject(value, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`);
|
||||
}
|
||||
|
||||
return /** @type {Record<string, any>} */ (value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function normalizeSource(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return requirePlainObject(value, 'source');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function normalizeEncoding(value) {
|
||||
const encoding = String(value || 'utf8').trim().toLowerCase();
|
||||
if (!['utf8', 'utf-8', 'base64'].includes(encoding)) {
|
||||
throw new Error(`Unsupported skill file encoding: ${encoding}`);
|
||||
}
|
||||
|
||||
return encoding;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} label
|
||||
*/
|
||||
function normalizeOptionalNonNegativeInteger(value, label) {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const number = Number(value);
|
||||
if (!Number.isInteger(number) || number < 0) {
|
||||
throw new Error(`${label} must be a non-negative integer`);
|
||||
}
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function normalizeSkillInlineFile(value) {
|
||||
const file = requirePlainObject(value, 'skill inline file');
|
||||
/** @type {Record<string, any>} */
|
||||
const output = {
|
||||
path: requireNonEmptyString(file.path, 'skill file path'),
|
||||
encoding: normalizeEncoding(file.encoding),
|
||||
content: String(file.content ?? ''),
|
||||
};
|
||||
|
||||
if (typeof file.content !== 'string') {
|
||||
throw new Error('skill file content must be a string');
|
||||
}
|
||||
|
||||
const mediaType = String(file.mediaType || '').trim();
|
||||
if (mediaType) {
|
||||
output.mediaType = mediaType;
|
||||
}
|
||||
|
||||
const sizeBytes = normalizeOptionalNonNegativeInteger(file.sizeBytes, 'sizeBytes');
|
||||
if (sizeBytes !== undefined) {
|
||||
output.sizeBytes = sizeBytes;
|
||||
}
|
||||
|
||||
const sha256 = String(file.sha256 || '').trim();
|
||||
if (sha256) {
|
||||
output.sha256 = sha256;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function normalizeSkillImportInput(value) {
|
||||
const input = requirePlainObject(value, 'skill import input');
|
||||
const kind = requireNonEmptyString(input.kind, 'skill import kind');
|
||||
|
||||
if (kind === 'inlineFiles') {
|
||||
if (!Array.isArray(input.files) || input.files.length === 0) {
|
||||
throw new Error('inlineFiles skill import requires at least one file');
|
||||
}
|
||||
|
||||
return {
|
||||
kind,
|
||||
files: input.files.map(normalizeSkillInlineFile),
|
||||
source: normalizeSource(input.source),
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'directory' || kind === 'archiveFile') {
|
||||
return {
|
||||
kind,
|
||||
path: requireNonEmptyString(input.path, 'skill import path'),
|
||||
source: normalizeSource(input.source),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported skill import kind: ${kind}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function normalizeConflictStrategy(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const strategy = requireNonEmptyString(value, 'conflictStrategy');
|
||||
if (strategy !== 'skip' && strategy !== 'replace') {
|
||||
throw new Error(`Unsupported skill conflict strategy: ${strategy}`);
|
||||
}
|
||||
|
||||
return strategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function normalizeSkillInstallRequest(value) {
|
||||
const request = requirePlainObject(value, 'skill install request');
|
||||
/** @type {Record<string, any>} */
|
||||
const output = {
|
||||
input: normalizeSkillImportInput(request.input),
|
||||
};
|
||||
|
||||
const conflictStrategy = normalizeConflictStrategy(request.conflictStrategy);
|
||||
if (conflictStrategy !== undefined) {
|
||||
output.conflictStrategy = conflictStrategy;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ safeInvoke: (command: string, args?: any) => Promise<any> }} deps
|
||||
*/
|
||||
function createSkillApi({ safeInvoke }) {
|
||||
async function list() {
|
||||
return safeInvoke('list_skills');
|
||||
}
|
||||
|
||||
async function previewImport(input) {
|
||||
return safeInvoke('preview_skill_import', {
|
||||
input: normalizeSkillImportInput(input),
|
||||
});
|
||||
}
|
||||
|
||||
async function installImport(request) {
|
||||
return safeInvoke('install_skill_import', {
|
||||
request: normalizeSkillInstallRequest(request),
|
||||
});
|
||||
}
|
||||
|
||||
async function readFile(options) {
|
||||
const name = requireNonEmptyString(options?.name, 'skill name');
|
||||
const path = requireNonEmptyString(options?.path, 'skill file path');
|
||||
const maxChars = normalizeOptionalNonNegativeInteger(options?.maxChars, 'maxChars');
|
||||
return safeInvoke('read_skill_file', { name, path, maxChars });
|
||||
}
|
||||
|
||||
async function exportSkill(options) {
|
||||
const name = requireNonEmptyString(options?.name, 'skill name');
|
||||
return safeInvoke('export_skill', { name });
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
previewImport,
|
||||
installImport,
|
||||
readFile,
|
||||
export: exportSkill,
|
||||
exportSkill,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} context
|
||||
*/
|
||||
export function installSkillApi(context) {
|
||||
const hostWindow = /** @type {any} */ (window);
|
||||
const hostAbi = hostWindow.__TAURITAVERN__;
|
||||
if (!hostAbi || typeof hostAbi !== 'object') {
|
||||
throw new Error('Host ABI __TAURITAVERN__ is missing');
|
||||
}
|
||||
|
||||
const safeInvoke = context?.safeInvoke;
|
||||
if (typeof safeInvoke !== 'function') {
|
||||
throw new Error('Tauri main context safeInvoke is missing');
|
||||
}
|
||||
|
||||
if (!hostAbi.api || typeof hostAbi.api !== 'object') {
|
||||
hostAbi.api = {};
|
||||
}
|
||||
|
||||
hostAbi.api.skill = createSkillApi({ safeInvoke });
|
||||
}
|
||||
3
src/tauri/main/bootstrap.js
vendored
3
src/tauri/main/bootstrap.js
vendored
|
|
@ -25,6 +25,7 @@ import { installAgentApi } from './api/agent.js';
|
|||
import { installDevApi } from './api/dev.js';
|
||||
import { installExtensionStoreApi } from './api/extension-store.js';
|
||||
import { installLayoutApi } from './api/layout.js';
|
||||
import { installSkillApi } from './api/skill.js';
|
||||
import { installWorldInfoApi } from './api/world-info.js';
|
||||
import { initializeTauriIntegration } from './bootstrap/initialize-tauri-integration.js';
|
||||
import {
|
||||
|
|
@ -272,7 +273,7 @@ export function bootstrapTauriMain() {
|
|||
installNativeShareBridge();
|
||||
|
||||
const context = createTauriMainContext({ invoke, convertFileSrc });
|
||||
installHostAbi(context); installLayoutApi(context); installChatApi(context); installAgentApi(context); installDevApi(context); installExtensionStoreApi(context); installWorldInfoApi();
|
||||
installHostAbi(context); installLayoutApi(context); installChatApi(context); installAgentApi(context); installSkillApi(context); installDevApi(context); installExtensionStoreApi(context); installWorldInfoApi();
|
||||
installMainApiOptionParking();
|
||||
installWorldInfoGlobalSelectorSelect2Enforcer();
|
||||
if (perfEnabled) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export {};
|
|||
* | 'download_external_import_url'
|
||||
* | 'encode_openai_tokens'
|
||||
* | 'export_character_content'
|
||||
* | 'export_skill'
|
||||
* | 'find_secret'
|
||||
* | 'generate_chat_completion'
|
||||
* | 'get_all_background_metadata'
|
||||
|
|
@ -82,6 +83,7 @@ export {};
|
|||
* | 'import_group_chat_payload'
|
||||
* | 'import_world_info'
|
||||
* | 'install_extension'
|
||||
* | 'install_skill_import'
|
||||
* | 'ios_import_data_archive_from_picker'
|
||||
* | 'ios_share_file'
|
||||
* | 'ios_share_export_data_archive'
|
||||
|
|
@ -90,11 +92,13 @@ export {};
|
|||
* | 'list_chat_backups'
|
||||
* | 'list_recent_chat_summaries'
|
||||
* | 'list_recent_group_chat_summaries'
|
||||
* | 'list_skills'
|
||||
* | 'list_user_image_folders'
|
||||
* | 'list_user_images'
|
||||
* | 'load_settings_snapshot'
|
||||
* | 'move_extension'
|
||||
* | 'read_secret_state'
|
||||
* | 'read_skill_file'
|
||||
* | 'read_thumbnail_asset'
|
||||
* | 'read_user_avatar_asset'
|
||||
* | 'read_user_file_asset'
|
||||
|
|
@ -107,6 +111,7 @@ export {};
|
|||
* | 'restore_preset'
|
||||
* | 'restore_settings_snapshot'
|
||||
* | 'rotate_secret'
|
||||
* | 'preview_skill_import'
|
||||
* | 'save_export_data_archive'
|
||||
* | 'save_preset'
|
||||
* | 'save_quick_reply_set'
|
||||
|
|
|
|||
114
src/types.d.ts
vendored
114
src/types.d.ts
vendored
|
|
@ -172,6 +172,119 @@ type TauriTavernAgentApi = {
|
|||
rollback: () => never;
|
||||
};
|
||||
|
||||
type TauriTavernSkillFileKind = 'text' | 'binary';
|
||||
|
||||
type TauriTavernSkillImportConflictKind = 'new' | 'same' | 'different';
|
||||
|
||||
type TauriTavernSkillInstallConflictStrategy = 'skip' | 'replace';
|
||||
|
||||
type TauriTavernSkillInstallAction = 'installed' | 'replaced' | 'already_installed' | 'skipped';
|
||||
|
||||
type TauriTavernSkillIndexEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
displayName?: string;
|
||||
sourceKind?: string;
|
||||
license?: string;
|
||||
author?: string;
|
||||
version?: string;
|
||||
tags: string[];
|
||||
installedHash: string;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
hasScripts: boolean;
|
||||
hasBinary: boolean;
|
||||
installedAt: string;
|
||||
sourceRefs?: TauriTavernSkillSourceRef[];
|
||||
};
|
||||
|
||||
type TauriTavernSkillSourceRef = {
|
||||
kind: string;
|
||||
id: string;
|
||||
label: string;
|
||||
installedHash: string;
|
||||
};
|
||||
|
||||
type TauriTavernSkillInlineFile = {
|
||||
path: string;
|
||||
encoding?: 'utf8' | 'utf-8' | 'base64';
|
||||
content: string;
|
||||
mediaType?: string;
|
||||
sizeBytes?: number;
|
||||
sha256?: string;
|
||||
};
|
||||
|
||||
type TauriTavernSkillImportInput =
|
||||
| {
|
||||
kind: 'inlineFiles';
|
||||
files: TauriTavernSkillInlineFile[];
|
||||
source?: any;
|
||||
}
|
||||
| {
|
||||
kind: 'directory';
|
||||
path: string;
|
||||
source?: any;
|
||||
}
|
||||
| {
|
||||
kind: 'archiveFile';
|
||||
path: string;
|
||||
source?: any;
|
||||
};
|
||||
|
||||
type TauriTavernSkillFileRef = {
|
||||
path: string;
|
||||
kind: TauriTavernSkillFileKind;
|
||||
mediaType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
type TauriTavernSkillImportPreview = {
|
||||
skill: TauriTavernSkillIndexEntry;
|
||||
files: TauriTavernSkillFileRef[];
|
||||
conflict: {
|
||||
kind: TauriTavernSkillImportConflictKind;
|
||||
installedHash?: string;
|
||||
};
|
||||
warnings: string[];
|
||||
source: any;
|
||||
};
|
||||
|
||||
type TauriTavernSkillInstallResult = {
|
||||
name: string;
|
||||
action: TauriTavernSkillInstallAction;
|
||||
skill?: TauriTavernSkillIndexEntry;
|
||||
};
|
||||
|
||||
type TauriTavernSkillReadResult = {
|
||||
name: string;
|
||||
path: string;
|
||||
content: string;
|
||||
chars: number;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
truncated: boolean;
|
||||
resourceRef: string;
|
||||
};
|
||||
|
||||
type TauriTavernSkillExportPayload = {
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
type TauriTavernSkillApi = {
|
||||
list: () => Promise<TauriTavernSkillIndexEntry[]>;
|
||||
previewImport: (input: TauriTavernSkillImportInput) => Promise<TauriTavernSkillImportPreview>;
|
||||
installImport: (request: {
|
||||
input: TauriTavernSkillImportInput;
|
||||
conflictStrategy?: TauriTavernSkillInstallConflictStrategy;
|
||||
}) => Promise<TauriTavernSkillInstallResult>;
|
||||
readFile: (options: { name: string; path: string; maxChars?: number }) => Promise<TauriTavernSkillReadResult>;
|
||||
export: (options: { name: string }) => Promise<TauriTavernSkillExportPayload>;
|
||||
exportSkill: (options: { name: string }) => Promise<TauriTavernSkillExportPayload>;
|
||||
};
|
||||
|
||||
type TauriTavernFrontendLogsApi = {
|
||||
list: (options?: { limit?: number }) => Promise<TauriTavernFrontendLogEntry[]>;
|
||||
subscribe: (
|
||||
|
|
@ -284,6 +397,7 @@ type TauriTavernLayoutApi = {
|
|||
type TauriTavernHostApi = {
|
||||
chat?: TauriTavernChatApi;
|
||||
agent?: TauriTavernAgentApi;
|
||||
skill?: TauriTavernSkillApi;
|
||||
layout?: TauriTavernLayoutApi;
|
||||
dev?: TauriTavernDevApi;
|
||||
worldInfo?: TauriTavernWorldInfoApi;
|
||||
|
|
|
|||
92
tests/skill-api-contract.test.mjs
Normal file
92
tests/skill-api-contract.test.mjs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function installHarness() {
|
||||
const calls = [];
|
||||
globalThis.window = {
|
||||
__TAURITAVERN__: { api: {} },
|
||||
};
|
||||
|
||||
const { installSkillApi } = await import(pathToFileURL(path.join(REPO_ROOT, 'src/tauri/main/api/skill.js')));
|
||||
installSkillApi({
|
||||
safeInvoke: async (command, args) => {
|
||||
calls.push({ command, args });
|
||||
return { command, args };
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
calls,
|
||||
skill: globalThis.window.__TAURITAVERN__.api.skill,
|
||||
};
|
||||
}
|
||||
|
||||
test('api.skill installs and forwards normalized import DTOs', async () => {
|
||||
const { calls, skill } = await installHarness();
|
||||
|
||||
assert.ok(skill);
|
||||
await skill.previewImport({
|
||||
kind: 'inlineFiles',
|
||||
files: [
|
||||
{
|
||||
path: 'SKILL.md',
|
||||
content: '---\nname: test-skill\ndescription: Use in tests.\n---\n',
|
||||
},
|
||||
],
|
||||
source: { kind: 'preset', label: 'Test preset' },
|
||||
});
|
||||
|
||||
assert.equal(calls[0].command, 'preview_skill_import');
|
||||
assert.deepEqual(calls[0].args.input, {
|
||||
kind: 'inlineFiles',
|
||||
files: [
|
||||
{
|
||||
path: 'SKILL.md',
|
||||
encoding: 'utf8',
|
||||
content: '---\nname: test-skill\ndescription: Use in tests.\n---\n',
|
||||
},
|
||||
],
|
||||
source: { kind: 'preset', label: 'Test preset' },
|
||||
});
|
||||
});
|
||||
|
||||
test('api.skill forwards install conflict strategy without implicit replace', async () => {
|
||||
const { calls, skill } = await installHarness();
|
||||
const input = {
|
||||
kind: 'inlineFiles',
|
||||
files: [{ path: 'SKILL.md', content: '---\nname: test-skill\ndescription: Use in tests.\n---\n' }],
|
||||
};
|
||||
|
||||
await skill.installImport({ input });
|
||||
await skill.installImport({ input, conflictStrategy: 'replace' });
|
||||
|
||||
assert.deepEqual(calls[0].args.request, {
|
||||
input: {
|
||||
kind: 'inlineFiles',
|
||||
files: [{ path: 'SKILL.md', encoding: 'utf8', content: '---\nname: test-skill\ndescription: Use in tests.\n---\n' }],
|
||||
source: {},
|
||||
},
|
||||
});
|
||||
assert.equal(calls[1].args.request.conflictStrategy, 'replace');
|
||||
});
|
||||
|
||||
test('api.skill fails fast on unsupported import shapes', async () => {
|
||||
const { skill } = await installHarness();
|
||||
|
||||
await assert.rejects(
|
||||
() => skill.previewImport({ kind: 'base64Zip', content: 'abc' }),
|
||||
/Unsupported skill import kind/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => skill.previewImport({ kind: 'inlineFiles', files: [] }),
|
||||
/requires at least one file/,
|
||||
);
|
||||
await assert.rejects(
|
||||
() => skill.installImport({ input: { kind: 'directory', path: '/tmp/skill' }, conflictStrategy: 'merge' }),
|
||||
/Unsupported skill conflict strategy/,
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue