mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
9 Commits
v0.3.24
...
template-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59fda229d | ||
|
|
5e96909ea8 | ||
|
|
126df05671 | ||
|
|
e68976ce18 | ||
|
|
5f2222b83d | ||
|
|
68edf57f64 | ||
|
|
31ec36146f | ||
|
|
6fafd86ecc | ||
|
|
f435dd2cd4 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -58,3 +58,6 @@ server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
# git worktrees
|
||||
.worktrees/
|
||||
|
||||
555
docs/agent-quick-create-plan.md
Normal file
555
docs/agent-quick-create-plan.md
Normal file
@@ -0,0 +1,555 @@
|
||||
# Agent 快速创建 — 三阶段实施计划
|
||||
|
||||
> Status: Draft (设计阶段,未动工)
|
||||
> Owner: TBD
|
||||
> Last updated: 2026-05-13
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **目标**:降低用户创建 Agent 的门槛,从「手工填表 + 一个个挑 skill」演进到「一键模板」「AI 推荐 skill」「AI 直接创建 agent」三档
|
||||
- **三阶段**:Template(必做、独立)→ Skill Finder(AI 推荐 skill)→ AI Create Agent(AI 直接创建)
|
||||
- **架构关键**:Phase 2/3 复用现有 Quick-create Issue 基础设施(派任务给 agent + tool calling + inbox 通知),不引入新 LLM 调用路径
|
||||
- **不需要新基础设施**:无 SSE、无 server-side LLM、无新 WS channel
|
||||
- **soft blocker**:两处 routine 重构(`createSkillWithFiles` TX 拆分、skill 同名 find-or-create)
|
||||
- **不做**:接入 Anthropic 官方 marketplace(plugin 体系跟单体 skill 形态不匹配)、接入 ClawHub(战略对位错误 + 实际使用率低,见 §5)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 当前现状
|
||||
|
||||
当前用户创建一个 Agent 需要走的步骤:
|
||||
|
||||
1. 进 `/agents` 页面 → 点 "Create Agent"
|
||||
2. 手工填 name / description / runtime / model
|
||||
3. 手工写 instructions(空白文本框,用户自己思考措辞)
|
||||
4. 创建完后进 Agent 详情页 → 点 "Add Skill" → 一个一个挑 skill 关联
|
||||
5. 如果 workspace 还没有需要的 skill,得先去别处建/导入 skill(`POST /api/skills/import` 支持 skills.sh / GitHub / ClawHub 三种 URL)
|
||||
|
||||
**痛点**:
|
||||
- 用户得**预先知道**自己需要哪些 skill,这要求他对 skill 生态熟悉
|
||||
- 写 instructions 是空白文本编辑,大多数用户不知道写什么
|
||||
- 跨多页操作,体感上"创建一个能用的 Agent"是个项目,不是个动作
|
||||
|
||||
### 1.2 三阶段方案
|
||||
|
||||
| Phase | 提供给用户的能力 | 是否需要 AI | 独立可发布 |
|
||||
|---|---|---|---|
|
||||
| **1. Template** | 选模板 → 自动 import 模板带的 skill + 预填 instructions | 否 | ✅ |
|
||||
| **2. Skill Finder** | 描述需求 → AI 推荐 skill 列表 → 一键导入到 workspace | ✅ | ✅(独立功能,任何场景都能用) |
|
||||
| **3. AI Create Agent** | 描述需求 → AI 自己 find skill + 写 instructions + 创建 agent | ✅ | 依赖 Phase 2 |
|
||||
|
||||
每个 phase **本身有用户价值**,不需要等下一个 phase 才能用:
|
||||
- Phase 1 用户能用模板创建 agent,即使后两阶段没做
|
||||
- Phase 2 用户能在任何地方"用 AI 找 skill"(创建 agent 时、给现有 agent 加 skill 时、单纯逛 skill 时)
|
||||
- Phase 3 是 1+2 的组合
|
||||
|
||||
### 1.3 不在范围内
|
||||
|
||||
明确不做的事(及理由,见 §5):
|
||||
- 接入 Anthropic 官方 plugin marketplace(`anthropics/claude-plugins-official`)
|
||||
- 接入 ClawHub 的"发现/搜索"层(import 路径已经存在,但是死代码,建议下线)
|
||||
- 让 AI 直接装 skill 到用户本地 `~/.claude/skills/`(npx skills CLI 行为)
|
||||
- Server-side LLM 调用(后端目前没有 LLM SDK,这条路引入新基础设施,而 Quick-create 模式可以避开)
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键概念回顾
|
||||
|
||||
> 这一节给没参与前期讨论的同事看。已经熟悉 skill 系统的可跳到 §3。
|
||||
|
||||
### 2.1 Skill 是什么
|
||||
|
||||
Skill 是一个**按需加载的能力包**,本质是 SKILL.md 文件 + 可选附件。Anthropic 2025-12 把它发布为开放标准(agentskills.io),Cursor / OpenAI / GitHub Copilot 等都已采纳——同一份 SKILL.md 跨多个 agent 工具都能用。
|
||||
|
||||
每个 runtime(Claude Code / Cursor / Codex 等)启动时**自动扫**自己约定的目录(`~/.claude/skills/`、`.cursor/skills/` 等),读 SKILL.md 的 frontmatter 形成"我手上有这些 skill"的清单注入 system prompt。具体 skill 正文只在被触发时才进 context。
|
||||
|
||||
### 2.2 Multica 的 Skill 数据模型
|
||||
|
||||
3 张表(migration `008_structured_skills.up.sql`):
|
||||
|
||||
| 表 | 关键字段 |
|
||||
|---|---|
|
||||
| `skill` | `id, workspace_id, name, description, content (=SKILL.md 正文), config (含 origin 元数据)` |
|
||||
| `skill_file` | `skill_id, path, content`(SKILL.md 的附件,如 examples/*.md、scripts/*.py) |
|
||||
| `agent_skill` | `agent_id, skill_id`(M:N 关联) |
|
||||
|
||||
**关键约束**:`UNIQUE(workspace_id, name)` — 同 workspace 内 skill 名字必须唯一。
|
||||
|
||||
### 2.3 Skill 流转链路(数据库 → runtime)
|
||||
|
||||
任务运行时,skill 从 PG 到 runtime 的完整路径:
|
||||
|
||||
```
|
||||
1. 数据库:skill + skill_file + agent_skill 三张表的行
|
||||
|
||||
2. Daemon claim 任务:
|
||||
POST /api/runtimes/{runtimeId}/tasks/claim
|
||||
handler/daemon.go:1018-1098 (ClaimTaskByRuntime)
|
||||
→ service/task.go:1447-1463 (LoadAgentSkills)
|
||||
→ 把 agent 关联的所有 skill 全文塞进 HTTP 响应
|
||||
|
||||
3. Daemon 算工作目录:
|
||||
server/internal/daemon/execenv/execenv.go:114, 124
|
||||
workDir = {WorkspacesRoot}/{wsID}/{shortTaskID}/workdir
|
||||
|
||||
4. Daemon 按 runtime 算 skill 目录:
|
||||
server/internal/daemon/execenv/context.go:121-158 (resolveSkillsDir)
|
||||
claude → {workDir}/.claude/skills
|
||||
cursor → {workDir}/.cursor/skills
|
||||
codex → 特殊:{codexHome}/skills
|
||||
|
||||
5. Daemon 把字符串写成磁盘文件:
|
||||
context.go:175-204 (writeSkillFiles)
|
||||
核心就两行 os.WriteFile
|
||||
|
||||
6. Daemon 启动 runtime,cwd = workDir
|
||||
runtime 自己扫 .claude/skills/(等)→ 加载 frontmatter
|
||||
|
||||
7. 任务结束:os.RemoveAll(workDir)
|
||||
PG 是真相源,workDir 是每次任务临时复印件
|
||||
```
|
||||
|
||||
**核心 invariant**:Multica 不教 runtime 怎么用 skill,只把文件摆到 runtime 已经会扫的位置。
|
||||
|
||||
### 2.4 Template = Instructions + Skill 引用
|
||||
|
||||
Template 是个**静态 JSON 定义**,包含:
|
||||
- 预写好的 instructions
|
||||
- 一组 skill 引用(用 URL 指向 skills.sh / GitHub)
|
||||
|
||||
用户选模板时,后端:
|
||||
1. 对每个 skill 引用,**复用现有 `/api/skills/import` 的 fetcher**(`fetchFromSkillsSh` / `fetchFromGitHub`)拉内容
|
||||
2. 物化到 workspace(同名复用 / 新建)
|
||||
3. CreateAgent + setAgentSkills
|
||||
4. 整个流程一个事务
|
||||
|
||||
skill 引用为什么用 URL 而不是内联 SKILL.md 内容:
|
||||
- 复用现有 import 基础设施,零新代码
|
||||
- skill 内容跟 GitHub 同步,不需要 vendoring 进 multica 仓库
|
||||
- 模板 JSON 体积小,git review 友好
|
||||
|
||||
### 2.5 Quick-create Issue 模式(Phase 2/3 复用的基础设施)
|
||||
|
||||
当前 `POST /api/issues/quick-create`(handler/issue.go:877-982)的流程:
|
||||
|
||||
```
|
||||
1. 后端 enqueue 任务:
|
||||
- agent_task_queue 加一行,issue_id = NULL,context JSONB = {type: "quick-create", prompt: ...}
|
||||
- 立即返回 202 Accepted + task_id
|
||||
|
||||
2. Daemon claim 任务时识别 quick-create:
|
||||
- 检查 task.Context != nil AND !task.IssueID.Valid
|
||||
- 解析为 QuickCreateContext (service/task.go:1810-1811)
|
||||
|
||||
3. Daemon 构造 prompt:
|
||||
- daemon/prompt.go:45-106 (buildQuickCreatePrompt)
|
||||
- 把用户的自然语言 prompt 作为语义核心
|
||||
- 加上"调用 multica issue create CLI 命令"的指令
|
||||
|
||||
4. Agent 跑 LLM + tool calling:
|
||||
- LLM 输出形如 `multica issue create --title="..." --description="..."` 的命令
|
||||
- daemon 执行 CLI 命令,CLI 调 POST /api/issues 创建 issue
|
||||
- CLI 自动在请求里带上 MULTICA_QUICK_CREATE_TASK_ID env(daemon/daemon.go:2081)
|
||||
→ 让创建出来的 issue 带 origin_type='quick_create' + origin_id=<task_id>
|
||||
|
||||
5. 后端 link + 通知:
|
||||
- 完成检测:GetIssueByOrigin(workspace_id, "quick_create", task_id)
|
||||
- LinkTaskToIssue(task_id, issue_id) 把任务行的 issue_id 补上
|
||||
- 写 inbox_item 通知用户(notifyQuickCreateCompleted, service/task.go:1908-1920)
|
||||
```
|
||||
|
||||
**关键洞察**:这个模式**完全通用化**了。复用它只需要:
|
||||
1. 新的 context JSONB type(比如 `"skill-find"`、`"agent-create"`)
|
||||
2. 新的 prompt builder
|
||||
3. 新的"完成检测 + inbox 通知"
|
||||
|
||||
不需要任何 daemon / 任务队列层面的改动。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三阶段详细设计
|
||||
|
||||
### Phase 1:Agent Template
|
||||
|
||||
**目标**:用户选模板 → 一键得到一个可用的 agent(自带 skill + instructions),不需要 AI 参与。
|
||||
|
||||
#### 设计
|
||||
|
||||
- **Template 定义存放**:静态 JSON,commit 在 `server/internal/agenttmpl/templates/*.json`
|
||||
- **Template JSON 形态**:
|
||||
```json
|
||||
{
|
||||
"slug": "code-reviewer",
|
||||
"name": "Code Reviewer",
|
||||
"description": "审代码用的 agent",
|
||||
"instructions": "你审代码,关注 N+1 查询、错误处理、类型安全...",
|
||||
"skills": [
|
||||
{ "source_url": "https://skills.sh/obra/superpowers/tdd" },
|
||||
{ "source_url": "https://github.com/foo/bar/tree/main/skills/code-style" }
|
||||
]
|
||||
}
|
||||
```
|
||||
- **新 endpoint**:`POST /api/agents/from-template`
|
||||
- 请求:`{template_slug, name, runtime_id, ...overrides}`
|
||||
- 后端流程(**全部在一个事务里**):
|
||||
1. 加载 template JSON
|
||||
2. 对每个 skill source_url:
|
||||
- 调用 `detectImportSource(url)`(skill.go:586-617)分发到对应 fetcher
|
||||
- 通过 GetSkillByWorkspaceAndName 检查 workspace 是否已有同名 skill
|
||||
- 有 → 复用现有 skill_id
|
||||
- 无 → 调 `createSkillWithFilesInTx`(待重构,见 §4)物化
|
||||
3. `CreateAgent`(复用 agent.go:CreateAgent 的内部逻辑)
|
||||
4. 批量 `AddAgentSkill` 关联
|
||||
- 响应:`{agent: {...}, imported_skill_ids: [...], reused_skill_ids: [...]}`
|
||||
- **前端**:`CreateAgentDialog`(packages/views/agents/components/create-agent-dialog.tsx)加 "From template" 模式,跟现有 manual / duplicate 模式并列
|
||||
- 模板选择器 → 预览(instructions + skill 列表)→ 提交调新 endpoint
|
||||
- 响应里的 `reused_skill_ids` 用 toast 提示"以下 skill 已存在,沿用了 workspace 现有版本"
|
||||
|
||||
#### 起步模板清单(初版,可调)
|
||||
|
||||
- `code-reviewer` — 代码审查
|
||||
- `tdd-pair` — TDD 配对编程
|
||||
- `db-reviewer` — 数据库 / SQL 审查
|
||||
- `pr-summarizer` — PR 摘要
|
||||
- `docs-writer` — 文档撰写
|
||||
|
||||
具体每个模板选哪些 skill URL,在 Phase 1 启动时单独决定(需要逛 skills.sh 选高质量 skill)。
|
||||
|
||||
#### Phase 1 改动清单
|
||||
|
||||
| 文件 / 位置 | 改动 |
|
||||
|---|---|
|
||||
| `server/internal/agenttmpl/`(新包) | 加载 JSON 模板的代码 |
|
||||
| `server/internal/agenttmpl/templates/*.json`(新文件) | 5 个起步模板 |
|
||||
| `server/internal/handler/agent.go` | 新 handler `CreateAgentFromTemplate` |
|
||||
| `server/internal/handler/skill_create.go` | **重构**:拆出 `createSkillWithFilesInTx` 变体(见 §4) |
|
||||
| `server/pkg/db/queries/skill.sql` | 加 `GetSkillByWorkspaceAndName`(见 §4) |
|
||||
| `server/cmd/server/router.go` | 注册新 endpoint |
|
||||
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 template 模式 |
|
||||
| `packages/core/api/agent.ts` | 加 `createAgentFromTemplate` API 调用 |
|
||||
| `packages/views/agents/components/template-picker.tsx`(新文件) | 模板选择器组件 |
|
||||
|
||||
### Phase 2:Skill Finder
|
||||
|
||||
**目标**:用户用自然语言描述需求(如"我想审 SQL"),AI 推荐一组 skill,用户勾选一键导入到 workspace。
|
||||
|
||||
#### 设计
|
||||
|
||||
- **架构选型**:走 quick-create 模式,**不是后端直接调 LLM**
|
||||
- **新 endpoint**:`POST /api/skills/find`
|
||||
- 请求:`{prompt, agent_id}`(agent_id 是用来跑这个 LLM 任务的 agent,跟 Quick-create Issue 一样要求预先有 agent)
|
||||
- 后端流程:
|
||||
1. enqueue 任务:`agent_task_queue` 加一行,context JSONB = `{type: "skill-find", prompt}`
|
||||
2. 返回 202 + task_id
|
||||
- **Daemon prompt builder**:`daemon/prompt.go` 加 `buildSkillFindPrompt`(类比 buildQuickCreatePrompt)
|
||||
- 喂给 agent 的 prompt 大致:
|
||||
```
|
||||
用户需求:{user_prompt}
|
||||
|
||||
你的任务:从以下 curated skill 清单里选 3-5 个最相关的推荐给用户。
|
||||
|
||||
可选 skill 清单(JSON):
|
||||
{curated_skill_index}
|
||||
|
||||
输出:调用 `multica skill find --output-results '<JSON>'` 命令,
|
||||
JSON 形态为 [{name, description, source_url, reason}, ...]
|
||||
```
|
||||
- **CLI 命令**(新):`multica skill find --output-results <JSON>`
|
||||
- 不发起 HTTP 请求,只把 JSON 写到 daemon 通过 env 指定的临时文件
|
||||
- daemon 读这个文件,把内容塞进 inbox notification 的 payload
|
||||
- **Curated skill 索引**:`server/internal/agenttmpl/skill_index.json`(新文件)
|
||||
- 几十到上百条精选 skill,每条:`{name, description, source_url, tags, install_count}`
|
||||
- 维护方式:工程师/产品手工维护,代码 review 卡内容质量
|
||||
- MVP **不做**实时 GitHub Code Search 或 skills.sh 爬虫
|
||||
- **完成通知**:写 inbox_item,type = `skill_find_done`,payload 含推荐结果数组
|
||||
- **前端**:
|
||||
- 独立"Find Skill"页面(`/skills/find` 或 `/skills?ai=true`)
|
||||
- skill list page 上"用 AI 找 skill"按钮入口
|
||||
- 用户输入 prompt → 提交 → 等通知 → inbox item 里展示 skill 卡片(name + description + source_url + reason)
|
||||
- 用户勾选 → 一键批量调现有 `POST /api/skills/import`(每个 skill 一次,可考虑加 batch endpoint 但 MVP 不必要)
|
||||
|
||||
#### Phase 2 改动清单
|
||||
|
||||
| 文件 / 位置 | 改动 |
|
||||
|---|---|
|
||||
| `server/internal/handler/skill.go` | 新 handler `FindSkill`(enqueue task) |
|
||||
| `server/internal/service/task.go` | 加 `EnqueueSkillFindTask` + 完成检测 + inbox 通知 |
|
||||
| `server/internal/daemon/prompt.go` | 加 `buildSkillFindPrompt` |
|
||||
| `server/internal/daemon/daemon.go` | 加 `SkillFindContext` 识别 + env 注入 |
|
||||
| `server/cmd/multica/cmd_skill.go` | 加 `find --output-results` 子命令 |
|
||||
| `server/internal/agenttmpl/skill_index.json`(新文件) | curated 清单 |
|
||||
| `packages/views/skills/components/find-skills-dialog.tsx`(新文件) | UI |
|
||||
| `packages/core/api/skill.ts` | 加 `findSkills` API |
|
||||
| `packages/views/inbox/items/skill-find-result.tsx`(新文件) | inbox item 渲染 |
|
||||
|
||||
### Phase 3:AI Create Agent
|
||||
|
||||
**目标**:用户描述需求,AI 自己 find skill + 写 instructions + 创建 agent。
|
||||
|
||||
#### 设计
|
||||
|
||||
- **架构选型**:走 quick-create 模式,**组合 Phase 2 的 find 能力 + 新的 agent create CLI**
|
||||
- **新 endpoint**:`POST /api/agents/ai-draft`
|
||||
- 请求:`{prompt, host_agent_id}`(host_agent_id 是跑这个元任务的 agent)
|
||||
- 后端:enqueue 任务,context = `{type: "agent-create", prompt}`,返回 202 + task_id
|
||||
- **Daemon prompt builder**:`buildAgentCreatePrompt` 指挥 agent 三步走:
|
||||
```
|
||||
1. 调用 `multica skill find --output-results ...` 选 skill
|
||||
(或直接看 curated 清单选)
|
||||
2. 基于选定 skill 写 instructions
|
||||
3. 调用 `multica agent create --name ... --instructions ... --skill-ids ...`
|
||||
创建 agent 并关联 skill
|
||||
```
|
||||
- **CLI 命令**(新):`multica agent create`
|
||||
- 后端 handler 已存在(handler/agent.go:CreateAgent),只需要绑 CLI(~50 行)
|
||||
- 创建时带 `MULTICA_AI_DRAFT_TASK_ID` env,服务端用它做 origin 标记 + LinkTaskToAgent
|
||||
- **完成通知**:inbox_item type = `agent_draft_done`,payload 含 agent_id + 摘要
|
||||
- **前端**:`CreateAgentDialog` 加 "AI" 模式
|
||||
- 输入需求 → 提交 → 等通知 → inbox 通知里点击 → 跳新 agent 详情页(用户在那儿编辑/调整)
|
||||
|
||||
#### Phase 3 改动清单
|
||||
|
||||
| 文件 / 位置 | 改动 |
|
||||
|---|---|
|
||||
| `server/internal/handler/agent.go` | 新 handler `AIDraftAgent`(enqueue task) |
|
||||
| `server/internal/service/task.go` | 加 `EnqueueAgentDraftTask` + 完成检测 + inbox 通知 |
|
||||
| `server/internal/daemon/prompt.go` | 加 `buildAgentCreatePrompt` |
|
||||
| `server/cmd/multica/cmd_agent.go` | 加 `create` 子命令(handler 已有) |
|
||||
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 "AI" 模式 |
|
||||
| `packages/core/api/agent.ts` | 加 `aiDraftAgent` API |
|
||||
| `packages/views/inbox/items/agent-draft-result.tsx`(新文件) | inbox item 渲染 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Blocker 清单与修复方案
|
||||
|
||||
### 4.1 [SOFT] `createSkillWithFiles` 不可组合事务
|
||||
|
||||
**问题**:`server/internal/handler/skill_create.go:21-71` 这个函数自己 `Begin()` 一个事务,执行完 `Commit()`。Phase 1 需要在外层事务里**多次**调用它(import N 个 skill + createAgent + setAgentSkills 都在一个 TX),但现在没法这么用。
|
||||
|
||||
**影响范围**:Phase 1
|
||||
|
||||
**修复方案**:
|
||||
|
||||
```go
|
||||
// 拆成两个函数(保持原 API 向后兼容):
|
||||
|
||||
// 新增:接受外部 qtx,不管事务
|
||||
func createSkillWithFilesInTx(
|
||||
ctx context.Context,
|
||||
qtx *db.Queries,
|
||||
input skillCreateInput,
|
||||
) (*SkillWithFilesResponse, error) {
|
||||
// 不 Begin/Commit,只调 qtx.CreateSkill + qtx.UpsertSkillFile loop
|
||||
}
|
||||
|
||||
// 改造:原函数变成包装层,内部调 InTx 版
|
||||
func (h *Handler) createSkillWithFiles(
|
||||
ctx context.Context,
|
||||
input skillCreateInput,
|
||||
) (*SkillWithFilesResponse, error) {
|
||||
tx, _ := h.TxStarter.Begin(ctx)
|
||||
defer tx.Rollback()
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
result, err := createSkillWithFilesInTx(ctx, qtx, input)
|
||||
if err != nil { return nil, err }
|
||||
tx.Commit()
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
旧调用方完全不变。Phase 1 新 endpoint 自己 Begin,然后多次调 `*InTx` 变体,最后统一 Commit。
|
||||
|
||||
**工作量**:小(< 100 行重构)
|
||||
|
||||
### 4.2 [SOFT] Skill 同名冲突
|
||||
|
||||
**问题**:`skill` 表有 `UNIQUE(workspace_id, name)` 约束。Phase 1 模板导入时,如果模板里的 skill 跟 workspace 已有 skill 同名,INSERT 会报 PG 错误 23505,整个 from-template 流程挂掉。
|
||||
|
||||
**影响范围**:Phase 1
|
||||
|
||||
**修复方案**:加 find-or-create 模式:
|
||||
|
||||
1. 新 query `GetSkillByWorkspaceAndName`(`server/pkg/db/queries/skill.sql`)
|
||||
2. Phase 1 流程改成:
|
||||
- 对每个模板 skill,先查 workspace 是否已有同名
|
||||
- 有 → 复用现有 skill_id,跳过 import
|
||||
- 无 → 调 `createSkillWithFilesInTx` 物化
|
||||
3. 响应里返回 `reused_skill_ids: [...]`,前端 toast "以下 skill 已存在,沿用现有版本"
|
||||
|
||||
**不选择"覆盖"或"加后缀"的原因**:用户可能已经改过本地版本,覆盖会丢用户修改;加后缀污染 skill 列表。
|
||||
|
||||
**工作量**:小(< 50 行 + 1 条 sqlc query)
|
||||
|
||||
### 4.3 [SOFT] 缺 `multica skill find` CLI
|
||||
|
||||
**影响范围**:Phase 2
|
||||
|
||||
**方案**:加一个 CLI 子命令,模仿 `multica skill import` 的实现(`server/cmd/multica/cmd_skill.go:55-60, 323-357`)。**注意**:这个命令不发 HTTP 请求,只是 LLM agent 用来"输出推荐结果"的 channel——它把 LLM 推荐的 JSON 写到 daemon 指定的临时文件,daemon 读完塞进 inbox notification。
|
||||
|
||||
**工作量**:小(~80 行)
|
||||
|
||||
### 4.4 [SOFT] 缺 `multica agent create` CLI
|
||||
|
||||
**影响范围**:Phase 3
|
||||
|
||||
**方案**:后端 handler 已有(`handler/agent.go:CreateAgent`),只需在 `server/cmd/multica/cmd_agent.go` 加 `create` 子命令。
|
||||
|
||||
**工作量**:小(~50 行)
|
||||
|
||||
### 4.5 [非 blocker] System Agent 问题
|
||||
|
||||
**之前误判为 hard blocker,实际不是**:
|
||||
|
||||
Quick-create Issue 当前的设计就要求用户**预先有一个 agent** 才能用——AI 路径不为"零 agent 起步"服务。Phase 2/3 沿用这个前提,所以**新 workspace 没 agent 时 AI 功能不可用**是符合现有产品模型的,不需要 bootstrap 一个 system agent。
|
||||
|
||||
产品自然解锁路径:
|
||||
1. 新用户进 workspace
|
||||
2. 用 **Phase 1 Template**(无需 AI、无需现有 agent)创建第一个 agent
|
||||
3. 之后 Phase 2/3 即可用,host_agent 就用刚创建的那个
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键设计决策(及理由)
|
||||
|
||||
### 5.1 为什么不接 Anthropic 官方 marketplace?
|
||||
|
||||
**结构错配**。Anthropic 官方 marketplace(`anthropics/claude-plugins-official`)是 **plugin 体系**:每个 plugin 是个 bundle,包含 `.claude-plugin/plugin.json` + `skills/` + `agents/` + `hooks/` + `.mcp.json`。
|
||||
|
||||
Multica 只有**单体 skill**(SKILL.md + skill_file),没有 plugin / bundle 概念。要接入得新写 plugin parser + 拆分逻辑,工作量大,而 skills.sh 已经覆盖了同一批高质量内容(skills.sh 后端就是 GitHub raw,绝大多数 skill 作者就在 GitHub 上,Anthropic plugin 体系里的 skill 通常也在作者的 GitHub repo 里有单体副本)。
|
||||
|
||||
### 5.2 为什么走 quick-create 模式而不是后端直接调 LLM?
|
||||
|
||||
代码事实:`server/` 目前**完全没有任何 LLM SDK**(grep `anthropic-sdk-go` / `openai-go` / 任何 LLM provider 都是 0 命中)。所有 LLM 调用都通过 daemon → runtime → CLI 这条路。
|
||||
|
||||
走 quick-create 模式的优势:
|
||||
- **不引入新基础设施**(SSE / LLM client / API key 管理)
|
||||
- **复用 agent 的 instructions / model / runtime 配置**(用户已经在某个 agent 里配置过的偏好自动生效)
|
||||
- **统一计费 / 用量监控**(LLM 调用都计在用户 agent 的 quota 里)
|
||||
|
||||
代价:
|
||||
- 用户得**预先有一个 agent**(参见 §4.5,这跟 Quick-create Issue 现状一致)
|
||||
- LLM 调用通过 daemon 多一跳,延迟略增(但不阻塞 202 响应)
|
||||
|
||||
### 5.3 为什么 Skill Finder 是 endpoint 不是 SKILL.md?
|
||||
|
||||
**Skill Finder 名字里的 "Skill" 是它的产物(找的是 skill),不是它自己实现成 SKILL.md**。
|
||||
|
||||
如果做成 SKILL.md 文件:
|
||||
- 它得装进某个 agent 里才能用 → 单点功能变得需要前置配置
|
||||
- skill 教 agent 调什么?调 `npx skills`(装到本地,目标错)?调 Multica API(那要写 tool channel,绕一大圈)
|
||||
- AI 创建 Agent(Phase 3)那条路要"启动 agent → agent 调 skill → skill 调 tool",链路复杂三倍
|
||||
|
||||
做成 endpoint:
|
||||
- 用户独立可用(独立 UI 入口)
|
||||
- AI 创建 Agent 后端直接调 endpoint,两个功能共用一段逻辑
|
||||
- 简单
|
||||
|
||||
### 5.4 Curated Skill 索引 vs 实时搜索
|
||||
|
||||
**MVP 用 curated 清单**(几十条精选 URL + 摘要 commit 在 repo 里)。理由:
|
||||
- 质量可控
|
||||
- 不踩 GitHub Code Search rate limit
|
||||
- 不被 LLM 编 URL(LLM 知识 cutoff + hallucinate URL 是真问题)
|
||||
- 维护成本低
|
||||
|
||||
进阶可加 `search_skills(query)` tool 实时打 GitHub Code Search,等用户反馈"清单太窄"再做。
|
||||
|
||||
### 5.5 不做 ClawHub(顺手清理建议)
|
||||
|
||||
**现状**:`POST /api/skills/import` 当前支持 3 个 source(`fetchFromClawHub` skill.go:642-744、`fetchFromSkillsSh` skill.go:757-879、`fetchFromGitHub` skill.go:1363-1463)。ClawHub 是个独立 HTTP 客户端,不复用 GitHub 基础设施。
|
||||
|
||||
**判断**(详见之前讨论):
|
||||
- ClawHub 服务的是 OpenClaw 平台(Multica 同生态位竞品的内容生态)
|
||||
- UI 没有发现/搜索层,用户只能粘 URL,而 ClawHub 装机量远低于 skills.sh,用户主动逛的概率极低
|
||||
- 独立代码路径,API 演进时单独跟进
|
||||
|
||||
**建议**(独立于本计划,可以一起做也可以延后):
|
||||
- 跑 `SELECT count(*) FROM skill WHERE config->'origin'->>'type' = 'clawhub'` 看实际使用量
|
||||
- 接近 0 → 渐进下线(先去 UI SourceCard,后续 release 删 fetcher)
|
||||
- 有量 → 留着,但仍不为它做新功能
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施依赖与排期
|
||||
|
||||
```
|
||||
[Phase 1] Template
|
||||
└── 独立,无依赖
|
||||
└── 包含 2 个 soft blocker 的修复(§4.1 §4.2)
|
||||
↓
|
||||
[Phase 2] Skill Finder
|
||||
└── 依赖 Phase 1 中的 skill import 路径(已存在,沿用)
|
||||
└── 含 1 个 soft blocker(§4.3)
|
||||
↓
|
||||
[Phase 3] AI Create Agent
|
||||
└── 依赖 Phase 2(复用 find skill 能力)
|
||||
└── 含 1 个 soft blocker(§4.4)
|
||||
```
|
||||
|
||||
**真实排期建议**:
|
||||
- Phase 1 可单独发版,有独立价值
|
||||
- Phase 2 独立可发版(找 skill 是高频独立场景)
|
||||
- Phase 3 等 Phase 2 ready 后开始
|
||||
|
||||
每个 phase 启动时单独开 PR 设计 doc,本文档只是路线图。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| GitHub rate limit(模板 import 多个 skill 时) | 已有 `GITHUB_TOKEN` env 支持(skill.go:1163-1166),5000/h 配额够用。生产环境确保配置 |
|
||||
| 模板里引用的 skill repo 被作者删除 | from-template handler 容错:某个 skill fetch 失败 → 整个事务回滚,前端展示具体哪个 URL 挂了。模板自己也定期 review |
|
||||
| LLM 推荐编造 URL(Phase 2) | 用 curated 清单作为 context,**不让 LLM 自由发挥 URL**,推荐范围限定在清单内 |
|
||||
| Phase 3 LLM 写出离谱 instructions | 用户在 inbox 通知里点击 → 跳新 agent 详情页**编辑模式**,不直接进入"已就绪"状态。用户必须确认 |
|
||||
| 模板格式后续要演进(加字段) | Template JSON 加 `version` 字段,后端按 version 兼容老格式 |
|
||||
| Curated skill 清单过时(作者改 repo / 删 skill) | 加 CI 任务定期跑一遍清单 URL,挂掉的报警通知维护者 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 不在本文档范围(已识别的下一步话题)
|
||||
|
||||
- 跨 workspace 模板共享 / marketplace 化(用户能把自己的 agent 存成模板分享)
|
||||
- 实时 GitHub Code Search tool(Phase 2 进阶)
|
||||
- Server-side LLM 调用基础设施(如果未来需要 streaming 等场景)
|
||||
- ClawHub 下线决策(独立讨论,见 §5.5)
|
||||
- Skill 版本管理(workspace skill 版本号 / 升级提示)
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:代码索引
|
||||
|
||||
> 给接手开发的同事的快速参考。每条 file:line 都在本计划里被引用过,记录在这里方便跳转。
|
||||
|
||||
| 主题 | 位置 |
|
||||
|---|---|
|
||||
| Skill DB 模型 | `server/migrations/008_structured_skills.up.sql:4-32` |
|
||||
| Skill 创建 handler + 事务 | `server/internal/handler/skill.go:143-162` + `skill_create.go:21-71` |
|
||||
| Skill import 入口(支持 3 个 source) | `server/internal/handler/skill.go:1538` |
|
||||
| Skill import source 分发 | `server/internal/handler/skill.go:586-617` (`detectImportSource`) |
|
||||
| Skills.sh fetcher | `server/internal/handler/skill.go:757-879` (`fetchFromSkillsSh`) |
|
||||
| GitHub fetcher | `server/internal/handler/skill.go:1363-1463` (`fetchFromGitHub`) |
|
||||
| ClawHub fetcher | `server/internal/handler/skill.go:642-744` (`fetchFromClawHub`) |
|
||||
| Agent 创建 handler | `server/internal/handler/agent.go:380-399` (request) + `:422-564` (CreateAgent) |
|
||||
| Agent 创建 sqlc | `server/pkg/db/queries/agent.sql:19-25` |
|
||||
| Agent-Skill 关联 sqlc | `server/pkg/db/queries/agent.sql:86-103` |
|
||||
| 当前 Agent Duplication(前端模式) | `packages/views/agents/components/agents-page.tsx:286-301`(post-create skill copy) |
|
||||
| Agent 创建 dialog | `packages/views/agents/components/create-agent-dialog.tsx` |
|
||||
| Skill add dialog | `packages/views/agents/components/skill-add-dialog.tsx` |
|
||||
| Quick-create Issue handler | `server/internal/handler/issue.go:877-982` (`QuickCreateIssue`) |
|
||||
| Quick-create task enqueue | `server/internal/service/task.go:488+` (`EnqueueQuickCreateTask`) |
|
||||
| Daemon claim + load skills | `server/internal/handler/daemon.go:1018-1098` + `service/task.go:1447-1463` |
|
||||
| Daemon prompt build | `server/internal/daemon/prompt.go:17-36` (dispatch) + `:45-106` (`buildQuickCreatePrompt`) |
|
||||
| Daemon execenv prepare | `server/internal/daemon/execenv/execenv.go:103-176` |
|
||||
| Skill 目录约定(runtime mapping) | `server/internal/daemon/execenv/context.go:121-158` (`resolveSkillsDir`) |
|
||||
| Skill 文件落盘 | `server/internal/daemon/execenv/context.go:175-204` (`writeSkillFiles`) |
|
||||
| Quick-create 完成检测 + inbox | `server/internal/service/task.go:1810-1949` |
|
||||
| LinkTaskToIssue | `server/internal/handler/agent.go:97-105` |
|
||||
| Quick-create Issue 前端 modal | `packages/views/modals/quick-create-issue.tsx:48-570+` |
|
||||
| Multica CLI 入口 | `server/cmd/multica/main.go:62-79` |
|
||||
| Skill CLI 命令 | `server/cmd/multica/cmd_skill.go:17-96`(已有 import,无 find) |
|
||||
| Agent CLI 命令 | `server/cmd/multica/cmd_agent.go:101-112`(已有 list/get,无 create) |
|
||||
@@ -82,3 +82,30 @@ export function agentTasksOptions(wsId: string, agentId: string) {
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Agent templates are workspace-independent: a static catalog served from
|
||||
// the server's embedded JSON. Cache effectively forever — the only way the
|
||||
// list / detail change is a server deploy, and a hard reload picks that up.
|
||||
export const agentTemplateKeys = {
|
||||
all: () => ["agent-templates"] as const,
|
||||
list: () => [...agentTemplateKeys.all(), "list"] as const,
|
||||
detail: (slug: string) => [...agentTemplateKeys.all(), "detail", slug] as const,
|
||||
};
|
||||
|
||||
export function agentTemplateListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: agentTemplateKeys.list(),
|
||||
queryFn: () => api.listAgentTemplates(),
|
||||
staleTime: Infinity,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function agentTemplateDetailOptions(slug: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentTemplateKeys.detail(slug),
|
||||
queryFn: () => api.getAgentTemplate(slug),
|
||||
staleTime: Infinity,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
ListIssuesParams,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
CreateAgentFromTemplateRequest,
|
||||
CreateAgentFromTemplateResponse,
|
||||
UpdateAgentRequest,
|
||||
AgentTask,
|
||||
AgentActivityBucket,
|
||||
@@ -94,13 +98,19 @@ import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
import { parseWithFallback } from "./schema";
|
||||
import {
|
||||
AgentTemplateSchema,
|
||||
AgentTemplateSummaryListSchema,
|
||||
AttachmentResponseSchema,
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||||
EMPTY_ATTACHMENT,
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_TIMELINE_ENTRIES,
|
||||
ListIssuesResponseSchema,
|
||||
@@ -634,6 +644,51 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
|
||||
const raw = await this.fetch<unknown>("/api/agent-templates");
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
AgentTemplateSummaryListSchema,
|
||||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||||
{ endpoint: "GET /api/agent-templates" },
|
||||
);
|
||||
}
|
||||
|
||||
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/agent-templates/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
// Round-trip the requested slug into the fallback so a malformed
|
||||
// detail response still produces a navigable record matching the URL
|
||||
// the user clicked.
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
AgentTemplateSchema,
|
||||
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
|
||||
{ endpoint: "GET /api/agent-templates/:slug" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates an agent from a curated template. The server fetches every
|
||||
* referenced skill URL in parallel, materializes them into the workspace
|
||||
* (find-or-create by name), and writes the agent + skill bindings in a
|
||||
* single transaction. On any upstream fetch failure, the entire write is
|
||||
* rolled back and the API returns 422 with `failed_urls`. */
|
||||
async createAgentFromTemplate(
|
||||
data: CreateAgentFromTemplateRequest,
|
||||
): Promise<CreateAgentFromTemplateResponse> {
|
||||
const raw = await this.fetch<unknown>("/api/agents/from-template", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
{ endpoint: "POST /api/agents/from-template" },
|
||||
);
|
||||
}
|
||||
|
||||
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -117,6 +117,108 @@ describe("ApiClient schema fallback", () => {
|
||||
expect(res).toEqual({ issues: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// Agent template catalog is hit by the desktop create-agent picker.
|
||||
// Installed desktop builds outlive any given server, so the shape MUST
|
||||
// survive future field renames / wrapping without crashing. Each test
|
||||
// here mirrors a concrete future drift we want to absorb.
|
||||
describe("listAgentTemplates", () => {
|
||||
it("falls back to [] when the body is null", async () => {
|
||||
stubFetchJson(null);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const tmpls = await client.listAgentTemplates();
|
||||
expect(tmpls).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults skills to [] when the field is missing from a template", async () => {
|
||||
// Future server: drops `skills` because the picker no longer reads
|
||||
// them. Picker code calls `template.skills.length` — must not throw.
|
||||
stubFetchJson([{ slug: "x", name: "X" }]);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const tmpls = await client.listAgentTemplates();
|
||||
expect(tmpls).toHaveLength(1);
|
||||
expect(tmpls[0]?.skills).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts the bare-array shape (current contract)", async () => {
|
||||
stubFetchJson([
|
||||
{ slug: "a", name: "A", description: "", skills: [] },
|
||||
{ slug: "b", name: "B", description: "", skills: [] },
|
||||
]);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const tmpls = await client.listAgentTemplates();
|
||||
expect(tmpls.map((t) => t.slug)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("accepts a future {templates: [...]} envelope without breaking", async () => {
|
||||
// Server migrates to a paginated envelope. We unwrap so the picker
|
||||
// keeps working on the older bare-array consumer.
|
||||
stubFetchJson({
|
||||
templates: [{ slug: "a", name: "A", description: "", skills: [] }],
|
||||
total: 1,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const tmpls = await client.listAgentTemplates();
|
||||
expect(tmpls).toHaveLength(1);
|
||||
expect(tmpls[0]?.slug).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentTemplate", () => {
|
||||
it("falls back to a minimal record carrying the requested slug", async () => {
|
||||
// Slug is part of the URL the user clicked — the fallback round-
|
||||
// trips it so the page header still makes sense after a parse miss.
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const detail = await client.getAgentTemplate("code-reviewer");
|
||||
expect(detail.slug).toBe("code-reviewer");
|
||||
expect(detail.skills).toEqual([]);
|
||||
expect(detail.instructions).toBe("");
|
||||
});
|
||||
|
||||
it("defaults instructions to '' when the field is missing", async () => {
|
||||
stubFetchJson({
|
||||
slug: "code-reviewer",
|
||||
name: "Code Reviewer",
|
||||
description: "",
|
||||
skills: [],
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const detail = await client.getAgentTemplate("code-reviewer");
|
||||
expect(detail.instructions).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAgentFromTemplate", () => {
|
||||
it("falls back to an empty agent when the response is malformed", async () => {
|
||||
// The agent was created server-side even though the client can't
|
||||
// parse the response — UI code reads `agent.id === ""` and skips
|
||||
// the navigation step rather than landing on `/agents/`.
|
||||
stubFetchJson({ unexpected: "shape" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const resp = await client.createAgentFromTemplate({
|
||||
template_slug: "x",
|
||||
name: "X",
|
||||
runtime_id: "rt-1",
|
||||
});
|
||||
expect(resp.agent.id).toBe("");
|
||||
expect(resp.imported_skill_ids).toEqual([]);
|
||||
expect(resp.reused_skill_ids).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults imported_skill_ids / reused_skill_ids to [] when missing", async () => {
|
||||
stubFetchJson({ agent: { id: "agent-1" } });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const resp = await client.createAgentFromTemplate({
|
||||
template_slug: "x",
|
||||
name: "X",
|
||||
runtime_id: "rt-1",
|
||||
});
|
||||
expect(resp.agent.id).toBe("agent-1");
|
||||
expect(resp.imported_skill_ids).toEqual([]);
|
||||
expect(resp.reused_skill_ids).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Direct tests for the helper, decoupled from any specific endpoint —
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import type { Attachment, ListIssuesResponse, TimelineEntry } from "../types";
|
||||
import type {
|
||||
Agent,
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
Attachment,
|
||||
CreateAgentFromTemplateResponse,
|
||||
ListIssuesResponse,
|
||||
TimelineEntry,
|
||||
} from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas for the highest-risk API endpoints — those whose responses drive
|
||||
@@ -212,3 +220,89 @@ const DashboardAgentRunTimeSchema = z.object({
|
||||
}).loose();
|
||||
|
||||
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent template catalog — `/api/agent-templates*` and the
|
||||
// create-from-template response. The desktop app's create-agent picker
|
||||
// reaches these endpoints, and a future server change to the template shape
|
||||
// would white-screen older installed builds (#2192 pattern) without these
|
||||
// parsers. Lenient by the same rules as IssueSchema above: arrays default to
|
||||
// `[]`, optional fields stay optional, `.loose()` lets unknown fields pass
|
||||
// through unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AgentTemplateSkillRefSchema = z.object({
|
||||
source_url: z.string(),
|
||||
cached_name: z.string().default(""),
|
||||
cached_description: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
const AgentTemplateSummarySchemaBase = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().default(""),
|
||||
category: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
accent: z.string().optional(),
|
||||
// skills MUST default to [] — picker code reads `template.skills.length`
|
||||
// and `.map(...)`, both of which crash on `undefined`. The most common
|
||||
// future drift (field renamed / wrapped) lands here.
|
||||
skills: z.array(AgentTemplateSkillRefSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const AgentTemplateSummarySchema = AgentTemplateSummarySchemaBase;
|
||||
|
||||
// List endpoint historically returns a bare array. Server could legitimately
|
||||
// migrate to `{templates: [...]}` later — we accept either shape so an old
|
||||
// desktop survives the upgrade.
|
||||
export const AgentTemplateSummaryListSchema = z.union([
|
||||
z.array(AgentTemplateSummarySchemaBase),
|
||||
z.object({ templates: z.array(AgentTemplateSummarySchemaBase).default([]) })
|
||||
.loose()
|
||||
.transform((v) => v.templates),
|
||||
]);
|
||||
|
||||
export const EMPTY_AGENT_TEMPLATE_SUMMARY_LIST: AgentTemplateSummary[] = [];
|
||||
|
||||
export const AgentTemplateSchema = AgentTemplateSummarySchemaBase.extend({
|
||||
// Detail-only field. Default "" so a malformed detail still renders the
|
||||
// header + skill list; the user just sees an empty Instructions block.
|
||||
instructions: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
// Used as the parse fallback for `GET /api/agent-templates/:slug`. Slug comes
|
||||
// from the URL, so we round-trip the requested one back into the fallback
|
||||
// at the call site (see `getAgentTemplate` in client.ts).
|
||||
export const EMPTY_AGENT_TEMPLATE_DETAIL: AgentTemplate = {
|
||||
slug: "",
|
||||
name: "",
|
||||
description: "",
|
||||
skills: [],
|
||||
instructions: "",
|
||||
};
|
||||
|
||||
// `agent` is a full Agent record — schematising every field would duplicate
|
||||
// a 50-field interface and bit-rot fast. We keep it loose and require only
|
||||
// `id`, the one field the create-from-template flow consumes (used to
|
||||
// navigate to the new agent's detail page). Downstream code already
|
||||
// optional-chains the rest.
|
||||
const MinimalAgentSchema = z.object({
|
||||
id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CreateAgentFromTemplateResponseSchema = z.object({
|
||||
agent: MinimalAgentSchema,
|
||||
imported_skill_ids: z.array(z.string()).default([]),
|
||||
reused_skill_ids: z.array(z.string()).default([]),
|
||||
}).loose();
|
||||
|
||||
// Fallback when the success response fails to parse. The agent server-side
|
||||
// has likely been created already, so we can't pretend nothing happened —
|
||||
// the caller (`create-agent-dialog.tsx`) is responsible for noticing
|
||||
// `agent.id === ""` and skipping navigation while keeping the list
|
||||
// invalidation, so the user finds their new agent in the list.
|
||||
export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateResponse = {
|
||||
agent: { id: "" } as Agent,
|
||||
imported_skill_ids: [],
|
||||
reused_skill_ids: [],
|
||||
};
|
||||
|
||||
@@ -168,6 +168,76 @@ export interface CreateAgentRequest {
|
||||
template?: string;
|
||||
}
|
||||
|
||||
/** Agent template summary — fields needed by the picker grid. Does NOT
|
||||
* include `instructions` to keep the list payload small; the detail
|
||||
* endpoint or the create flow returns the full template body. */
|
||||
export interface AgentTemplateSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Optional grouping for the picker UI ("Engineering" / "Writing" / …). */
|
||||
category?: string;
|
||||
/** Optional lucide-react icon name (e.g. "Search"). Frontend falls back
|
||||
* to a generic icon when empty. */
|
||||
icon?: string;
|
||||
/** Optional semantic color token for the icon badge — one of "info" /
|
||||
* "success" / "warning" / "primary" / "secondary". Frontend has a
|
||||
* static class map so Tailwind can JIT-scan all variants. */
|
||||
accent?: string;
|
||||
skills: AgentTemplateSkillRef[];
|
||||
}
|
||||
|
||||
/** Full agent template — same as `AgentTemplateSummary` plus the
|
||||
* instructions block. Returned by `GET /api/agent-templates/:slug`. */
|
||||
export interface AgentTemplate extends AgentTemplateSummary {
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
/** Skill reference inside an agent template. `source_url` is the upstream
|
||||
* GitHub / skills.sh URL fetched on create; `cached_*` mirror the upstream
|
||||
* frontmatter at template-author time and let the picker render without
|
||||
* HTTP fetches. */
|
||||
export interface AgentTemplateSkillRef {
|
||||
source_url: string;
|
||||
cached_name: string;
|
||||
cached_description: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentFromTemplateRequest {
|
||||
template_slug: string;
|
||||
name: string;
|
||||
runtime_id: string;
|
||||
model?: string;
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
/** Optional overrides applied to the template before creation. nil/omit
|
||||
* uses the template's own value. */
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
avatar_url?: string;
|
||||
/** Workspace skill IDs attached **in addition to** the template's
|
||||
* skills. Server dedupes against template skills automatically. */
|
||||
extra_skill_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CreateAgentFromTemplateResponse {
|
||||
agent: Agent;
|
||||
/** Skill IDs that were newly created in the workspace from upstream URLs. */
|
||||
imported_skill_ids: string[];
|
||||
/** Skill IDs that already existed in the workspace (same name) and were
|
||||
* reused rather than re-imported. The UI can surface this as a toast so
|
||||
* the user knows their pre-existing skill wasn't overwritten. */
|
||||
reused_skill_ids: string[];
|
||||
}
|
||||
|
||||
/** 422 body returned by `POST /api/agents/from-template` when one or more
|
||||
* template skill URLs cannot be reached. The transaction is rolled back —
|
||||
* no partial workspace state. */
|
||||
export interface CreateAgentFromTemplateFailure {
|
||||
error: string;
|
||||
failed_urls: string[];
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
|
||||
@@ -11,6 +11,12 @@ export type {
|
||||
AgentRuntime,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
AgentTemplateSkillRef,
|
||||
CreateAgentFromTemplateRequest,
|
||||
CreateAgentFromTemplateResponse,
|
||||
CreateAgentFromTemplateFailure,
|
||||
UpdateAgentRequest,
|
||||
Skill,
|
||||
SkillSummary,
|
||||
|
||||
@@ -208,7 +208,7 @@ export function AgentOverviewPane({
|
||||
);
|
||||
}
|
||||
|
||||
// Centred, max-width container shared by every config tab. `h-full flex
|
||||
// Padded, full-width container shared by every config tab. `h-full flex
|
||||
// flex-col` lets a tab opt into "fill the viewport" by giving its root
|
||||
// element `flex-1 min-h-0` (Instructions does this so the editor expands
|
||||
// instead of pushing the Save row off-screen). Tabs that don't opt in
|
||||
@@ -216,6 +216,6 @@ export function AgentOverviewPane({
|
||||
// list) still scrolls via the parent's overflow-y-auto.
|
||||
function TabContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-2xl flex-col p-4 md:p-6">{children}</div>
|
||||
<div className="flex h-full flex-col p-4 md:p-6">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,35 +280,24 @@ export function AgentsPage() {
|
||||
if (view === "archived" && archivedCount === 0) setView("active");
|
||||
}, [view, archivedCount]);
|
||||
|
||||
const handleCreate = async (data: CreateAgentRequest) => {
|
||||
const handleCreate = async (data: CreateAgentRequest): Promise<Agent> => {
|
||||
const agent = await api.createAgent(data);
|
||||
let cachedAgent = agent;
|
||||
// When duplicating, carry the source agent's skill assignments over.
|
||||
// Skills aren't part of CreateAgentRequest (they're managed via
|
||||
// setAgentSkills) so the create endpoint can't take them inline; we
|
||||
// do a follow-up call. Failure here doesn't abort the duplicate —
|
||||
// the agent already exists and the user can re-attach skills from
|
||||
// the detail page.
|
||||
if (duplicateTemplate?.skills.length) {
|
||||
try {
|
||||
await api.setAgentSkills(agent.id, {
|
||||
skill_ids: duplicateTemplate.skills.map((s) => s.id),
|
||||
});
|
||||
cachedAgent = { ...agent, skills: duplicateTemplate.skills };
|
||||
} catch {
|
||||
// Surfaced softly; the agent itself is fine.
|
||||
}
|
||||
}
|
||||
// Skill follow-up is now owned by the dialog (it reads the user's
|
||||
// form selection, which already includes the duplicate source's
|
||||
// skills as a default when applicable). The dialog will call
|
||||
// setAgentSkills after we return; we just have to surface the
|
||||
// created agent so it can.
|
||||
qc.setQueryData<Agent[]>(workspaceKeys.agents(wsId), (current = []) => {
|
||||
const exists = current.some((a) => a.id === cachedAgent.id);
|
||||
const exists = current.some((a) => a.id === agent.id);
|
||||
return exists
|
||||
? current.map((a) => (a.id === cachedAgent.id ? cachedAgent : a))
|
||||
: [...current, cachedAgent];
|
||||
? current.map((a) => (a.id === agent.id ? agent : a))
|
||||
: [...current, agent];
|
||||
});
|
||||
setShowCreate(false);
|
||||
setDuplicateTemplate(null);
|
||||
navigation.push(paths.agentDetail(agent.id));
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
return agent;
|
||||
};
|
||||
|
||||
const handleDuplicate = useCallback((agent: Agent) => {
|
||||
@@ -467,6 +456,7 @@ export function AgentsPage() {
|
||||
members={members}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
template={duplicateTemplate}
|
||||
existingAgentNames={agents.map((a) => a.name)}
|
||||
onClose={() => {
|
||||
setShowCreate(false);
|
||||
setDuplicateTemplate(null);
|
||||
|
||||
143
packages/views/agents/components/avatar-picker.tsx
Normal file
143
packages/views/agents/components/avatar-picker.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Camera, ImagePlus, Loader2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface AvatarPickerProps {
|
||||
/** Current avatar URL. null when nothing chosen yet. */
|
||||
value: string | null;
|
||||
/** Fires after a successful upload — the parent stashes the URL for the
|
||||
* create call. Re-fires with null when the user clears the choice. */
|
||||
onChange: (url: string | null) => void;
|
||||
/** Pixel size of the square. Defaults to 56 (h-14 / w-14), which lines
|
||||
* up vertically with the Name + Description stack in the create-agent
|
||||
* form so the two read as a single visual row. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact avatar picker — a single square that lives next to the Name
|
||||
* input in the create-agent form. Mirrors the visual language of
|
||||
* agent-detail-inspector.tsx (Camera overlay on hover, file input behind
|
||||
* the scenes), so users who've configured an avatar elsewhere in the app
|
||||
* recognise the affordance immediately.
|
||||
*
|
||||
* No avatar yet → dashed placeholder with an ImagePlus icon.
|
||||
* Has avatar → image fills the square, hover dims it with a Camera
|
||||
* overlay for "click to change". A small × in the corner
|
||||
* clears the choice.
|
||||
*/
|
||||
export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps) {
|
||||
const { t } = useT("agents");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = ""; // allow re-selecting the same file
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error(t(($) => $.create_dialog.avatar.select_image_toast));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await upload(file);
|
||||
if (!result) return;
|
||||
setPreviewError(false);
|
||||
onChange(result.link);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.create_dialog.avatar.upload_failed_toast),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasValue = !!value && !previewError;
|
||||
const dimensionStyle = { width: size, height: size };
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0" style={dimensionStyle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className={cn(
|
||||
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
hasValue
|
||||
? "border bg-muted"
|
||||
: "border border-dashed bg-muted/40 hover:bg-muted",
|
||||
)}
|
||||
aria-label={
|
||||
hasValue
|
||||
? t(($) => $.create_dialog.avatar.change_aria)
|
||||
: t(($) => $.create_dialog.avatar.upload_aria)
|
||||
}
|
||||
style={dimensionStyle}
|
||||
>
|
||||
{hasValue ? (
|
||||
<img
|
||||
src={value ?? undefined}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setPreviewError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay only when there's already an image — otherwise the
|
||||
placeholder icon already invites the click. */}
|
||||
{hasValue && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-white" />
|
||||
) : (
|
||||
<Camera className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tiny X to clear, only shown when there's a value. Positioned just
|
||||
outside the avatar's top-right corner so it doesn't cover the
|
||||
image. */}
|
||||
{hasValue && !uploading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
setPreviewError(false);
|
||||
}}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t(($) => $.create_dialog.avatar.remove_aria)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { Agent, MemberWithUser, RuntimeDevice } from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { NavigationProvider, type NavigationAdapter } from "../../navigation";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enAgents from "../../locales/en/agents.json";
|
||||
|
||||
const navigationStub: NavigationAdapter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: "/",
|
||||
searchParams: new URLSearchParams(),
|
||||
getShareableUrl: (path: string) => path,
|
||||
};
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
@@ -120,22 +131,42 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
|
||||
render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={ME}
|
||||
template={template}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
<WorkspaceSlugProvider slug="test-ws">
|
||||
<NavigationProvider value={navigationStub}>
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={ME}
|
||||
template={template}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</NavigationProvider>
|
||||
</WorkspaceSlugProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
// Without a `template`, the dialog opens on the blank-vs-template
|
||||
// chooser. These tests target the manual form's runtime picker, so
|
||||
// advance through the chooser to the form. Duplicate mode jumps
|
||||
// straight to the form and doesn't render the chooser.
|
||||
if (!template) {
|
||||
fireEvent.click(screen.getByText(enAgents.create_dialog.chooser.blank_title));
|
||||
}
|
||||
return { onCreate, onClose };
|
||||
}
|
||||
|
||||
describe("CreateAgentDialog runtime visibility gate", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
// Base UI Dialog renders into a portal on document.body and leaves
|
||||
// focus-guard / inert wrapper divs around after the React tree unmounts.
|
||||
// The auto-cleanup from @testing-library/react drops the container but
|
||||
// not the portal residue, so two-tests-in-a-row queries see double
|
||||
// matches ("All", "My Runtime"). Force cleanup + wipe body between tests.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("disables another member's private runtime in the picker", () => {
|
||||
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
116
packages/views/agents/components/instructions-editor.tsx
Normal file
116
packages/views/agents/components/instructions-editor.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ChevronDown, FileText, X } from "lucide-react";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface InstructionsEditorProps {
|
||||
/** Markdown source. Used both as default value when expanded and as
|
||||
* preview text when collapsed. */
|
||||
value: string;
|
||||
/** Fires on every keystroke (debounced inside ContentEditor). */
|
||||
onChange: (value: string) => void;
|
||||
/** Optional placeholder override. Defaults to the i18n "click to write"
|
||||
* copy; the create dialog passes the duplicate-specific string for
|
||||
* agents being cloned. */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible Instructions field for the create-agent dialog. Stays compact
|
||||
* until the user wants to write — most agents only need instructions when
|
||||
* they're being authored carefully, not on every quick-create.
|
||||
*
|
||||
* Two states:
|
||||
* collapsed → small clickable card, shows a preview of `value` (or the
|
||||
* placeholder when empty). One click expands.
|
||||
* expanded → full ContentEditor (markdown, bubble menu, mention support,
|
||||
* attachment upload). "Collapse" button on the right of the
|
||||
* header tucks it back; value is preserved.
|
||||
*/
|
||||
export function InstructionsEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: InstructionsEditorProps) {
|
||||
const { t } = useT("agents");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
|
||||
const label = t(($) => $.create_dialog.instructions.label);
|
||||
const resolvedPlaceholder =
|
||||
placeholder ?? t(($) => $.create_dialog.instructions.placeholder_blank);
|
||||
|
||||
const expand = () => {
|
||||
setExpanded(true);
|
||||
// Focus on next tick so the editor mounts first.
|
||||
setTimeout(() => editorRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={expand}
|
||||
className="mt-1.5 flex w-full items-start gap-2.5 rounded-lg border bg-card px-3 py-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<FileText className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{value.trim() ? (
|
||||
// Preview: first 2 lines of markdown, ellipsised.
|
||||
<div className="line-clamp-2 whitespace-pre-wrap text-sm text-foreground/80">
|
||||
{value}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{resolvedPlaceholder}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="h-6 gap-1 px-2 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t(($) => $.create_dialog.instructions.collapse)}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 rounded-lg border bg-card",
|
||||
"focus-within:border-primary/40",
|
||||
)}
|
||||
>
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={value}
|
||||
onUpdate={onChange}
|
||||
placeholder={t(($) => $.create_dialog.instructions.editor_placeholder)}
|
||||
className="min-h-[160px] max-h-[320px] overflow-y-auto px-3 py-2.5 text-sm"
|
||||
showBubbleMenu={true}
|
||||
disableMentions={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import type { Agent, SkillSummary } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
@@ -20,18 +19,19 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { useT } from "../../i18n";
|
||||
import { SkillPickerList } from "./skill-picker-list";
|
||||
|
||||
/**
|
||||
* Single source of truth for "attach a workspace skill to this agent".
|
||||
* Used by both:
|
||||
* - SkillsTab — full surface, "Add skill" button
|
||||
* - Inspector → SkillAttach — inline dashed `+ Attach` chip
|
||||
* "Attach workspace skills to this agent." Multi-select with explicit
|
||||
* Confirm — earlier iterations attached on a single row click, which
|
||||
* meant the user couldn't tick several skills at once and the dialog
|
||||
* closed before they could review their choice.
|
||||
*
|
||||
* Owns the workspace-skill list query, the "what's still attachable" filter,
|
||||
* the API call, and the optimistic invalidation. Callers only manage the
|
||||
* open/close state — they don't repeat the attach logic.
|
||||
* Already-attached skills are filtered out of the list entirely (vs.
|
||||
* showing them disabled). When there are no remaining workspace skills
|
||||
* to attach, the empty-state copy explains why, and the Confirm button
|
||||
* is naturally disabled because nothing can be selected.
|
||||
*/
|
||||
export function SkillAddDialog({
|
||||
agent,
|
||||
@@ -45,34 +45,44 @@ export function SkillAddDialog({
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const { data: workspaceSkills = [], isLoading } = useQuery(skillListOptions(wsId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
|
||||
const availableSkills = workspaceSkills.filter(
|
||||
(s) => !agentSkillIds.has(s.id),
|
||||
const attachedIds = useMemo(
|
||||
() => new Set(agent.skills.map((s) => s.id)),
|
||||
[agent.skills],
|
||||
);
|
||||
// Hide attached skills outright — the dialog is for adding new ones.
|
||||
// If a user wants to see what's already on the agent, the SkillsTab
|
||||
// list above shows it.
|
||||
const availableSkills = useMemo(
|
||||
() => workspaceSkills.filter((s) => !attachedIds.has(s.id)),
|
||||
[workspaceSkills, attachedIds],
|
||||
);
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const filteredSkills = trimmedQuery
|
||||
? availableSkills.filter((s) => {
|
||||
const name = s.name.toLowerCase();
|
||||
const description = s.description?.toLowerCase() ?? "";
|
||||
return (
|
||||
name.includes(trimmedQuery) || description.includes(trimmedQuery)
|
||||
);
|
||||
})
|
||||
: availableSkills;
|
||||
|
||||
const handleOpenChange = (v: boolean) => {
|
||||
if (!v) setQuery("");
|
||||
if (!v) setSelectedIds(new Set());
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
const handleAdd = async (skillId: string) => {
|
||||
const handleToggle = (skill: SkillSummary) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(skill.id)) next.delete(skill.id);
|
||||
else next.add(skill.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||
const newIds = [
|
||||
...agent.skills.map((s) => s.id),
|
||||
...selectedIds,
|
||||
];
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
handleOpenChange(false);
|
||||
@@ -83,65 +93,46 @@ export function SkillAddDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const showSearch = availableSkills.length > 0;
|
||||
const noMatch = showSearch && filteredSkills.length === 0;
|
||||
const count = selectedIds.size;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{t(($) => $.tab_body.skills.add_dialog_title)}</DialogTitle>
|
||||
<DialogTitle className="text-sm">
|
||||
{t(($) => $.tab_body.skills.add_dialog_title)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{t(($) => $.tab_body.skills.add_dialog_description)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{showSearch && (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
|
||||
aria-label={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
|
||||
className="pl-7"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||
{filteredSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
onClick={() => handleAdd(skill.id)}
|
||||
disabled={saving}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{availableSkills.length === 0 && (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.skills.add_dialog_empty)}
|
||||
</p>
|
||||
)}
|
||||
{noMatch && (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.skills.add_dialog_no_match)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SkillPickerList
|
||||
skills={availableSkills}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={handleToggle}
|
||||
loading={isLoading}
|
||||
emptyMessage={
|
||||
workspaceSkills.length === 0
|
||||
? t(($) => $.tab_body.skills.add_dialog_empty)
|
||||
: t(($) => $.tab_body.skills.add_dialog_empty_partial)
|
||||
}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
|
||||
{t(($) => $.tab_body.skills.add_dialog_cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={count === 0 || saving}
|
||||
>
|
||||
{saving
|
||||
? t(($) => $.tab_body.skills.add_dialog_saving)
|
||||
: count > 0
|
||||
? t(($) => $.tab_body.skills.add_dialog_confirm, { count })
|
||||
: t(($) => $.tab_body.skills.add_dialog_confirm_default)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
104
packages/views/agents/components/skill-multi-select.tsx
Normal file
104
packages/views/agents/components/skill-multi-select.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Plus, X } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { SkillSummary } from "@multica/core/types";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { skillListOptions } from "@multica/core/workspace/queries";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useT } from "../../i18n";
|
||||
import { SkillPickerList } from "./skill-picker-list";
|
||||
|
||||
interface SkillMultiSelectProps {
|
||||
/** Currently-selected skill IDs (controlled). */
|
||||
selectedIds: ReadonlySet<string>;
|
||||
/** Replaces the selection on every toggle. */
|
||||
onChange: (next: Set<string>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select wrapper for the create-agent form. Collapsed by default;
|
||||
* expands into a SkillPickerList configured for toggle behaviour
|
||||
* (click adds to / removes from the local selection set).
|
||||
*
|
||||
* Shares its visual surface with SkillAddDialog via SkillPickerList —
|
||||
* one component owns search + row rendering + indicators, so a tweak
|
||||
* to either appears identically in both flows.
|
||||
*/
|
||||
export function SkillMultiSelect({
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: SkillMultiSelectProps) {
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: workspaceSkills = [], isLoading } = useQuery(skillListOptions(wsId));
|
||||
const [expanded, setExpanded] = useState(selectedIds.size > 0);
|
||||
|
||||
const label = t(($) => $.create_dialog.skills_section.label);
|
||||
|
||||
const toggle = (skill: SkillSummary) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(skill.id)) next.delete(skill.id);
|
||||
else next.add(skill.id);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="mt-1.5 flex w-full items-center gap-2.5 rounded-lg border bg-card px-3 py-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
{selectedIds.size > 0
|
||||
? t(($) => $.create_dialog.skills_section.selected, {
|
||||
count: selectedIds.size,
|
||||
})
|
||||
: t(($) => $.create_dialog.skills_section.placeholder)}
|
||||
</div>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
{selectedIds.size > 0 ? (
|
||||
<span className="ml-2 text-foreground/60">({selectedIds.size})</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="h-6 gap-1 px-2 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t(($) => $.create_dialog.skills_section.collapse)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5">
|
||||
<SkillPickerList
|
||||
skills={workspaceSkills}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggle}
|
||||
loading={isLoading}
|
||||
emptyMessage={t(($) => $.create_dialog.skills_section.list_empty_multi)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
packages/views/agents/components/skill-picker-list.tsx
Normal file
141
packages/views/agents/components/skill-picker-list.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import type { SkillSummary } from "@multica/core/types";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface SkillPickerListProps {
|
||||
/** Skills to show. Callers filter (e.g. exclude already-attached
|
||||
* skills in SkillAddDialog) before passing — this component just
|
||||
* renders the rows. */
|
||||
skills: readonly SkillSummary[];
|
||||
|
||||
/** Currently-toggled rows. Selected rows get a checked Checkbox and a
|
||||
* subtle background; click toggles. */
|
||||
selectedIds: ReadonlySet<string>;
|
||||
|
||||
/** Fires on every row click. Caller updates `selectedIds`. */
|
||||
onToggle: (skill: SkillSummary) => void;
|
||||
|
||||
/** Show the search input at the top. Default true. */
|
||||
searchable?: boolean;
|
||||
|
||||
/** Loading state for the skills query. */
|
||||
loading?: boolean;
|
||||
|
||||
/** Caller-supplied empty / no-match copy. Falls back to generic i18n
|
||||
* strings when omitted — the dialog and the create-form pass their
|
||||
* own flavour-specific copy. */
|
||||
emptyMessage?: string;
|
||||
noMatchMessage?: string;
|
||||
|
||||
/** Outer-wrapper className. Defaults to `w-full`; callers pass
|
||||
* e.g. `max-w-md` to constrain width. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless multi-select list of workspace skills. Used by both
|
||||
* SkillAddDialog (filtered to unattached skills) and SkillMultiSelect
|
||||
* (create-form selection). One surface owns row layout, the search
|
||||
* input, empty/loading states, and the shadcn Checkbox indicator, so
|
||||
* tweaks land in one place.
|
||||
*
|
||||
* Rows truncate the name + description columns inside `flex-1 min-w-0`
|
||||
* so long text doesn't push the Checkbox out of view.
|
||||
*/
|
||||
export function SkillPickerList({
|
||||
skills,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
searchable = true,
|
||||
loading = false,
|
||||
emptyMessage,
|
||||
noMatchMessage,
|
||||
className,
|
||||
}: SkillPickerListProps) {
|
||||
const { t } = useT("agents");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const filtered = trimmedQuery
|
||||
? skills.filter((s) => {
|
||||
const name = s.name.toLowerCase();
|
||||
const description = s.description?.toLowerCase() ?? "";
|
||||
return name.includes(trimmedQuery) || description.includes(trimmedQuery);
|
||||
})
|
||||
: skills;
|
||||
|
||||
const resolvedEmpty =
|
||||
emptyMessage ?? t(($) => $.create_dialog.skills_section.list_empty_default);
|
||||
const resolvedNoMatch =
|
||||
noMatchMessage ?? t(($) => $.create_dialog.skills_section.list_no_match);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full overflow-hidden rounded-lg border bg-card", className)}>
|
||||
{searchable && skills.length > 0 && (
|
||||
<div className="border-b p-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t(($) => $.create_dialog.skills_section.search_placeholder)}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-64 space-y-0.5 overflow-y-auto p-1.5">
|
||||
{loading ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.skills_section.list_loading)}
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">{resolvedEmpty}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">{resolvedNoMatch}</div>
|
||||
) : (
|
||||
filtered.map((skill) => {
|
||||
const isSelected = selectedIds.has(skill.id);
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(skill)}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors",
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{/* Indicator only — the wrapping <button> handles clicks,
|
||||
so the Checkbox is non-interactive on its own. We
|
||||
pass `checked` so the visual matches the row state. */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{skill.name}</div>
|
||||
{skill.description ? (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,17 +24,16 @@ export function SkillsTab({
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
// Same query the SkillAddDialog uses (TanStack Query dedupes by key, so
|
||||
// this isn't an extra request) — used here only to grey out the "Add skill"
|
||||
// button when there's nothing left to attach.
|
||||
// this isn't an extra request) — used here only to grey out the "Add
|
||||
// skill" button when the workspace has zero skills total. When skills
|
||||
// exist but are all already attached, we still open the dialog: it
|
||||
// filters out attached skills and renders a localised "no more skills
|
||||
// to add" empty state, which is more useful than a mysterious
|
||||
// greyed-out button.
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
|
||||
const availableCount = workspaceSkills.filter(
|
||||
(s) => !agentSkillIds.has(s.id),
|
||||
).length;
|
||||
|
||||
const handleRemove = async (skillId: string) => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
@@ -60,7 +59,7 @@ export function SkillsTab({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAdd(true)}
|
||||
disabled={availableCount === 0}
|
||||
disabled={workspaceSkills.length === 0}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
@@ -84,7 +83,7 @@ export function SkillsTab({
|
||||
<p className="mt-1 max-w-xs text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.skills.empty_hint)}
|
||||
</p>
|
||||
{availableCount > 0 && (
|
||||
{workspaceSkills.length > 0 && (
|
||||
<Button
|
||||
onClick={() => setShowAdd(true)}
|
||||
size="sm"
|
||||
|
||||
392
packages/views/agents/components/template-detail.tsx
Normal file
392
packages/views/agents/components/template-detail.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
Loader2,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentTemplateDetailOptions } from "@multica/core/agents/queries";
|
||||
import { runtimeModelsOptions } from "@multica/core/runtimes";
|
||||
import type {
|
||||
AgentTemplateSummary,
|
||||
MemberWithUser,
|
||||
RuntimeDevice,
|
||||
} from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { ModelDropdown } from "./model-dropdown";
|
||||
import { getAccentClass, getTemplateIcon } from "./template-picker";
|
||||
|
||||
export interface TemplateDetailUseOptions {
|
||||
runtimeId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface TemplateDetailProps {
|
||||
template: AgentTemplateSummary;
|
||||
/** Workspace runtimes — used to populate the runtime picker. */
|
||||
runtimes: RuntimeDevice[];
|
||||
runtimesLoading?: boolean;
|
||||
/** Members of the workspace, used to label runtime owners. */
|
||||
members: MemberWithUser[];
|
||||
/** Current user id, used to grey-out private runtimes owned by others. */
|
||||
currentUserId: string | null;
|
||||
/** Fired when the user clicks "Use this template". The dialog calls the
|
||||
* create API with the runtime + model the user picked here. */
|
||||
onUse: (template: AgentTemplateSummary, options: TemplateDetailUseOptions) => void;
|
||||
/** True while the parent's create request is in flight; we disable the
|
||||
* Use button so the user can't double-click. */
|
||||
creating?: boolean;
|
||||
/** Upstream URLs the server reported as unreachable on the most recent
|
||||
* create attempt. Surfaces an inline error banner so the user knows
|
||||
* *why* Create didn't navigate. */
|
||||
failedURLs?: readonly string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the create-agent flow: a read-only preview of the picked
|
||||
* template — runtime + model picker, instructions, skill list, and a
|
||||
* "Use this template" CTA. The CTA stays disabled until the user picks
|
||||
* a runtime *and* a model (or the runtime explicitly doesn't support
|
||||
* per-agent model selection, in which case model is auto-cleared and
|
||||
* not required).
|
||||
*/
|
||||
export function TemplateDetail({
|
||||
template,
|
||||
runtimes,
|
||||
runtimesLoading,
|
||||
members,
|
||||
currentUserId,
|
||||
onUse,
|
||||
creating = false,
|
||||
failedURLs,
|
||||
}: TemplateDetailProps) {
|
||||
const { t } = useT("agents");
|
||||
const { data: detail, isLoading, error } = useQuery(
|
||||
agentTemplateDetailOptions(template.slug),
|
||||
);
|
||||
|
||||
const Icon = getTemplateIcon(template.icon);
|
||||
const accentClass = getAccentClass(template.accent);
|
||||
|
||||
// Runtime + model state — local to this step so the form path's own
|
||||
// selection is untouched. User must pick both before Use is enabled.
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState<string>("");
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
|
||||
const isRuntimeDisabledForUser = (r: RuntimeDevice): boolean => {
|
||||
if (!currentUserId) return false;
|
||||
if (r.owner_id === currentUserId) return false;
|
||||
return r.visibility !== "public";
|
||||
};
|
||||
|
||||
const sortedRuntimes = useMemo(() => {
|
||||
return [...runtimes].sort((a, b) => {
|
||||
const aMine = a.owner_id === currentUserId;
|
||||
const bMine = b.owner_id === currentUserId;
|
||||
if (aMine && !bMine) return -1;
|
||||
if (!aMine && bMine) return 1;
|
||||
const aDisabled = isRuntimeDisabledForUser(a);
|
||||
const bDisabled = isRuntimeDisabledForUser(b);
|
||||
if (!aDisabled && bDisabled) return -1;
|
||||
if (aDisabled && !bDisabled) return 1;
|
||||
return 0;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runtimes, currentUserId]);
|
||||
|
||||
const selectedRuntime =
|
||||
runtimes.find((r) => r.id === selectedRuntimeId) ?? null;
|
||||
const selectedRuntimeLocked =
|
||||
selectedRuntime != null && isRuntimeDisabledForUser(selectedRuntime);
|
||||
|
||||
const getOwnerMember = (ownerId: string | null) => {
|
||||
if (!ownerId) return null;
|
||||
return members.find((m) => m.user_id === ownerId) ?? null;
|
||||
};
|
||||
|
||||
// Query the selected runtime's model catalog so we can tell whether the
|
||||
// runtime supports per-agent model selection at all. Cached by TanStack
|
||||
// Query so ModelDropdown's own subscription reuses the same data.
|
||||
const modelsQuery = useQuery(
|
||||
runtimeModelsOptions(
|
||||
selectedRuntime?.status === "online" ? selectedRuntime.id : null,
|
||||
),
|
||||
);
|
||||
const modelSupported = modelsQuery.data?.supported ?? true;
|
||||
|
||||
// Use CTA is enabled only when:
|
||||
// - a runtime is picked and not locked
|
||||
// - either the runtime doesn't support per-agent model selection
|
||||
// (model is irrelevant), or the user picked a non-empty model.
|
||||
const modelSatisfied = !modelSupported || model.trim() !== "";
|
||||
const canUse =
|
||||
!creating &&
|
||||
!!selectedRuntime &&
|
||||
!selectedRuntimeLocked &&
|
||||
modelSatisfied;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
{/* failedURLs banner — sits above the header so it's the first
|
||||
thing the user sees after the spinner clears on a 422. */}
|
||||
{failedURLs && failedURLs.length > 0 && (
|
||||
<div className="mb-5 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
||||
<div className="font-medium text-destructive">
|
||||
{t(($) => $.create_dialog.template_failure.title)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_failure.body)}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-0.5 text-xs">
|
||||
{failedURLs.map((u) => (
|
||||
<li key={u} className="break-all font-mono">
|
||||
{u}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header: icon + name + description. Same rhythm as the picker
|
||||
card so the user reads the transition as "the same item,
|
||||
expanded". */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn("flex h-12 w-12 shrink-0 items-center justify-center rounded-lg", accentClass)}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold">{template.name}</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{template.description}</p>
|
||||
{template.category ? (
|
||||
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{template.category}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime + model selectors — required before Use is enabled.
|
||||
Two-column grid so they read as a single configuration row. */}
|
||||
<section className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.runtime_label)}
|
||||
</Label>
|
||||
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={runtimes.length === 0 && !runtimesLoading}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{runtimesLoading ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : selectedRuntime ? (
|
||||
<ProviderLogo provider={selectedRuntime.provider} className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{runtimesLoading
|
||||
? t(($) => $.create_dialog.runtime_loading)
|
||||
: selectedRuntime?.name ?? t(($) => $.create_dialog.runtime_none)}
|
||||
</span>
|
||||
{selectedRuntime?.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{selectedRuntime
|
||||
? getOwnerMember(selectedRuntime.owner_id)?.name ?? selectedRuntime.device_info
|
||||
: t(($) => $.create_dialog.runtime_register_first)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto"
|
||||
>
|
||||
{sortedRuntimes.map((device) => {
|
||||
const ownerMember = getOwnerMember(device.owner_id);
|
||||
const disabled = isRuntimeDisabledForUser(device);
|
||||
const disabledTitle = disabled
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabledTitle}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setSelectedRuntimeId(device.id);
|
||||
// Picking a new runtime clears the model — the
|
||||
// catalog (and "supported" flag) changes per
|
||||
// runtime, so any previously-picked model is
|
||||
// potentially invalid.
|
||||
setModel("");
|
||||
setRuntimeOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: device.id === selectedRuntimeId
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
{device.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
{disabled && (
|
||||
<span className="shrink-0 inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{ownerMember ? (
|
||||
<>
|
||||
<ActorAvatar actorType="member" actorId={ownerMember.user_id} size={14} />
|
||||
<span className="truncate">{ownerMember.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{device.device_info}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<ModelDropdown
|
||||
runtimeId={selectedRuntime?.id ?? null}
|
||||
runtimeOnline={selectedRuntime?.status === "online"}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={!selectedRuntime}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Skill list */}
|
||||
<section className="mt-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_detail.skill_count, {
|
||||
count: template.skills.length,
|
||||
})}
|
||||
</h3>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{template.skills.map((s) => (
|
||||
<li
|
||||
key={s.source_url}
|
||||
className="rounded-lg border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
<span className="font-mono text-xs font-medium">{s.cached_name}</span>
|
||||
</div>
|
||||
{s.cached_description ? (
|
||||
<p className="mt-1 ml-6 text-xs text-muted-foreground">
|
||||
{s.cached_description}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Instructions — lazy fetch + loading/error states */}
|
||||
<section className="mt-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_detail.instructions_label)}
|
||||
</h3>
|
||||
<div className="mt-3 rounded-lg border bg-muted/30 px-4 py-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t(($) => $.create_dialog.template_detail.instructions_loading)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-destructive">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: t(($) => $.create_dialog.template_detail.load_failed)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="max-h-60 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/80">
|
||||
{detail?.instructions ?? ""}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky CTA footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
|
||||
<Button
|
||||
onClick={() =>
|
||||
selectedRuntime &&
|
||||
onUse(template, { runtimeId: selectedRuntime.id, model: model.trim() })
|
||||
}
|
||||
disabled={!canUse}
|
||||
title={
|
||||
selectedRuntimeLocked
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined
|
||||
}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t(($) => $.create_dialog.template_detail.creating)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t(($) => $.create_dialog.template_detail.use)}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
packages/views/agents/components/template-picker.tsx
Normal file
203
packages/views/agents/components/template-picker.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Brush,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Palette,
|
||||
PenLine,
|
||||
Presentation,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentTemplateListOptions } from "@multica/core/agents/queries";
|
||||
import type { AgentTemplateSummary } from "@multica/core/types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface TemplatePickerProps {
|
||||
/** Fired when a template card is clicked. The dialog advances to the
|
||||
* detail step (which shows instructions + skills + Use button). */
|
||||
onSelect: (template: AgentTemplateSummary) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 of the create-agent flow: a 2-column grid of template cards,
|
||||
* grouped by `category`. Clicking a card moves to the detail step.
|
||||
*
|
||||
* Templates are a static catalog (workspace-independent, only changes on
|
||||
* server deploy), so the catalog is loaded through TanStack Query with
|
||||
* `staleTime: Infinity` — re-opening the picker hits the cache instantly
|
||||
* and there's no per-mount refetch.
|
||||
*
|
||||
* Icons and accent colors come from the template JSON itself (`icon` is a
|
||||
* lucide-react name, `accent` is a Multica semantic token). Resolved
|
||||
* through static maps (ICONS / ACCENTS) so Tailwind can JIT-scan every
|
||||
* class variant — dynamic `bg-${accent}/10` strings would silently not
|
||||
* generate.
|
||||
*/
|
||||
export function TemplatePicker({ onSelect }: TemplatePickerProps) {
|
||||
const { t } = useT("agents");
|
||||
const { data: templates = [], isLoading, error } = useQuery(
|
||||
agentTemplateListOptions(),
|
||||
);
|
||||
|
||||
// Group by category. Templates without a category fall into the
|
||||
// localised "Other" bucket so they still render. Preserves the load
|
||||
// order within each group for deterministic UI (matches the
|
||||
// alphabetic-by-filename order the loader uses on the server).
|
||||
const otherCategory = t(($) => $.create_dialog.template_picker.other_category);
|
||||
const groups = useMemo(() => {
|
||||
const byCategory = new Map<string, AgentTemplateSummary[]>();
|
||||
for (const tmpl of templates) {
|
||||
const key = tmpl.category?.trim() ? tmpl.category : otherCategory;
|
||||
if (!byCategory.has(key)) byCategory.set(key, []);
|
||||
byCategory.get(key)!.push(tmpl);
|
||||
}
|
||||
return Array.from(byCategory.entries());
|
||||
}, [templates, otherCategory]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="text-sm text-destructive">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: t(($) => $.create_dialog.template_picker.load_failed)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_picker.empty)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
{groups.map(([category, tmpls]) => (
|
||||
<section key={category}>
|
||||
<h2 className="sticky top-0 z-10 -mx-6 border-b bg-background px-6 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{category}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-3 pt-3 md:grid-cols-2">
|
||||
{tmpls.map((tmpl) => (
|
||||
<TemplateCard
|
||||
key={tmpl.slug}
|
||||
template={tmpl}
|
||||
onClick={() => onSelect(tmpl)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: AgentTemplateSummary;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function TemplateCard({ template, onClick }: TemplateCardProps) {
|
||||
const { t } = useT("agents");
|
||||
const Icon = ICONS[template.icon ?? ""] ?? FileText;
|
||||
const accentClass = ACCENTS[template.accent ?? ""] ?? ACCENTS.muted;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group flex items-start gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
|
||||
accentClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-sm font-semibold">{template.name}</span>
|
||||
<ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="mt-2.5 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_card.skills, {
|
||||
count: template.skills.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Static maps so Tailwind's JIT scanner picks up every variant ---
|
||||
|
||||
/** Lucide icon name → component. Add new entries when shipping templates
|
||||
* that use icons not yet listed here. Unknown names fall back to FileText. */
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
Search,
|
||||
Palette,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
ListChecks,
|
||||
Brush,
|
||||
PenLine,
|
||||
Megaphone,
|
||||
Presentation,
|
||||
LayoutDashboard,
|
||||
};
|
||||
|
||||
/** Semantic accent → Tailwind class string. The class strings are written
|
||||
* out verbatim so JIT scans them; dynamic `bg-${name}/10` would not be
|
||||
* generated. Mirrors the conventions in runtime-columns.tsx /
|
||||
* usage-section.tsx (existing uses of these tokens). */
|
||||
const DEFAULT_ACCENT = "bg-muted text-muted-foreground";
|
||||
|
||||
const ACCENTS: Record<string, string> = {
|
||||
info: "bg-info/10 text-info",
|
||||
success: "bg-success/10 text-success",
|
||||
warning: "bg-warning/10 text-warning",
|
||||
primary: "bg-primary/10 text-primary",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
muted: DEFAULT_ACCENT,
|
||||
};
|
||||
|
||||
/** Exposed for the detail / form steps so they can render the same icon
|
||||
* badge as the picker card. Keeps visual continuity across steps. */
|
||||
export function getTemplateIcon(iconName: string | undefined): LucideIcon {
|
||||
return ICONS[iconName ?? ""] ?? FileText;
|
||||
}
|
||||
|
||||
export function getAccentClass(accent: string | undefined): string {
|
||||
return ACCENTS[accent ?? ""] ?? DEFAULT_ACCENT;
|
||||
}
|
||||
@@ -218,7 +218,67 @@
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
"cancel": "Cancel",
|
||||
"create_failed_toast": "Failed to create agent"
|
||||
"create_failed_toast": "Failed to create agent",
|
||||
"back_aria": "Back",
|
||||
"template_created_toast": "Agent \"{{name}}\" created",
|
||||
"template_created_with_reuse_toast_one": "Agent \"{{name}}\" created. Reused {{count}} existing skill.",
|
||||
"template_created_with_reuse_toast_other": "Agent \"{{name}}\" created. Reused {{count}} existing skills.",
|
||||
"skill_attach_failed_toast": "Agent created, but failed to attach skills: {{error}}",
|
||||
"chooser": {
|
||||
"blank_title": "Start blank",
|
||||
"blank_desc": "Write everything yourself.",
|
||||
"template_title": "From template",
|
||||
"template_desc": "Pre-configured agents with curated skills."
|
||||
},
|
||||
"template_picker": {
|
||||
"title": "Pick a template",
|
||||
"empty": "No templates available yet.",
|
||||
"load_failed": "Failed to load templates",
|
||||
"other_category": "Other"
|
||||
},
|
||||
"template_card": {
|
||||
"skills_one": "{{count}} skill",
|
||||
"skills_other": "{{count}} skills"
|
||||
},
|
||||
"template_detail": {
|
||||
"load_failed": "Failed to load template",
|
||||
"skill_count_one": "Includes {{count}} skill",
|
||||
"skill_count_other": "Includes {{count}} skills",
|
||||
"instructions_label": "Instructions",
|
||||
"instructions_loading": "Loading instructions…",
|
||||
"use": "Use this template",
|
||||
"creating": "Creating…"
|
||||
},
|
||||
"template_failure": {
|
||||
"title": "Some template skills are unavailable",
|
||||
"body": "The template can't be applied because one or more upstream URLs returned an error. No changes were written. Ping the template maintainer with the URLs below."
|
||||
},
|
||||
"instructions": {
|
||||
"label": "Instructions",
|
||||
"placeholder_blank": "Click to write instructions…",
|
||||
"placeholder_duplicate": "Click to view or edit the duplicated instructions",
|
||||
"collapse": "Collapse",
|
||||
"editor_placeholder": "Write what this agent should do, what to focus on, what to avoid…"
|
||||
},
|
||||
"skills_section": {
|
||||
"label": "Skills",
|
||||
"placeholder": "Add skills from workspace",
|
||||
"selected_one": "{{count}} selected — click to edit",
|
||||
"selected_other": "{{count}} selected — click to edit",
|
||||
"collapse": "Collapse",
|
||||
"list_empty_multi": "No skills in this workspace yet. Create or import one first.",
|
||||
"list_loading": "Loading…",
|
||||
"list_empty_default": "No skills available.",
|
||||
"list_no_match": "No skills match.",
|
||||
"search_placeholder": "Search skills…"
|
||||
},
|
||||
"avatar": {
|
||||
"change_aria": "Change avatar",
|
||||
"upload_aria": "Upload avatar",
|
||||
"remove_aria": "Remove avatar",
|
||||
"select_image_toast": "Please select an image file.",
|
||||
"upload_failed_toast": "Avatar upload failed"
|
||||
}
|
||||
},
|
||||
"tab_body": {
|
||||
"common": {
|
||||
@@ -266,6 +326,11 @@
|
||||
"add_dialog_search_placeholder": "Search skills",
|
||||
"add_dialog_empty": "All workspace skills are already assigned.",
|
||||
"add_dialog_no_match": "No skills match your search.",
|
||||
"add_dialog_empty_partial": "No more skills to add — this agent already has all of them.",
|
||||
"add_dialog_saving": "Adding…",
|
||||
"add_dialog_confirm_default": "Add",
|
||||
"add_dialog_confirm_one": "Add {{count}} skill",
|
||||
"add_dialog_confirm_other": "Add {{count}} skills",
|
||||
"add_dialog_cancel": "Cancel",
|
||||
"add_failed_toast": "Failed to add skill"
|
||||
},
|
||||
|
||||
@@ -214,7 +214,63 @@
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
"cancel": "取消",
|
||||
"create_failed_toast": "创建智能体失败"
|
||||
"create_failed_toast": "创建智能体失败",
|
||||
"back_aria": "返回",
|
||||
"template_created_toast": "智能体「{{name}}」已创建",
|
||||
"template_created_with_reuse_toast_other": "智能体「{{name}}」已创建。复用了 {{count}} 个现有 skill。",
|
||||
"skill_attach_failed_toast": "智能体已创建,但 skill 关联失败:{{error}}",
|
||||
"chooser": {
|
||||
"blank_title": "从零开始",
|
||||
"blank_desc": "全部由你来填写。",
|
||||
"template_title": "使用模板",
|
||||
"template_desc": "预配置指令并精选 skill 的智能体。"
|
||||
},
|
||||
"template_picker": {
|
||||
"title": "选择模板",
|
||||
"empty": "暂无可用模板。",
|
||||
"load_failed": "加载模板失败",
|
||||
"other_category": "其他"
|
||||
},
|
||||
"template_card": {
|
||||
"skills_other": "{{count}} 个 skill"
|
||||
},
|
||||
"template_detail": {
|
||||
"load_failed": "加载模板失败",
|
||||
"skill_count_other": "包含 {{count}} 个 skill",
|
||||
"instructions_label": "指令",
|
||||
"instructions_loading": "加载指令中…",
|
||||
"use": "使用该模板",
|
||||
"creating": "创建中…"
|
||||
},
|
||||
"template_failure": {
|
||||
"title": "部分模板 skill 不可用",
|
||||
"body": "因部分上游 URL 返回错误,模板无法应用。未写入任何变更。请将下方 URL 反馈给模板维护者。"
|
||||
},
|
||||
"instructions": {
|
||||
"label": "指令",
|
||||
"placeholder_blank": "点击撰写指令…",
|
||||
"placeholder_duplicate": "点击查看或编辑复制的指令",
|
||||
"collapse": "收起",
|
||||
"editor_placeholder": "写下这个智能体该做什么、关注什么、要避开什么…"
|
||||
},
|
||||
"skills_section": {
|
||||
"label": "Skill",
|
||||
"placeholder": "从工作区添加 skill",
|
||||
"selected_other": "已选 {{count}} 个 —— 点击修改",
|
||||
"collapse": "收起",
|
||||
"list_empty_multi": "工作区还没有 skill。请先创建或导入一个。",
|
||||
"list_loading": "加载中…",
|
||||
"list_empty_default": "暂无 skill。",
|
||||
"list_no_match": "没有匹配的 skill。",
|
||||
"search_placeholder": "搜索 skill…"
|
||||
},
|
||||
"avatar": {
|
||||
"change_aria": "更换头像",
|
||||
"upload_aria": "上传头像",
|
||||
"remove_aria": "移除头像",
|
||||
"select_image_toast": "请选择图片文件。",
|
||||
"upload_failed_toast": "头像上传失败"
|
||||
}
|
||||
},
|
||||
"tab_body": {
|
||||
"common": {
|
||||
@@ -262,6 +318,10 @@
|
||||
"add_dialog_search_placeholder": "搜索 skill",
|
||||
"add_dialog_empty": "全部工作区 skill 都已分配。",
|
||||
"add_dialog_no_match": "没有匹配的 skill。",
|
||||
"add_dialog_empty_partial": "已无可添加的 skill —— 全部都已分配给该智能体。",
|
||||
"add_dialog_saving": "添加中…",
|
||||
"add_dialog_confirm_default": "添加",
|
||||
"add_dialog_confirm_other": "添加 {{count}} 个 skill",
|
||||
"add_dialog_cancel": "取消",
|
||||
"add_failed_toast": "添加 skill 失败"
|
||||
},
|
||||
|
||||
@@ -123,6 +123,12 @@ func init() {
|
||||
agentCreateCmd.Flags().String("description", "", "Agent description")
|
||||
agentCreateCmd.Flags().String("instructions", "", "Agent instructions")
|
||||
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
|
||||
// --from-template seeds the new agent from a curated template: imports the
|
||||
// template's skills into the workspace (find-or-create by name) and applies
|
||||
// the template's instructions. When set, --description/--instructions/
|
||||
// --custom-args/--custom-env/--runtime-config are ignored (the template
|
||||
// provides all the agent shape); --name and --runtime-id are still required.
|
||||
agentCreateCmd.Flags().String("from-template", "", "Template slug to seed the agent from (e.g. code-reviewer). Lists are available via GET /api/agent-templates.")
|
||||
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
|
||||
agentCreateCmd.Flags().String("model", "", "Model identifier (e.g. claude-sonnet-4-6, openai/gpt-4o). Prefer this over passing --model in --custom-args.")
|
||||
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
|
||||
@@ -363,6 +369,14 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("--runtime-id is required")
|
||||
}
|
||||
|
||||
// --from-template short-circuits to the dedicated endpoint, which
|
||||
// fetches the template's skill URLs in parallel and creates the agent
|
||||
// + skill rows atomically. Skip the manual-create body building and
|
||||
// post the small template payload instead.
|
||||
if templateSlug, _ := cmd.Flags().GetString("from-template"); templateSlug != "" {
|
||||
return runAgentCreateFromTemplate(cmd, client, name, runtimeID, templateSlug)
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
"runtime_id": runtimeID,
|
||||
@@ -424,6 +438,55 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runAgentCreateFromTemplate posts to POST /api/agents/from-template. The
|
||||
// server fetches every referenced skill in parallel and writes everything in
|
||||
// a single transaction; a 422 here means at least one upstream URL was
|
||||
// unreachable, in which case the body carries the failing URLs so we can
|
||||
// surface them verbatim to the operator instead of a generic error.
|
||||
func runAgentCreateFromTemplate(cmd *cobra.Command, client *cli.APIClient, name, runtimeID, slug string) error {
|
||||
body := map[string]any{
|
||||
"template_slug": slug,
|
||||
"name": name,
|
||||
"runtime_id": runtimeID,
|
||||
}
|
||||
if cmd.Flags().Changed("model") {
|
||||
v, _ := cmd.Flags().GetString("model")
|
||||
body["model"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("visibility") {
|
||||
v, _ := cmd.Flags().GetString("visibility")
|
||||
body["visibility"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("max-concurrent-tasks") {
|
||||
v, _ := cmd.Flags().GetInt32("max-concurrent-tasks")
|
||||
body["max_concurrent_tasks"] = v
|
||||
}
|
||||
|
||||
// 60s ceiling: templates fan out N HTTP fetches to GitHub, each ~200-500ms.
|
||||
// Matches the timeout used by `multica skill import` (cmd_skill.go).
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/agents/from-template", body, &result); err != nil {
|
||||
return fmt.Errorf("create agent from template: %w", err)
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
agent, _ := result["agent"].(map[string]any)
|
||||
imported, _ := result["imported_skill_ids"].([]any)
|
||||
reused, _ := result["reused_skill_ids"].([]any)
|
||||
fmt.Printf("Agent created from template %q: %s (%s)\n", slug, strVal(agent, "name"), strVal(agent, "id"))
|
||||
if len(imported) > 0 || len(reused) > 0 {
|
||||
fmt.Printf(" Skills: %d imported, %d reused\n", len(imported), len(reused))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAgentUpdate(cmd *cobra.Command, args []string) error {
|
||||
client, err := newAPIClient(cmd)
|
||||
if err != nil {
|
||||
|
||||
@@ -436,6 +436,11 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Route("/api/agents", func(r chi.Router) {
|
||||
r.Get("/", h.ListAgents)
|
||||
r.Post("/", h.CreateAgent)
|
||||
// Agent templates: pre-configured instructions + skill refs.
|
||||
// Picking a template imports the referenced skills into the
|
||||
// workspace (find-or-create by name) and creates the agent
|
||||
// with the template's instructions in one transaction.
|
||||
r.Post("/from-template", h.CreateAgentFromTemplate)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetAgent)
|
||||
r.Put("/", h.UpdateAgent)
|
||||
@@ -448,6 +453,14 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
})
|
||||
})
|
||||
|
||||
// Agent templates catalog (browse + detail). The Create flow
|
||||
// lives under /api/agents/from-template above; this route is for
|
||||
// the picker UI to list available templates.
|
||||
r.Route("/api/agent-templates", func(r chi.Router) {
|
||||
r.Get("/", h.ListAgentTemplates)
|
||||
r.Get("/{slug}", h.GetAgentTemplate)
|
||||
})
|
||||
|
||||
// Skills
|
||||
r.Route("/api/skills", func(r chi.Router) {
|
||||
r.Get("/", h.ListSkills)
|
||||
|
||||
127
server/internal/agenttmpl/loader.go
Normal file
127
server/internal/agenttmpl/loader.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package agenttmpl
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed templates/*.json
|
||||
var templateFS embed.FS
|
||||
|
||||
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
// Registry is the in-memory store of loaded templates. It's read-only after
|
||||
// construction — the only mutator is Load(), called once at server startup.
|
||||
// Concurrent reads after that are safe without locking.
|
||||
type Registry struct {
|
||||
bySlug map[string]Template
|
||||
order []string // slugs in deterministic load order, used by List()
|
||||
}
|
||||
|
||||
// Load parses every *.json file under templates/ and returns a populated
|
||||
// Registry. Any malformed template (bad JSON, missing required fields,
|
||||
// slug/filename mismatch) aborts startup — we'd rather fail loudly at boot
|
||||
// than serve a half-broken picker.
|
||||
func Load() (*Registry, error) {
|
||||
return loadFromFS(templateFS, "templates")
|
||||
}
|
||||
|
||||
func loadFromFS(fsys fs.FS, dir string) (*Registry, error) {
|
||||
entries, err := fs.ReadDir(fsys, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agenttmpl: read templates dir: %w", err)
|
||||
}
|
||||
|
||||
reg := &Registry{bySlug: make(map[string]Template)}
|
||||
|
||||
// Sort filenames so List() output is deterministic regardless of FS order.
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
path := dir + "/" + name
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agenttmpl: read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var t Template
|
||||
if err := json.Unmarshal(data, &t); err != nil {
|
||||
return nil, fmt.Errorf("agenttmpl: parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := validate(t, name); err != nil {
|
||||
return nil, fmt.Errorf("agenttmpl: %s: %w", path, err)
|
||||
}
|
||||
|
||||
if _, dup := reg.bySlug[t.Slug]; dup {
|
||||
return nil, fmt.Errorf("agenttmpl: duplicate slug %q (file %s)", t.Slug, path)
|
||||
}
|
||||
|
||||
reg.bySlug[t.Slug] = t
|
||||
reg.order = append(reg.order, t.Slug)
|
||||
}
|
||||
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// validate enforces the invariants that the rest of the handler / UI assume.
|
||||
// Cheap to run at boot — every check pays for itself the first time someone
|
||||
// adds a malformed template in a PR.
|
||||
func validate(t Template, filename string) error {
|
||||
if t.Slug == "" {
|
||||
return fmt.Errorf("missing slug")
|
||||
}
|
||||
if !slugPattern.MatchString(t.Slug) {
|
||||
return fmt.Errorf("slug %q must be lowercase kebab-case (a-z, 0-9, -)", t.Slug)
|
||||
}
|
||||
// Slug must equal the filename basename so URL routing matches file
|
||||
// layout. Catches typos and lets `git mv` rename templates safely.
|
||||
if filename != t.Slug+".json" {
|
||||
return fmt.Errorf("slug %q does not match filename %q", t.Slug, filename)
|
||||
}
|
||||
if strings.TrimSpace(t.Name) == "" {
|
||||
return fmt.Errorf("missing name")
|
||||
}
|
||||
if strings.TrimSpace(t.Instructions) == "" {
|
||||
return fmt.Errorf("missing instructions")
|
||||
}
|
||||
if len(t.Skills) == 0 {
|
||||
return fmt.Errorf("must declare at least one skill")
|
||||
}
|
||||
for i, s := range t.Skills {
|
||||
if strings.TrimSpace(s.SourceURL) == "" {
|
||||
return fmt.Errorf("skill[%d]: missing source_url", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all templates in deterministic load order.
|
||||
func (r *Registry) List() []Template {
|
||||
out := make([]Template, 0, len(r.order))
|
||||
for _, slug := range r.order {
|
||||
out = append(out, r.bySlug[slug])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns the template with the given slug, or false if not found.
|
||||
func (r *Registry) Get(slug string) (Template, bool) {
|
||||
t, ok := r.bySlug[slug]
|
||||
return t, ok
|
||||
}
|
||||
147
server/internal/agenttmpl/loader_test.go
Normal file
147
server/internal/agenttmpl/loader_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package agenttmpl
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestLoad_RealTemplates(t *testing.T) {
|
||||
// Exercises the production go:embed path. If a real template file is
|
||||
// malformed in main, this test fails — the same failure server boot would
|
||||
// hit, but in CI before merge.
|
||||
reg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load(): %v", err)
|
||||
}
|
||||
if len(reg.List()) == 0 {
|
||||
t.Fatal("expected at least one bundled template, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromFS_Valid(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"templates/alpha.json": &fstest.MapFile{Data: []byte(`{
|
||||
"slug": "alpha",
|
||||
"name": "Alpha",
|
||||
"description": "first",
|
||||
"instructions": "do alpha",
|
||||
"skills": [{"source_url": "https://github.com/x/y/tree/main/skills/z"}]
|
||||
}`)},
|
||||
"templates/beta.json": &fstest.MapFile{Data: []byte(`{
|
||||
"slug": "beta",
|
||||
"name": "Beta",
|
||||
"description": "second",
|
||||
"instructions": "do beta",
|
||||
"skills": [{"source_url": "https://github.com/x/y/tree/main/skills/q"}]
|
||||
}`)},
|
||||
}
|
||||
|
||||
reg, err := loadFromFS(fsys, "templates")
|
||||
if err != nil {
|
||||
t.Fatalf("loadFromFS: %v", err)
|
||||
}
|
||||
if got, want := len(reg.List()), 2; got != want {
|
||||
t.Fatalf("List() len = %d, want %d", got, want)
|
||||
}
|
||||
// List() must be deterministic (sorted by filename).
|
||||
if reg.List()[0].Slug != "alpha" {
|
||||
t.Errorf("List()[0].Slug = %q, want alpha", reg.List()[0].Slug)
|
||||
}
|
||||
if _, ok := reg.Get("alpha"); !ok {
|
||||
t.Errorf("Get(alpha) = false, want true")
|
||||
}
|
||||
if _, ok := reg.Get("nope"); ok {
|
||||
t.Errorf("Get(nope) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromFS_Invalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "bad json",
|
||||
content: `{not json`,
|
||||
wantErr: "parse",
|
||||
},
|
||||
{
|
||||
name: "missing slug",
|
||||
content: `{"name": "X", "instructions": "do", "skills": [{"source_url":"u"}]}`,
|
||||
wantErr: "missing slug",
|
||||
},
|
||||
{
|
||||
name: "slug mismatches filename",
|
||||
content: `{"slug":"other","name":"X","instructions":"do","skills":[{"source_url":"u"}]}`,
|
||||
wantErr: "does not match filename",
|
||||
},
|
||||
{
|
||||
name: "bad slug",
|
||||
content: `{"slug":"Bad_Slug","name":"X","instructions":"do","skills":[{"source_url":"u"}]}`,
|
||||
wantErr: "kebab-case",
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
content: `{"slug":"x","instructions":"do","skills":[{"source_url":"u"}]}`,
|
||||
wantErr: "missing name",
|
||||
},
|
||||
{
|
||||
name: "missing instructions",
|
||||
content: `{"slug":"x","name":"X","skills":[{"source_url":"u"}]}`,
|
||||
wantErr: "missing instructions",
|
||||
},
|
||||
{
|
||||
name: "no skills",
|
||||
content: `{"slug":"x","name":"X","instructions":"do","skills":[]}`,
|
||||
wantErr: "at least one skill",
|
||||
},
|
||||
{
|
||||
name: "skill missing url",
|
||||
content: `{"slug":"x","name":"X","instructions":"do","skills":[{}]}`,
|
||||
wantErr: "missing source_url",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
filename := "x.json"
|
||||
if tc.name == "slug mismatches filename" {
|
||||
filename = "x.json" // slug is "other", file is "x.json" → mismatch
|
||||
}
|
||||
fsys := fstest.MapFS{
|
||||
"templates/" + filename: &fstest.MapFile{Data: []byte(tc.content)},
|
||||
}
|
||||
_, err := loadFromFS(fsys, "templates")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("error = %v, want substring %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromFS_DuplicateSlug(t *testing.T) {
|
||||
// Two valid files declaring the same slug — caught by the registry, not
|
||||
// by validate(). Slugs are unique within the registry.
|
||||
fsys := fstest.MapFS{
|
||||
"templates/a.json": &fstest.MapFile{Data: []byte(`{
|
||||
"slug":"a","name":"A","instructions":"do","skills":[{"source_url":"u"}]
|
||||
}`)},
|
||||
"templates/b.json": &fstest.MapFile{Data: []byte(`{
|
||||
"slug":"a","name":"A2","instructions":"do","skills":[{"source_url":"u"}]
|
||||
}`)},
|
||||
}
|
||||
_, err := loadFromFS(fsys, "templates")
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate slug") {
|
||||
// Note: this test will fail validation first (slug "a" vs filename
|
||||
// "b.json") because we check filename-slug match before duplicate.
|
||||
// That's fine — both are errors. Adjust expectation:
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match filename") {
|
||||
t.Errorf("expected duplicate slug or filename mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
server/internal/agenttmpl/templates/article-writer.json
Normal file
21
server/internal/agenttmpl/templates/article-writer.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"slug": "article-writer",
|
||||
"name": "Article Writer",
|
||||
"description": "Drafts longform articles, blog posts, and release notes that sound like a person, not a content farm.",
|
||||
"category": "Writing",
|
||||
"icon": "PenLine",
|
||||
"accent": "success",
|
||||
"instructions": "You write longform prose — blog posts, release notes, deep-dives, engineering write-ups. Defaults:\n\n1. **Find the angle before the outline.** What does the reader walk away knowing that they didn't before? If you can't name it in one sentence, you don't have a piece yet — push back on the brief.\n2. **Open with the thing that matters.** First paragraph either states the surprising result, the concrete problem, or the question you're answering. No \"in today's fast-paced world\", no throat-clearing, no \"I'm excited to share\".\n3. **Use the doc-coauthoring skill's structured workflow.** Draft → self-edit for clarity → tighten → check against tone. Each pass has a single goal; do not blur them.\n4. **Apply the brand-guidelines skill for voice and terminology.** Names, capitalization, product nouns must match. Don't invent synonyms because variety \"reads better\".\n5. **Concrete over abstract.** Every claim gets a number, an example, or a code snippet. Replace adjectives with evidence. \"Faster\" → \"3.2x faster on the 10k-row case\".\n6. **End with what to do next.** Not a CTA, a next step — what the reader does on Monday morning if they buy your argument.\n\nDo NOT: write marketing copy, listicle filler, or \"Top 10 X You Should Know About\". Avoid em-dash overuse. Avoid \"in conclusion\" and \"that being said\". One-sentence paragraphs are allowed when they earn their line.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/doc-coauthoring",
|
||||
"cached_name": "doc-coauthoring",
|
||||
"cached_description": "Structured workflow for co-authoring longform structured content."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/brand-guidelines",
|
||||
"cached_name": "brand-guidelines",
|
||||
"cached_description": "Apply consistent brand voice, terminology, and style."
|
||||
}
|
||||
]
|
||||
}
|
||||
26
server/internal/agenttmpl/templates/code-reviewer.json
Normal file
26
server/internal/agenttmpl/templates/code-reviewer.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"slug": "code-reviewer",
|
||||
"name": "Code Reviewer",
|
||||
"description": "Reviews React / TypeScript code for correctness, performance, and maintainability.",
|
||||
"category": "Engineering",
|
||||
"icon": "Search",
|
||||
"accent": "info",
|
||||
"instructions": "You are a code review specialist. Given a diff, PR, or file:\n\n1. Read the code thoroughly before commenting — partial reads cause wrong feedback.\n2. Focus your review, in order of priority:\n - Correctness: race conditions, off-by-one errors, null/undefined handling, error propagation, exhaustiveness on enums.\n - Performance: N+1 patterns, unnecessary re-renders, missing memoization on hot paths, blocking I/O.\n - Type safety: implicit `any`, unchecked casts, lying type signatures, missing return types on exported APIs.\n - Maintainability: naming, dead code, duplication that should be extracted, comment quality.\n3. Cite `file:line` for every finding. Suggest a concrete patch over abstract advice.\n4. Use the attached skills (React best practices, composition patterns) as your knowledge base. When code violates a rule from those skills, name the rule explicitly.\n\nDo NOT comment on: formatting (assume an autoformatter runs), stylistic preferences without evidence of a bug, or changes outside the diff. Stay scoped — drive-by suggestions waste review cycles.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices",
|
||||
"cached_name": "vercel-react-best-practices",
|
||||
"cached_description": "React and Next.js performance optimization guidelines from Vercel Engineering."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/vercel-labs/agent-skills/tree/main/skills/composition-patterns",
|
||||
"cached_name": "vercel-composition-patterns",
|
||||
"cached_description": "Component composition idioms that improve reusability and testability."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/debugging/root-cause-tracing",
|
||||
"cached_name": "root-cause-tracing",
|
||||
"cached_description": "Systematically trace bugs backward through the call stack to find the original trigger."
|
||||
}
|
||||
]
|
||||
}
|
||||
21
server/internal/agenttmpl/templates/docs-writer.json
Normal file
21
server/internal/agenttmpl/templates/docs-writer.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"slug": "docs-writer",
|
||||
"name": "Docs Writer",
|
||||
"description": "Writes and revises technical documentation that respects an existing brand voice.",
|
||||
"category": "Writing",
|
||||
"icon": "FileText",
|
||||
"accent": "success",
|
||||
"instructions": "You write technical documentation collaboratively with the user. Defaults:\n\n1. Match the existing project's voice — read 2-3 existing docs before writing new prose. Mimic sentence length, formality, and structural conventions.\n2. Prefer concrete examples over abstract explanation. Every concept gets at least one code example or screenshot reference.\n3. Lead with the reader's task, not the implementer's architecture. \"How do I do X\" first, \"why X is built this way\" later.\n4. Use the attached brand-guidelines skill as the source of truth for tone, terminology, and capitalization. Never override it with personal preferences.\n5. When revising existing docs, preserve the original author's intent. Refactor for clarity, not because the prose isn't yours.\n\nDo NOT generate marketing copy, sales-speak, or filler like \"in today's fast-paced world\". Plain, technically accurate prose only.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/doc-coauthoring",
|
||||
"cached_name": "doc-coauthoring",
|
||||
"cached_description": "Structured workflow for co-authoring documentation, proposals, technical specs."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/brand-guidelines",
|
||||
"cached_name": "brand-guidelines",
|
||||
"cached_description": "Apply consistent brand colors, typography, and style guidelines."
|
||||
}
|
||||
]
|
||||
}
|
||||
21
server/internal/agenttmpl/templates/frontend-builder.json
Normal file
21
server/internal/agenttmpl/templates/frontend-builder.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"slug": "frontend-builder",
|
||||
"name": "Frontend Builder",
|
||||
"description": "Builds polished frontend UIs with design-system fidelity and accessibility in mind.",
|
||||
"category": "Building",
|
||||
"icon": "Palette",
|
||||
"accent": "secondary",
|
||||
"instructions": "You build production-quality frontend UIs. Default behaviors:\n\n1. Treat design specs as binding contracts: respect spacing, typography, hover/focus/disabled states, and motion.\n2. Use the attached design skill as your style source of truth. If a token (color, radius, spacing) exists, use it; do not introduce ad-hoc values.\n3. Accessibility is non-negotiable: semantic HTML, keyboard navigation, ARIA only when semantics are insufficient, contrast ratio ≥ 4.5:1 for body text.\n4. Performance budget: avoid unnecessary client components, large client-side libraries, and waterfalls of dependent fetches.\n5. Ship complete, working UI — no TODO placeholders, no \"hook this up later\" stubs.\n\nWhen the user gives a vague spec, ask one targeted clarifying question (which layout, which interaction) before writing code. Do not invent design decisions silently.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/frontend-design",
|
||||
"cached_name": "frontend-design",
|
||||
"cached_description": "Create distinctive, production-grade frontend interfaces with high design quality."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/web-artifacts-builder",
|
||||
"cached_name": "web-artifacts-builder",
|
||||
"cached_description": "Tools for creating elaborate, multi-component artifacts with React, Tailwind, shadcn/ui."
|
||||
}
|
||||
]
|
||||
}
|
||||
26
server/internal/agenttmpl/templates/frontend-designer.json
Normal file
26
server/internal/agenttmpl/templates/frontend-designer.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"slug": "frontend-designer",
|
||||
"name": "Frontend Designer",
|
||||
"description": "Designs distinctive, accessible UI with deliberate motion — beyond shadcn defaults, still production-grade.",
|
||||
"category": "Building",
|
||||
"icon": "Brush",
|
||||
"accent": "info",
|
||||
"instructions": "You design and implement UI with a designer's eye, not just an engineer's. Defaults:\n\n1. **Visual hierarchy first.** Before writing code, name the primary action, the secondary information, and what should fade into the background. Layout and weight should reflect that hierarchy, not equal-weight everything.\n2. **Audit against the Web Interface Guidelines.** Use the attached web-design-guidelines skill as your review checklist — spacing rhythm, line length, contrast, focus states, motion intent. Cite the rule when you justify a choice.\n3. **Reach for motion only when it earns its place.** When state transitions are non-trivial (route changes, list reorder, modal open from a card), use react-view-transitions. Motion communicates causality; do not animate just to decorate.\n4. **Beyond defaults.** Shadcn / Tailwind defaults are a floor, not a ceiling. Customise spacing, type scale, and density so the UI feels intentional. Document your overrides as design tokens, not one-off classes.\n5. **Accessibility is part of design.** Color contrast, keyboard reachability, motion-reduce support, and screen-reader labels are design decisions — not engineering chores tacked on at the end.\n\nDo NOT: ship UI that looks like a Figma export with no opinion; add motion to elements that don't change state; use semantic tokens as a fig-leaf for boring layouts. \"Production-grade\" means polished, not generic.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/frontend-design",
|
||||
"cached_name": "frontend-design",
|
||||
"cached_description": "Create distinctive, production-grade frontend interfaces with high design quality."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines",
|
||||
"cached_name": "web-design-guidelines",
|
||||
"cached_description": "Review UI code for Web Interface Guidelines compliance."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/vercel-labs/agent-skills/tree/main/skills/react-view-transitions",
|
||||
"cached_name": "vercel-react-view-transitions",
|
||||
"cached_description": "Implement smooth, native-feeling animations with React's View Transition API."
|
||||
}
|
||||
]
|
||||
}
|
||||
41
server/internal/agenttmpl/templates/full-stack-engineer.json
Normal file
41
server/internal/agenttmpl/templates/full-stack-engineer.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"slug": "full-stack-engineer",
|
||||
"name": "Full-stack Engineer",
|
||||
"description": "End-to-end engineer that brainstorms, plans, builds, tests, and verifies — disciplined every step.",
|
||||
"category": "Engineering",
|
||||
"icon": "Sparkles",
|
||||
"accent": "primary",
|
||||
"instructions": "You are a senior full-stack engineer. For every non-trivial task, follow the superpowers workflow end-to-end:\n\n1. **Brainstorm first.** Before writing code, use the brainstorming skill to clarify intent, constraints, and the smallest valuable slice. Never start coding from a vague ask.\n2. **Write a plan.** Decompose the work into bite-sized tasks per the writing-plans skill. A plan is a contract — list files, expected behavior, and verification steps.\n3. **Execute in batches.** Implement against the plan using the executing-plans rhythm: complete a chunk, self-review, run verification, then move on. Do NOT skip ahead.\n4. **TDD when the surface is testable.** Write the failing test first, see it fail, make it pass, refactor. This is non-negotiable for new logic that has clear inputs/outputs.\n5. **Debug systematically.** When something breaks, use systematic-debugging — reproduce → minimise → hypothesise → instrument → fix. Do not pile fixes on symptoms.\n6. **Verify before claiming done.** Run the actual command (tests, typecheck, lint, app boot) and read the output. \"Should work\" is not evidence.\n\nDo NOT: skip planning because the task \"feels small\"; cite a green CI without inspecting the run; refactor opportunistically outside the planned scope; leave TODO placeholders or half-finished implementations.\n\nWhen blocked, pick the smallest concrete experiment that disambiguates two hypotheses and run it. Report findings, not feelings.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/collaboration/brainstorming",
|
||||
"cached_name": "brainstorming",
|
||||
"cached_description": "Interactive idea refinement using Socratic method to develop fully-formed designs."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/collaboration/writing-plans",
|
||||
"cached_name": "writing-plans",
|
||||
"cached_description": "Create detailed implementation plans with bite-sized tasks."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/collaboration/executing-plans",
|
||||
"cached_name": "executing-plans",
|
||||
"cached_description": "Execute detailed plans in batches with review checkpoints."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/testing/test-driven-development",
|
||||
"cached_name": "test-driven-development",
|
||||
"cached_description": "Write the test first, watch it fail, write minimal code to pass."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/debugging/systematic-debugging",
|
||||
"cached_name": "systematic-debugging",
|
||||
"cached_description": "Four-phase debugging framework that ensures root cause investigation before fixes."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/debugging/verification-before-completion",
|
||||
"cached_name": "verification-before-completion",
|
||||
"cached_description": "Run verification commands and confirm output before claiming success."
|
||||
}
|
||||
]
|
||||
}
|
||||
26
server/internal/agenttmpl/templates/html-slides.json
Normal file
26
server/internal/agenttmpl/templates/html-slides.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"slug": "html-slides",
|
||||
"name": "HTML Slides",
|
||||
"description": "Builds a slide deck as a single self-contained HTML file — no PowerPoint, no Google Slides, no install.",
|
||||
"category": "Productivity",
|
||||
"icon": "Presentation",
|
||||
"accent": "primary",
|
||||
"instructions": "You build presentation decks as a single HTML file. Output is one .html with all CSS/JS inlined, openable by double-click. Use deck-design discipline, but the medium is the web.\n\n1. **One idea per slide.** Each slide is one `<section>` with one main idea. Two ideas → two slides. Zero ideas → cut it.\n2. **Titles state the takeaway, not the topic.** \"Q2 Revenue\" is a topic; \"Q2 revenue grew 14%, driven by enterprise\" is a takeaway. The audience should be able to skim titles only and get the story.\n3. **Use the web-artifacts-builder pipeline.** Initialize with the script, build with React + Tailwind + shadcn, bundle to a single HTML file. Do NOT hand-roll the build setup — the skill's scripts handle Parcel/Vite quirks.\n4. **Navigation: keyboard-first.** Arrow keys advance slides, `f` toggles fullscreen, `?` shows help. Touch/swipe is a bonus. No \"click the corner\" UX.\n5. **Apply canvas-design for typography and layout.** Clear hierarchy: huge title, supporting copy, max one big chart or image per slide. No 8-bullet walls of 14pt text.\n6. **Self-contained.** All fonts, images (inline base64 or SVG), and scripts bundled. The file works offline, opens in any browser. No CDN dependencies.\n\nDo NOT: import reveal.js / Spectacle / impress.js (overkill for a deck — write 100 lines of CSS yourself); use stock emoji as decoration; add page transitions that aren't tied to causality; end with \"Thank you / Q&A\" — close with the single action you want the reader to take.\n\nDefault transition: fade or instant cut. Never spin, zoom, or 3D-flip without a content reason.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/web-artifacts-builder",
|
||||
"cached_name": "web-artifacts-builder",
|
||||
"cached_description": "Bundle React + Tailwind + shadcn into a single self-contained HTML artifact."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/canvas-design",
|
||||
"cached_name": "canvas-design",
|
||||
"cached_description": "Apply design philosophy for visual composition, hierarchy, and type."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/frontend-design",
|
||||
"cached_name": "frontend-design",
|
||||
"cached_description": "Production-grade typography, spacing, and design quality."
|
||||
}
|
||||
]
|
||||
}
|
||||
21
server/internal/agenttmpl/templates/internal-comms.json
Normal file
21
server/internal/agenttmpl/templates/internal-comms.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"slug": "internal-comms",
|
||||
"name": "Internal Comms",
|
||||
"description": "Writes internal announcements, Slack posts, and release notes that respect the company's house style.",
|
||||
"category": "Writing",
|
||||
"icon": "Megaphone",
|
||||
"accent": "warning",
|
||||
"instructions": "You write internal communications: launch announcements, all-hands notes, Slack posts, incident retros, status updates. Defaults:\n\n1. **Lead with TL;DR.** First 1-2 lines: what changed, who it affects, what (if anything) they need to do. Everyone scans before they read.\n2. **Use the internal-comms skill as the template source.** Match the format your company uses for this kind of post — announcement, FYI, decision log, retro. Do not mix formats.\n3. **Apply brand-guidelines for tone and product names.** Internal voice can be looser than external — still consistent. Product names, capitalization, and acronyms follow the guide.\n4. **Name the audience explicitly.** \"Engineering only / company-wide / leads + on-call\" at the top. A post that's relevant to half the readers and noise to the other half loses both groups.\n5. **Specifics over reassurance.** Replace \"we're working on it\" with \"X is owning the fix, ETA Friday, follow #incident-foo for updates\". If you can't be specific, say what you don't know yet.\n6. **Close with the action.** What does the reader do next — read a doc, fill a form, attend a meeting, nothing? State it.\n\nDo NOT: open with \"hi team!\" filler; promise without an owner; bury the lede under context; mix announcement and discussion (link to a thread instead).",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/internal-comms",
|
||||
"cached_name": "internal-comms",
|
||||
"cached_description": "Resources for writing internal communications in common company formats."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/brand-guidelines",
|
||||
"cached_name": "brand-guidelines",
|
||||
"cached_description": "Apply consistent brand voice, terminology, and style."
|
||||
}
|
||||
]
|
||||
}
|
||||
26
server/internal/agenttmpl/templates/one-pager.json
Normal file
26
server/internal/agenttmpl/templates/one-pager.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"slug": "one-pager",
|
||||
"name": "One-pager",
|
||||
"description": "Builds a single-page HTML summary — project briefs, launch pages, results recap — polished and shareable.",
|
||||
"category": "Productivity",
|
||||
"icon": "LayoutDashboard",
|
||||
"accent": "success",
|
||||
"instructions": "You build one-page HTML artifacts: project briefs, launch summaries, weekly recaps, results dashboards, internal landing pages. Output is a single self-contained .html file the user can share by drag-and-drop.\n\n1. **Lead with the headline.** Top of the page = the one thing the reader walks away with. A number, a quote, a status. If you can't name it in 8 words, the brief isn't ready.\n2. **Three to five sections, no more.** Hero / context / details / next steps is a strong default. Pages that grow to ten sections become docs — and a doc is a different deliverable.\n3. **Use the web-artifacts-builder pipeline.** Initialize with the script, develop with React + Tailwind + shadcn, bundle to one HTML file. Do NOT hand-roll the toolchain.\n4. **Apply frontend-design and canvas-design.** Real type hierarchy (display / heading / body sizes), generous whitespace, one accent color, one supporting color. Default fonts beat random Google Fonts — pick one and commit.\n5. **Charts only when the number is the point.** If you have a chart, it goes near the top with the headline. Don't bury a chart on page 3.\n6. **Static, single file, no network calls.** No fetch(), no analytics scripts, no external CDN. The file must work offline and outlive any backend.\n\nDo NOT: build a multi-route SPA (that's a different template — use Frontend Builder); add a contact form / signup that needs a backend; reach for purple gradients and centered hero copy as the default — that is what AI slop looks like; pad with \"trusted by\" logos you don't have.\n\nLength target: one screen on desktop, two scrolls on mobile. If it's longer, it's a doc, not a one-pager.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/web-artifacts-builder",
|
||||
"cached_name": "web-artifacts-builder",
|
||||
"cached_description": "Bundle React + Tailwind + shadcn into a single self-contained HTML artifact."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/frontend-design",
|
||||
"cached_name": "frontend-design",
|
||||
"cached_description": "Production-grade typography, spacing, and design quality."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/canvas-design",
|
||||
"cached_name": "canvas-design",
|
||||
"cached_description": "Apply design philosophy for visual composition, hierarchy, and type."
|
||||
}
|
||||
]
|
||||
}
|
||||
31
server/internal/agenttmpl/templates/planner.json
Normal file
31
server/internal/agenttmpl/templates/planner.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"slug": "planner",
|
||||
"name": "Planner",
|
||||
"description": "Turns a vague request into a clear, bite-sized implementation plan another engineer can pick up cold.",
|
||||
"category": "Engineering",
|
||||
"icon": "ListChecks",
|
||||
"accent": "info",
|
||||
"instructions": "You are a planning specialist. You do not write production code in this role — you produce plans an implementer can execute without re-asking the user.\n\n1. **Clarify intent before structure.** Use the brainstorming skill to surface the real goal, constraints, and definition of done. Surface ambiguity early; one targeted clarifying question now beats five rounds of rework.\n2. **Produce a plan in the writing-plans format.** Bite-sized tasks, each with: files touched, expected behavior change, verification step. Tasks must be independently testable.\n3. **Sequence to minimise blockers.** Order tasks so each one leaves the codebase in a runnable state. No \"this is broken until task 4 lands\" gaps.\n4. **When the problem feels too big, simplify.** Apply the simplification-cascades skill — find one insight that lets you delete a component, a step, or a branch. If you can't, name the load-bearing assumption explicitly so it gets reviewed.\n5. **When stuck, dispatch deliberately.** Use the when-stuck skill to pick the right unblock technique (inversion, scale game, collision zones) rather than spinning on the same approach.\n\nDo NOT: ship a plan that ends with \"and then make it work\"; pad with optional steps that aren't on the critical path; assume the reader has the conversation context — every plan must be self-contained.\n\nDeliverable shape: a plan doc (markdown) with TL;DR, file-by-file changes, test plan, and explicit out-of-scope list.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/collaboration/brainstorming",
|
||||
"cached_name": "brainstorming",
|
||||
"cached_description": "Interactive idea refinement using Socratic method to develop fully-formed designs."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/collaboration/writing-plans",
|
||||
"cached_name": "writing-plans",
|
||||
"cached_description": "Create detailed implementation plans with bite-sized tasks."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/problem-solving/when-stuck",
|
||||
"cached_name": "when-stuck",
|
||||
"cached_description": "Dispatch to the right problem-solving technique based on how you're stuck."
|
||||
},
|
||||
{
|
||||
"source_url": "https://github.com/obra/superpowers-skills/tree/main/skills/problem-solving/simplification-cascades",
|
||||
"cached_name": "simplification-cascades",
|
||||
"cached_description": "Find one insight that eliminates multiple components."
|
||||
}
|
||||
]
|
||||
}
|
||||
16
server/internal/agenttmpl/templates/webapp-tester.json
Normal file
16
server/internal/agenttmpl/templates/webapp-tester.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"slug": "webapp-tester",
|
||||
"name": "Webapp Tester",
|
||||
"description": "Writes meaningful E2E and integration tests that catch real regressions.",
|
||||
"category": "Engineering",
|
||||
"icon": "FlaskConical",
|
||||
"accent": "primary",
|
||||
"instructions": "You write web application tests. Defaults:\n\n1. Test behavior, not implementation. A test that breaks when the user-visible behavior is unchanged is a bad test.\n2. Read the attached `webapp-testing` skill before choosing an approach — the right tool (Playwright, MSW, Vitest) depends on what's being tested.\n3. Test the happy path, the obvious edge case, and the regression that prompted the test. Skip exhaustive enumeration of trivial variations.\n4. Use realistic fixtures: factor real-looking payloads into shared helpers, not 200-line inline mocks per test.\n5. Tests must be deterministic. Flaky tests are worse than no tests; fix the flake or delete the test.\n\nWhen writing a regression test, name the bug it's pinning down in the test description. Future readers should know why this test exists.",
|
||||
"skills": [
|
||||
{
|
||||
"source_url": "https://github.com/anthropics/skills/tree/main/skills/webapp-testing",
|
||||
"cached_name": "webapp-testing",
|
||||
"cached_description": "Toolkit for testing local web applications with Playwright."
|
||||
}
|
||||
]
|
||||
}
|
||||
73
server/internal/agenttmpl/types.go
Normal file
73
server/internal/agenttmpl/types.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Package agenttmpl loads and serves the curated agent templates that power
|
||||
// the "Create Agent from template" flow. Templates are static JSON files
|
||||
// embedded at build time (see loader.go); they pair a hand-written
|
||||
// instructions block with a list of skill references (GitHub URLs) that get
|
||||
// materialised into the workspace when a user picks the template.
|
||||
//
|
||||
// Templates are intentionally repo-only: their content is part of the product
|
||||
// (the "curated best-practice combos") and changes go through normal PR
|
||||
// review. No runtime mutation, no admin UI. If/when that changes, swap the
|
||||
// in-memory map for a DB-backed Registry without touching callers.
|
||||
package agenttmpl
|
||||
|
||||
// Template is the structured representation of an `agent template` JSON file
|
||||
// loaded from server/internal/agenttmpl/templates/<slug>.json.
|
||||
type Template struct {
|
||||
// Slug uniquely identifies a template within the catalog. Must equal the
|
||||
// JSON file's basename so URLs like /api/agent-templates/{slug} resolve
|
||||
// deterministically. Allowed characters: lowercase letters, digits, "-".
|
||||
Slug string `json:"slug"`
|
||||
|
||||
// Name is the human-readable title shown in the picker grid.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Description is a one-line summary for the picker card.
|
||||
Description string `json:"description"`
|
||||
|
||||
// Category groups templates in the picker UI ("Engineering" / "Writing" /
|
||||
// "Building" / ...). Empty allowed (renders in an "Other" group); when set
|
||||
// the picker shows a section header.
|
||||
Category string `json:"category,omitempty"`
|
||||
|
||||
// Icon is a lucide-react icon name (e.g. "Search", "Palette"). Rendered
|
||||
// in the picker card + detail header as the visual differentiator. Empty
|
||||
// falls back to a generic "FileText" icon on the frontend.
|
||||
Icon string `json:"icon,omitempty"`
|
||||
|
||||
// Accent picks the semantic color token used to tint the icon badge:
|
||||
// one of "info" / "success" / "warning" / "primary" / "secondary".
|
||||
// Empty falls back to "muted" on the frontend. Hardcoded color values
|
||||
// (text-red-500, bg-blue-100, …) are explicitly NOT allowed — accent
|
||||
// must be a Multica design-system token name (see CLAUDE.md).
|
||||
Accent string `json:"accent,omitempty"`
|
||||
|
||||
// Instructions is the verbatim text written into the created agent's
|
||||
// `agent.instructions` column. Keep it plain markdown — the runtime
|
||||
// receives it as-is.
|
||||
Instructions string `json:"instructions"`
|
||||
|
||||
// Skills lists the skill references that should be materialised into the
|
||||
// workspace when the template is picked. Order is preserved in responses
|
||||
// so the UI can show skills in a stable sequence.
|
||||
Skills []TemplateSkillRef `json:"skills"`
|
||||
}
|
||||
|
||||
// TemplateSkillRef points to one skill that should be imported when the
|
||||
// template is materialised. SourceURL is the only fetched field; CachedName
|
||||
// and CachedDescription let the picker render the skill name without making
|
||||
// an HTTP round-trip per template per page load.
|
||||
type TemplateSkillRef struct {
|
||||
// SourceURL is the upstream skill location. Resolved at materialisation
|
||||
// time via handler/skill.go:detectImportSource (skills.sh, github.com, …)
|
||||
// so any URL the existing skill importer already accepts works here.
|
||||
SourceURL string `json:"source_url"`
|
||||
|
||||
// CachedName mirrors the upstream SKILL.md frontmatter `name` field at
|
||||
// the time the template was authored. Used for picker rendering only —
|
||||
// the actual skill row uses whatever the fetched frontmatter says.
|
||||
CachedName string `json:"cached_name"`
|
||||
|
||||
// CachedDescription mirrors the upstream frontmatter `description`.
|
||||
// Same role as CachedName.
|
||||
CachedDescription string `json:"cached_description"`
|
||||
}
|
||||
653
server/internal/handler/agent_template.go
Normal file
653
server/internal/handler/agent_template.go
Normal file
@@ -0,0 +1,653 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/agenttmpl"
|
||||
"github.com/multica-ai/multica/server/internal/analytics"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// agentTemplates is the in-memory catalog loaded once at package init. We
|
||||
// fail-fast here rather than at the first request: a malformed template ships
|
||||
// in source, so it's a deploy-time defect, not a runtime one.
|
||||
var agentTemplates *agenttmpl.Registry
|
||||
|
||||
func init() {
|
||||
reg, err := agenttmpl.Load()
|
||||
if err != nil {
|
||||
panic("agenttmpl: failed to load templates at startup: " + err.Error())
|
||||
}
|
||||
agentTemplates = reg
|
||||
}
|
||||
|
||||
// --- Response shapes ---
|
||||
|
||||
// AgentTemplateSkillResponse is the per-skill payload returned in the picker
|
||||
// list and detail. CachedName/CachedDescription let the UI render without an
|
||||
// HTTP round-trip to upstream — they reflect the template author's snapshot.
|
||||
type AgentTemplateSkillResponse struct {
|
||||
SourceURL string `json:"source_url"`
|
||||
CachedName string `json:"cached_name"`
|
||||
CachedDescription string `json:"cached_description"`
|
||||
}
|
||||
|
||||
// AgentTemplateSummaryResponse is what `GET /api/agent-templates` returns
|
||||
// per entry. Omits Instructions to keep the list payload small; the detail
|
||||
// endpoint (or the create flow) loads the full template.
|
||||
type AgentTemplateSummaryResponse struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Accent string `json:"accent,omitempty"`
|
||||
Skills []AgentTemplateSkillResponse `json:"skills"`
|
||||
}
|
||||
|
||||
// AgentTemplateResponse is the detail variant — same as the summary plus the
|
||||
// full Instructions block.
|
||||
type AgentTemplateResponse struct {
|
||||
AgentTemplateSummaryResponse
|
||||
Instructions string `json:"instructions"`
|
||||
}
|
||||
|
||||
func templateToSummary(t agenttmpl.Template) AgentTemplateSummaryResponse {
|
||||
skills := make([]AgentTemplateSkillResponse, 0, len(t.Skills))
|
||||
for _, s := range t.Skills {
|
||||
skills = append(skills, AgentTemplateSkillResponse{
|
||||
SourceURL: s.SourceURL,
|
||||
CachedName: s.CachedName,
|
||||
CachedDescription: s.CachedDescription,
|
||||
})
|
||||
}
|
||||
return AgentTemplateSummaryResponse{
|
||||
Slug: t.Slug,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Category: t.Category,
|
||||
Icon: t.Icon,
|
||||
Accent: t.Accent,
|
||||
Skills: skills,
|
||||
}
|
||||
}
|
||||
|
||||
func templateToDetail(t agenttmpl.Template) AgentTemplateResponse {
|
||||
return AgentTemplateResponse{
|
||||
AgentTemplateSummaryResponse: templateToSummary(t),
|
||||
Instructions: t.Instructions,
|
||||
}
|
||||
}
|
||||
|
||||
// --- List + Get handlers ---
|
||||
|
||||
func (h *Handler) ListAgentTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
tmpls := agentTemplates.List()
|
||||
resp := make([]AgentTemplateSummaryResponse, 0, len(tmpls))
|
||||
for _, t := range tmpls {
|
||||
resp = append(resp, templateToSummary(t))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) GetAgentTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
t, ok := agentTemplates.Get(slug)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "template not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, templateToDetail(t))
|
||||
}
|
||||
|
||||
// --- Create-from-template handler ---
|
||||
|
||||
type CreateAgentFromTemplateRequest struct {
|
||||
TemplateSlug string `json:"template_slug"`
|
||||
Name string `json:"name"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks,omitempty"`
|
||||
// Optional overrides — let the picker UI customise the template before
|
||||
// creation without forcing a second round-trip to the detail page.
|
||||
// When nil/empty, the template's own values are used.
|
||||
Description *string `json:"description,omitempty"`
|
||||
Instructions *string `json:"instructions,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
// Workspace skill IDs to attach **in addition to** the template's
|
||||
// skills. The merge dedupes against template skills automatically
|
||||
// (agent_skill INSERT uses ON CONFLICT DO NOTHING).
|
||||
ExtraSkillIDs []string `json:"extra_skill_ids,omitempty"`
|
||||
}
|
||||
|
||||
type CreateAgentFromTemplateResponse struct {
|
||||
Agent AgentResponse `json:"agent"`
|
||||
ImportedSkillIDs []string `json:"imported_skill_ids"`
|
||||
ReusedSkillIDs []string `json:"reused_skill_ids"`
|
||||
}
|
||||
|
||||
type fetchFailureResponse struct {
|
||||
Error string `json:"error"`
|
||||
FailedURLs []string `json:"failed_urls"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAgentFromTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
|
||||
ownerID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateAgentFromTemplateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if req.RuntimeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "runtime_id is required")
|
||||
return
|
||||
}
|
||||
if req.Visibility == "" {
|
||||
req.Visibility = "private"
|
||||
}
|
||||
if req.MaxConcurrentTasks == 0 {
|
||||
req.MaxConcurrentTasks = 6
|
||||
}
|
||||
|
||||
tmpl, found := agentTemplates.Get(req.TemplateSlug)
|
||||
if !found {
|
||||
writeError(w, http.StatusBadRequest, "template not found: "+req.TemplateSlug)
|
||||
return
|
||||
}
|
||||
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Runtime validation reproduces the gating done by CreateAgent
|
||||
// (handler/agent.go) — keep the two paths in sync. Done before fetch so
|
||||
// we don't waste GitHub API calls for a request that's going to 403.
|
||||
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
|
||||
ID: runtimeUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid runtime_id")
|
||||
return
|
||||
}
|
||||
member, ok := h.workspaceMember(w, r, workspaceID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !canUseRuntimeForAgent(member, runtime) {
|
||||
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can create agents on it")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("agent-template create: request received",
|
||||
append(logger.RequestAttrs(r),
|
||||
"template_slug", tmpl.Slug,
|
||||
"workspace_id", workspaceID,
|
||||
"skill_url_count", len(tmpl.Skills),
|
||||
)...)
|
||||
|
||||
// Pre-flight dedupe: each skill that already exists in the workspace
|
||||
// by `cached_name` can be reused WITHOUT fetching. This is the big win:
|
||||
// on the second create-from-the-same-template, fetch_count drops to 0
|
||||
// and the whole operation completes in <100ms instead of 20+ seconds.
|
||||
//
|
||||
// `cached_name` MUST match the upstream SKILL.md frontmatter `name`
|
||||
// (see template authoring docs in agenttmpl/types.go). When it doesn't,
|
||||
// the pre-flight misses and we fall back to the in-TX find-or-create
|
||||
// below — slower (one wasted fetch) but still correct.
|
||||
preReused := make(map[int]db.Skill, len(tmpl.Skills))
|
||||
toFetchRefs := make([]agenttmpl.TemplateSkillRef, 0, len(tmpl.Skills))
|
||||
toFetchOrigIdx := make([]int, 0, len(tmpl.Skills))
|
||||
for i, ref := range tmpl.Skills {
|
||||
if ref.CachedName == "" {
|
||||
toFetchRefs = append(toFetchRefs, ref)
|
||||
toFetchOrigIdx = append(toFetchOrigIdx, i)
|
||||
continue
|
||||
}
|
||||
existing, err := h.Queries.GetSkillByWorkspaceAndName(r.Context(), db.GetSkillByWorkspaceAndNameParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Name: ref.CachedName,
|
||||
})
|
||||
if err == nil {
|
||||
preReused[i] = existing
|
||||
slog.Info("agent-template create: pre-reuse hit (skipped fetch)",
|
||||
append(logger.RequestAttrs(r),
|
||||
"index", i,
|
||||
"cached_name", ref.CachedName,
|
||||
"existing_skill_id", uuidToString(existing.ID),
|
||||
)...)
|
||||
continue
|
||||
}
|
||||
toFetchRefs = append(toFetchRefs, ref)
|
||||
toFetchOrigIdx = append(toFetchOrigIdx, i)
|
||||
}
|
||||
|
||||
// Fetch only the skills that aren't already in the workspace. fetched[j]
|
||||
// corresponds to toFetchRefs[j], whose original index is toFetchOrigIdx[j].
|
||||
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||
fetchStart := time.Now()
|
||||
var fetched []*importedSkill
|
||||
var failedURLs []string
|
||||
if len(toFetchRefs) > 0 {
|
||||
fetched, failedURLs = fetchTemplateSkillsParallel(httpClient, toFetchRefs)
|
||||
}
|
||||
slog.Info("agent-template create: fetch phase done",
|
||||
append(logger.RequestAttrs(r),
|
||||
"template_slug", tmpl.Slug,
|
||||
"fetch_duration_ms", time.Since(fetchStart).Milliseconds(),
|
||||
"pre_reused_count", len(preReused),
|
||||
"fetched_count", len(toFetchRefs)-len(failedURLs),
|
||||
"fail_count", len(failedURLs),
|
||||
"failed_urls", failedURLs,
|
||||
)...)
|
||||
if len(failedURLs) > 0 {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, fetchFailureResponse{
|
||||
Error: "one or more skill sources are unavailable",
|
||||
FailedURLs: failedURLs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build a per-original-index lookup so we can iterate tmpl.Skills in
|
||||
// order below without fiddling with toFetch* slices.
|
||||
fetchedByOrigIdx := make(map[int]*importedSkill, len(fetched))
|
||||
for j, imp := range fetched {
|
||||
fetchedByOrigIdx[toFetchOrigIdx[j]] = imp
|
||||
}
|
||||
|
||||
creatorUUID := parseUUID(ownerID)
|
||||
isFirstAgent := false
|
||||
if existing, listErr := h.Queries.ListAgents(r.Context(), wsUUID); listErr == nil {
|
||||
isFirstAgent = len(existing) == 0
|
||||
}
|
||||
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to begin tx: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
importedIDs := make([]string, 0, len(tmpl.Skills))
|
||||
reusedIDs := make([]string, 0, len(tmpl.Skills))
|
||||
allSkillIDs := make([]pgtype.UUID, 0, len(tmpl.Skills))
|
||||
|
||||
for i, ref := range tmpl.Skills {
|
||||
// Pre-flight hit: reuse the workspace's existing skill ID without
|
||||
// any further fetch or DB work.
|
||||
if existing, ok := preReused[i]; ok {
|
||||
allSkillIDs = append(allSkillIDs, existing.ID)
|
||||
reusedIDs = append(reusedIDs, uuidToString(existing.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
imp := fetchedByOrigIdx[i]
|
||||
if imp == nil {
|
||||
// Defensive — shouldn't happen since we already checked failedURLs.
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("internal: missing fetch result for skill index %d", i))
|
||||
return
|
||||
}
|
||||
|
||||
// Second-chance dedupe by ACTUAL frontmatter name. Catches the case
|
||||
// where the template author's cached_name drifted from the upstream
|
||||
// frontmatter `name` — pre-flight misses but the workspace still has
|
||||
// the skill under its real name, and we want to reuse not duplicate.
|
||||
existing, err := qtx.GetSkillByWorkspaceAndName(r.Context(), db.GetSkillByWorkspaceAndNameParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Name: imp.name,
|
||||
})
|
||||
if err == nil {
|
||||
slog.Info("agent-template create: reusing existing skill (frontmatter-name match, cached_name drifted)",
|
||||
append(logger.RequestAttrs(r),
|
||||
"index", i,
|
||||
"frontmatter_name", imp.name,
|
||||
"cached_name", ref.CachedName,
|
||||
"existing_skill_id", uuidToString(existing.ID),
|
||||
)...)
|
||||
allSkillIDs = append(allSkillIDs, existing.ID)
|
||||
reusedIDs = append(reusedIDs, uuidToString(existing.ID))
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
slog.Error("agent-template create: lookup existing skill failed",
|
||||
append(logger.RequestAttrs(r),
|
||||
"index", i,
|
||||
"name", imp.name,
|
||||
"error", err,
|
||||
)...)
|
||||
writeError(w, http.StatusInternalServerError, "lookup existing skill failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("agent-template create: inserting new skill",
|
||||
append(logger.RequestAttrs(r),
|
||||
"index", i,
|
||||
"name", imp.name,
|
||||
"file_count", len(imp.files),
|
||||
)...)
|
||||
|
||||
files := make([]CreateSkillFileRequest, 0, len(imp.files))
|
||||
for _, f := range imp.files {
|
||||
if !validateFilePath(f.path) {
|
||||
continue
|
||||
}
|
||||
files = append(files, CreateSkillFileRequest{Path: f.path, Content: f.content})
|
||||
}
|
||||
|
||||
// Record provenance: which template seeded this skill, plus the
|
||||
// upstream URL. Mirrors handler/skill.go:ImportSkill's origin block
|
||||
// so the skill detail page renders a consistent "imported from …"
|
||||
// chip regardless of entry point.
|
||||
origin := map[string]any{
|
||||
"type": "agent_template",
|
||||
"template_slug": tmpl.Slug,
|
||||
"source_url": ref.SourceURL,
|
||||
}
|
||||
// Preserve the upstream-specific origin fields (owner/repo/...) when
|
||||
// the fetcher returned them — useful for a "Open on GitHub" link.
|
||||
if imp.origin != nil {
|
||||
for k, v := range imp.origin {
|
||||
if _, exists := origin[k]; !exists {
|
||||
origin[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
created, err := createSkillWithFilesInTx(r.Context(), qtx, skillCreateInput{
|
||||
WorkspaceID: wsUUID,
|
||||
CreatorID: creatorUUID,
|
||||
Name: imp.name,
|
||||
Description: imp.description,
|
||||
Content: imp.content,
|
||||
Config: map[string]any{"origin": origin},
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
// Full PG error in the log so we can tell unique-constraint from
|
||||
// other failures without guessing.
|
||||
slog.Error("agent-template create: failed to create skill",
|
||||
append(logger.RequestAttrs(r),
|
||||
"index", i,
|
||||
"name", imp.name,
|
||||
"workspace_id", workspaceID,
|
||||
"error", err,
|
||||
"is_unique_violation", isUniqueViolation(err),
|
||||
)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())
|
||||
return
|
||||
}
|
||||
allSkillIDs = append(allSkillIDs, parseUUID(created.ID))
|
||||
importedIDs = append(importedIDs, created.ID)
|
||||
}
|
||||
|
||||
rc, _ := json.Marshal(map[string]any{})
|
||||
ce, _ := json.Marshal(map[string]string{})
|
||||
ca, _ := json.Marshal([]string{})
|
||||
|
||||
// Apply optional overrides — nil means "use template default".
|
||||
description := tmpl.Description
|
||||
if req.Description != nil {
|
||||
description = *req.Description
|
||||
}
|
||||
instructions := tmpl.Instructions
|
||||
if req.Instructions != nil {
|
||||
instructions = *req.Instructions
|
||||
}
|
||||
avatarURL := pgtype.Text{}
|
||||
if req.AvatarURL != nil && *req.AvatarURL != "" {
|
||||
avatarURL = pgtype.Text{String: *req.AvatarURL, Valid: true}
|
||||
}
|
||||
|
||||
agent, err := qtx.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: wsUUID,
|
||||
Name: req.Name,
|
||||
Description: description,
|
||||
Instructions: instructions,
|
||||
AvatarUrl: avatarURL,
|
||||
RuntimeMode: runtime.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
RuntimeID: runtime.ID,
|
||||
Visibility: req.Visibility,
|
||||
MaxConcurrentTasks: req.MaxConcurrentTasks,
|
||||
OwnerID: creatorUUID,
|
||||
CustomEnv: ce,
|
||||
CustomArgs: ca,
|
||||
McpConfig: nil,
|
||||
Model: pgtype.Text{String: req.Model, Valid: req.Model != ""},
|
||||
})
|
||||
if err != nil {
|
||||
// Mirror handler/agent.go:CreateAgent: when the duplicate is the
|
||||
// agent name UNIQUE in this workspace, return 409 with a clear
|
||||
// message instead of leaking the raw PG error as 500. Frontend
|
||||
// already knows how to render 409 from the manual create path.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "agent_workspace_name_unique" {
|
||||
slog.Info("agent-template create: agent name conflict",
|
||||
append(logger.RequestAttrs(r),
|
||||
"agent_name", req.Name,
|
||||
"workspace_id", workspaceID,
|
||||
)...)
|
||||
writeError(w, http.StatusConflict, fmt.Sprintf("an agent named %q already exists in this workspace", req.Name))
|
||||
return
|
||||
}
|
||||
slog.Error("agent-template create: failed to create agent",
|
||||
append(logger.RequestAttrs(r),
|
||||
"agent_name", req.Name,
|
||||
"workspace_id", workspaceID,
|
||||
"error", err,
|
||||
"is_unique_violation", isUniqueViolation(err),
|
||||
)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for idx, skillID := range allSkillIDs {
|
||||
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
|
||||
AgentID: agent.ID,
|
||||
SkillID: skillID,
|
||||
}); err != nil {
|
||||
slog.Error("agent-template create: failed to attach skill",
|
||||
append(logger.RequestAttrs(r),
|
||||
"agent_id", uuidToString(agent.ID),
|
||||
"skill_id", uuidToString(skillID),
|
||||
"skill_index", idx,
|
||||
"error", err,
|
||||
)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to attach skill: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Attach user-supplied extra skills (selected in the create dialog
|
||||
// alongside the template). AddAgentSkill uses ON CONFLICT DO NOTHING,
|
||||
// so duplicates with template-imported skills are harmless.
|
||||
for _, raw := range req.ExtraSkillIDs {
|
||||
extraUUID, perr := util.ParseUUID(raw)
|
||||
if perr != nil {
|
||||
// Skip malformed IDs but don't fail the whole create — the agent
|
||||
// is otherwise valid. Logged so the bad ID can be traced.
|
||||
slog.Warn("agent-template create: skipping malformed extra_skill_id",
|
||||
append(logger.RequestAttrs(r), "raw", raw, "error", perr)...)
|
||||
continue
|
||||
}
|
||||
// Verify the skill belongs to this workspace before attaching;
|
||||
// otherwise a malicious client could attach a skill from another
|
||||
// workspace by guessing UUIDs.
|
||||
owned, qerr := qtx.GetSkillInWorkspace(r.Context(), db.GetSkillInWorkspaceParams{
|
||||
ID: extraUUID, WorkspaceID: wsUUID,
|
||||
})
|
||||
if qerr != nil {
|
||||
slog.Warn("agent-template create: skipping cross-workspace extra_skill_id",
|
||||
append(logger.RequestAttrs(r), "skill_id", raw, "error", qerr)...)
|
||||
continue
|
||||
}
|
||||
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
|
||||
AgentID: agent.ID,
|
||||
SkillID: owned.ID,
|
||||
}); err != nil {
|
||||
slog.Error("agent-template create: failed to attach extra skill",
|
||||
append(logger.RequestAttrs(r), "skill_id", raw, "error", err)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to attach skill: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
slog.Error("agent-template create: commit failed",
|
||||
append(logger.RequestAttrs(r),
|
||||
"agent_id", uuidToString(agent.ID),
|
||||
"error", err,
|
||||
)...)
|
||||
writeError(w, http.StatusInternalServerError, "commit failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if runtime.Status == "online" {
|
||||
h.TaskService.ReconcileAgentStatus(r.Context(), agent.ID)
|
||||
agent, _ = h.Queries.GetAgent(r.Context(), agent.ID)
|
||||
}
|
||||
|
||||
resp := agentToResponse(agent)
|
||||
actorType, actorID := h.resolveActor(r, ownerID, workspaceID)
|
||||
h.publish(protocol.EventAgentCreated, workspaceID, actorType, actorID, map[string]any{"agent": resp})
|
||||
|
||||
h.Analytics.Capture(analytics.AgentCreated(
|
||||
ownerID,
|
||||
workspaceID,
|
||||
uuidToString(agent.ID),
|
||||
runtime.Provider,
|
||||
runtime.RuntimeMode,
|
||||
tmpl.Slug, // template slug doubles as the analytics template field
|
||||
isFirstAgent,
|
||||
))
|
||||
|
||||
slog.Info("agent created from template",
|
||||
append(logger.RequestAttrs(r),
|
||||
"agent_id", uuidToString(agent.ID),
|
||||
"template_slug", tmpl.Slug,
|
||||
"imported_skill_count", len(importedIDs),
|
||||
"reused_skill_count", len(reusedIDs),
|
||||
)...)
|
||||
|
||||
writeJSON(w, http.StatusCreated, CreateAgentFromTemplateResponse{
|
||||
Agent: resp,
|
||||
ImportedSkillIDs: importedIDs,
|
||||
ReusedSkillIDs: reusedIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Parallel skill fetch ---
|
||||
|
||||
type templateFetchResult struct {
|
||||
index int
|
||||
imported *importedSkill
|
||||
url string
|
||||
err error
|
||||
}
|
||||
|
||||
// fetchTemplateSkillsParallel resolves every template skill ref into an
|
||||
// importedSkill, in parallel. Returns the imports in input order; failed_urls
|
||||
// is non-nil iff any fetch failed. Logs per-URL timing so we can spot which
|
||||
// upstream is the long pole in a slow request.
|
||||
func fetchTemplateSkillsParallel(client *http.Client, refs []agenttmpl.TemplateSkillRef) ([]*importedSkill, []string) {
|
||||
results := make(chan templateFetchResult, len(refs))
|
||||
var wg sync.WaitGroup
|
||||
for i, ref := range refs {
|
||||
wg.Add(1)
|
||||
go func(i int, ref agenttmpl.TemplateSkillRef) {
|
||||
defer wg.Done()
|
||||
start := time.Now()
|
||||
slog.Info("agent-template fetch: start", "index", i, "source_url", ref.SourceURL)
|
||||
imp, err := fetchSkillFromURL(client, ref.SourceURL)
|
||||
elapsedMs := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
slog.Warn("agent-template fetch: failed",
|
||||
"index", i,
|
||||
"source_url", ref.SourceURL,
|
||||
"duration_ms", elapsedMs,
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
resolvedName := ""
|
||||
fileCount := 0
|
||||
if imp != nil {
|
||||
resolvedName = imp.name
|
||||
fileCount = len(imp.files)
|
||||
}
|
||||
slog.Info("agent-template fetch: done",
|
||||
"index", i,
|
||||
"source_url", ref.SourceURL,
|
||||
"duration_ms", elapsedMs,
|
||||
"resolved_name", resolvedName,
|
||||
"file_count", fileCount,
|
||||
)
|
||||
}
|
||||
results <- templateFetchResult{index: i, imported: imp, url: ref.SourceURL, err: err}
|
||||
}(i, ref)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
imports := make([]*importedSkill, len(refs))
|
||||
var failed []string
|
||||
for r := range results {
|
||||
if r.err != nil {
|
||||
failed = append(failed, r.url)
|
||||
continue
|
||||
}
|
||||
imports[r.index] = r.imported
|
||||
}
|
||||
return imports, failed
|
||||
}
|
||||
|
||||
// fetchSkillFromURL dispatches to the right upstream fetcher based on URL.
|
||||
// Mirrors the switch inside ImportSkill (skill.go:1566) so both entry points
|
||||
// stay in sync.
|
||||
func fetchSkillFromURL(client *http.Client, rawURL string) (*importedSkill, error) {
|
||||
source, normalized, err := detectImportSource(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch source {
|
||||
case sourceClawHub:
|
||||
return fetchFromClawHub(client, normalized)
|
||||
case sourceSkillsSh:
|
||||
return fetchFromSkillsSh(client, normalized)
|
||||
case sourceGitHub:
|
||||
return fetchFromGitHub(client, normalized)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown import source for %s", rawURL)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,18 @@ import (
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// sanitizeNullBytes removes null bytes (0x00) from strings.
|
||||
// PostgreSQL rejects null bytes in text columns with
|
||||
// "invalid byte sequence for encoding UTF8: 0x00 (SQLSTATE 22021)".
|
||||
// sanitizeNullBytes makes a string safe for a PostgreSQL TEXT column.
|
||||
//
|
||||
// Two failure modes covered:
|
||||
// - Embedded NUL (0x00) — PG rejects with SQLSTATE 22021. Removed.
|
||||
// - Other invalid-UTF-8 byte sequences (e.g. 0x91 = Windows-1252 smart
|
||||
// quote, which crashed agent-template import of skills containing
|
||||
// Windows-encoded prose). `strings.ToValidUTF8` drops them.
|
||||
//
|
||||
// Name is kept for compatibility with the many call sites; the behaviour
|
||||
// is a strict superset of the original.
|
||||
func sanitizeNullBytes(s string) string {
|
||||
return strings.ReplaceAll(s, "\x00", "")
|
||||
return strings.ToValidUTF8(strings.ReplaceAll(s, "\x00", ""), "")
|
||||
}
|
||||
|
||||
// --- Response structs ---
|
||||
@@ -481,7 +488,16 @@ func isCapError(err error) bool {
|
||||
// addFile appends a supporting file while enforcing the per-bundle caps. It
|
||||
// returns an error when either the file count or aggregate byte budget would
|
||||
// be exceeded so the caller fails the import instead of silently truncating.
|
||||
//
|
||||
// Binary files (images, fonts, archives) are silently skipped: their bytes
|
||||
// can't survive a PG TEXT column (SQLSTATE 22021), and they're reference
|
||||
// assets the agent never reads as text anyway. Logging the skip leaves a
|
||||
// breadcrumb if a user expected one of these to import.
|
||||
func (s *importedSkill) addFile(path, content string) error {
|
||||
if isLikelyBinaryFilePath(path) {
|
||||
slog.Info("skill import: skipping binary file", "path", path, "size", len(content))
|
||||
return nil
|
||||
}
|
||||
if len(s.files) >= maxImportFileCount {
|
||||
return fmt.Errorf("%w: import bundle exceeds %d file limit", errImportCapExceeded, maxImportFileCount)
|
||||
}
|
||||
@@ -493,6 +509,34 @@ func (s *importedSkill) addFile(path, content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isLikelyBinaryFilePath reports whether the file's extension indicates a
|
||||
// non-text payload. Conservative blacklist — extensions not on the list
|
||||
// are assumed text and pass through. `sanitizeNullBytes` (called at PG
|
||||
// insert time) is the second-line defence against any text file that
|
||||
// turns out to have stray invalid-UTF-8 bytes.
|
||||
func isLikelyBinaryFilePath(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case
|
||||
// images
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".ico", ".heic",
|
||||
// fonts
|
||||
".ttf", ".otf", ".woff", ".woff2", ".eot",
|
||||
// archives
|
||||
".zip", ".gz", ".tar", ".bz2", ".7z", ".rar",
|
||||
// documents (binary office)
|
||||
".pdf", ".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt",
|
||||
// media
|
||||
".mp3", ".mp4", ".wav", ".avi", ".mov", ".webm", ".m4a", ".flac",
|
||||
// compiled / executable
|
||||
".exe", ".dll", ".so", ".dylib", ".class", ".jar", ".wasm",
|
||||
// db / cache
|
||||
".db", ".sqlite", ".sqlite3", ".pyc":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- ClawHub types ---
|
||||
|
||||
type clawhubGetSkillResponse struct {
|
||||
|
||||
@@ -18,7 +18,12 @@ type skillCreateInput struct {
|
||||
Files []CreateSkillFileRequest
|
||||
}
|
||||
|
||||
func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInput) (SkillWithFilesResponse, error) {
|
||||
// createSkillWithFilesInTx writes a skill plus its supporting files using the
|
||||
// provided sqlc Queries handle, which must already be bound to an open
|
||||
// transaction. Callers compose skill creation with other writes (e.g. agent
|
||||
// template materialization) inside one outer transaction. For standalone
|
||||
// skill creation, prefer createSkillWithFiles, which manages its own tx.
|
||||
func createSkillWithFilesInTx(ctx context.Context, qtx *db.Queries, input skillCreateInput) (SkillWithFilesResponse, error) {
|
||||
config, err := json.Marshal(input.Config)
|
||||
if err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
@@ -27,14 +32,6 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp
|
||||
config = []byte("{}")
|
||||
}
|
||||
|
||||
tx, err := h.TxStarter.Begin(ctx)
|
||||
if err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
skill, err := qtx.CreateSkill(ctx, db.CreateSkillParams{
|
||||
WorkspaceID: input.WorkspaceID,
|
||||
Name: sanitizeNullBytes(input.Name),
|
||||
@@ -60,12 +57,29 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp
|
||||
fileResps = append(fileResps, skillFileToResponse(sf))
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
|
||||
return SkillWithFilesResponse{
|
||||
SkillResponse: skillToResponse(skill),
|
||||
Files: fileResps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInput) (SkillWithFilesResponse, error) {
|
||||
tx, err := h.TxStarter.Begin(ctx)
|
||||
if err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
result, err := createSkillWithFilesInTx(ctx, qtx, input)
|
||||
if err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -384,8 +384,12 @@ func TestFetchFromSkillsSh_ResolvesRootLevelSkillMd(t *testing.T) {
|
||||
if !strings.HasPrefix(result.content, "---\nname: huashu-design") {
|
||||
t.Fatalf("SKILL.md content not populated, got %q", result.content)
|
||||
}
|
||||
// assets/logo.png is intentionally dropped by addFile's binary-extension
|
||||
// guard — PG TEXT columns can't store image bytes, and agents never read
|
||||
// them as text. The directory is still walked (the listing request below
|
||||
// confirms it), but the .png never reaches result.files.
|
||||
gotPaths := importedFilePaths(result.files)
|
||||
wantPaths := []string{"README.md", "assets/logo.png"}
|
||||
wantPaths := []string{"README.md"}
|
||||
if !equalStrings(gotPaths, wantPaths) {
|
||||
t.Fatalf("files = %v, want %v", gotPaths, wantPaths)
|
||||
}
|
||||
|
||||
@@ -115,6 +115,37 @@ func (q *Queries) GetSkill(ctx context.Context, id pgtype.UUID) (Skill, error) {
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSkillByWorkspaceAndName = `-- name: GetSkillByWorkspaceAndName :one
|
||||
SELECT id, workspace_id, name, description, content, config, created_by, created_at, updated_at FROM skill
|
||||
WHERE workspace_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
type GetSkillByWorkspaceAndNameParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Used by agent-template materialization to implement find-or-create: when a
|
||||
// template references a skill by name that already exists in the workspace,
|
||||
// reuse the existing skill_id rather than INSERT (which would fail the
|
||||
// UNIQUE(workspace_id, name) constraint from migration 008).
|
||||
func (q *Queries) GetSkillByWorkspaceAndName(ctx context.Context, arg GetSkillByWorkspaceAndNameParams) (Skill, error) {
|
||||
row := q.db.QueryRow(ctx, getSkillByWorkspaceAndName, arg.WorkspaceID, arg.Name)
|
||||
var i Skill
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Content,
|
||||
&i.Config,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSkillFile = `-- name: GetSkillFile :one
|
||||
SELECT id, skill_id, path, content, created_at, updated_at FROM skill_file
|
||||
WHERE id = $1
|
||||
|
||||
@@ -23,6 +23,14 @@ WHERE id = $1;
|
||||
SELECT * FROM skill
|
||||
WHERE id = $1 AND workspace_id = $2;
|
||||
|
||||
-- name: GetSkillByWorkspaceAndName :one
|
||||
-- Used by agent-template materialization to implement find-or-create: when a
|
||||
-- template references a skill by name that already exists in the workspace,
|
||||
-- reuse the existing skill_id rather than INSERT (which would fail the
|
||||
-- UNIQUE(workspace_id, name) constraint from migration 008).
|
||||
SELECT * FROM skill
|
||||
WHERE workspace_id = $1 AND name = $2;
|
||||
|
||||
-- name: CreateSkill :one
|
||||
INSERT INTO skill (workspace_id, name, description, content, config, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
|
||||
Reference in New Issue
Block a user