Compare commits
3 Commits
013293abe1
...
249e2c2e9c
| Author | SHA1 | Date | |
|---|---|---|---|
| 249e2c2e9c | |||
| c1bc4ac91d | |||
| 030b21949b |
8
core/agents/skills/user/123/SKILL.md
Normal file
8
core/agents/skills/user/123/SKILL.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: 123
|
||||
description: 123
|
||||
---
|
||||
|
||||
# 123
|
||||
|
||||
123
|
||||
@@ -3,6 +3,153 @@ name: jimliu/baoyu-skills@baoyu-article-illustrator
|
||||
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style two-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
|
||||
---
|
||||
|
||||
# jimliu/baoyu-skills@baoyu-article-illustrator
|
||||
# Article Illustrator
|
||||
|
||||
Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style two-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
|
||||
Analyze articles, identify illustration positions, generate images with Type × Style consistency.
|
||||
|
||||
## Two Dimensions
|
||||
|
||||
| Dimension | Controls | Examples |
|
||||
|-----------|----------|----------|
|
||||
| **Type** | Information structure | infographic, scene, flowchart, comparison, framework, timeline |
|
||||
| **Style** | Visual aesthetics | notion, warm, minimal, blueprint, watercolor, elegant |
|
||||
|
||||
Combine freely: `--type infographic --style blueprint`
|
||||
|
||||
## Types
|
||||
|
||||
| Type | Best For |
|
||||
|------|----------|
|
||||
| `infographic` | Data, metrics, technical |
|
||||
| `scene` | Narratives, emotional |
|
||||
| `flowchart` | Processes, workflows |
|
||||
| `comparison` | Side-by-side, options |
|
||||
| `framework` | Models, architecture |
|
||||
| `timeline` | History, evolution |
|
||||
|
||||
## Styles
|
||||
|
||||
See [references/styles.md](references/styles.md) for Core Styles, full gallery, and Type × Style compatibility.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
- [ ] Step 1: Pre-check (EXTEND.md, references, config)
|
||||
- [ ] Step 2: Analyze content
|
||||
- [ ] Step 3: Confirm settings (AskUserQuestion)
|
||||
- [ ] Step 4: Generate outline
|
||||
- [ ] Step 5: Generate images
|
||||
- [ ] Step 6: Finalize
|
||||
```
|
||||
|
||||
### Step 1: Pre-check
|
||||
|
||||
**1.5 Load Preferences (EXTEND.md) ⛔ BLOCKING**
|
||||
|
||||
```bash
|
||||
test -f .baoyu-skills/baoyu-article-illustrator/EXTEND.md && echo "project"
|
||||
test -f "$HOME/.baoyu-skills/baoyu-article-illustrator/EXTEND.md" && echo "user"
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display summary |
|
||||
| Not found | ⛔ Run [first-time-setup](references/config/first-time-setup.md) |
|
||||
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-1-pre-check)
|
||||
|
||||
### Step 2: Analyze
|
||||
|
||||
| Analysis | Output |
|
||||
|----------|--------|
|
||||
| Content type | Technical / Tutorial / Methodology / Narrative |
|
||||
| Purpose | information / visualization / imagination |
|
||||
| Core arguments | 2-5 main points |
|
||||
| Positions | Where illustrations add value |
|
||||
|
||||
**CRITICAL**: Metaphors → visualize underlying concept, NOT literal image.
|
||||
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-2-setup--analyze)
|
||||
|
||||
### Step 3: Confirm Settings ⚠️
|
||||
|
||||
**ONE AskUserQuestion, max 4 Qs. Q1-Q3 REQUIRED.**
|
||||
|
||||
| Q | Options |
|
||||
|---|---------|
|
||||
| **Q1: Type** | [Recommended], infographic, scene, flowchart, comparison, framework, timeline, mixed |
|
||||
| **Q2: Density** | minimal (1-2), balanced (3-5), per-section (Recommended), rich (6+) |
|
||||
| **Q3: Style** | [Recommended], minimal-flat, sci-fi, hand-drawn, editorial, scene, Other |
|
||||
| Q4: Language | When article language ≠ EXTEND.md setting |
|
||||
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-3-confirm-settings-)
|
||||
|
||||
### Step 4: Generate Outline
|
||||
|
||||
Save `outline.md` with frontmatter (type, density, style, image_count) and entries:
|
||||
|
||||
```yaml
|
||||
## Illustration 1
|
||||
**Position**: [section/paragraph]
|
||||
**Purpose**: [why]
|
||||
**Visual Content**: [what]
|
||||
**Filename**: 01-infographic-concept-name.png
|
||||
```
|
||||
|
||||
Full template: [references/workflow.md](references/workflow.md#step-4-generate-outline)
|
||||
|
||||
### Step 5: Generate Images
|
||||
|
||||
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
||||
|
||||
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
||||
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
||||
3. Prompts **MUST** use type-specific templates with structured sections (ZONES / LABELS / COLORS / STYLE / ASPECT)
|
||||
4. LABELS **MUST** include article-specific data: actual numbers, terms, metrics, quotes
|
||||
5. **DO NOT** pass ad-hoc inline prompts to `--prompt` without saving prompt files first
|
||||
6. Select generation skill, process references (`direct`/`style`/`palette`)
|
||||
7. Apply watermark if EXTEND.md enabled
|
||||
8. Generate from saved prompt files; retry once on failure
|
||||
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-5-generate-images)
|
||||
|
||||
### Step 6: Finalize
|
||||
|
||||
Insert `` after paragraphs.
|
||||
|
||||
```
|
||||
Article Illustration Complete!
|
||||
Article: [path] | Type: [type] | Density: [level] | Style: [style]
|
||||
Images: X/N generated
|
||||
```
|
||||
|
||||
## Output Directory
|
||||
|
||||
```
|
||||
illustrations/{topic-slug}/
|
||||
├── source-{slug}.{ext}
|
||||
├── references/ # if provided
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
└── NN-{type}-{slug}.png
|
||||
```
|
||||
|
||||
**Slug**: 2-4 words, kebab-case. **Conflict**: append `-YYYYMMDD-HHMMSS`.
|
||||
|
||||
## Modification
|
||||
|
||||
| Action | Steps |
|
||||
|--------|-------|
|
||||
| Edit | Update prompt → Regenerate → Update reference |
|
||||
| Add | Position → Prompt → Generate → Update outline → Insert |
|
||||
| Delete | Delete files → Remove reference → Update outline |
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| [references/workflow.md](references/workflow.md) | Detailed procedures |
|
||||
| [references/usage.md](references/usage.md) | Command syntax |
|
||||
| [references/styles.md](references/styles.md) | Style gallery |
|
||||
| [references/prompt-construction.md](references/prompt-construction.md) | Prompt templates |
|
||||
| [references/config/first-time-setup.md](references/config/first-time-setup.md) | First-time setup |
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
---
|
||||
name: openakita/skills@xiaohongshu-creator
|
||||
description: Create engaging Xiaohongshu (RED/小红书) content including titles, body text, hashtags, and image style recommendations. Supports multiple content types such as product reviews, tutorials, lifestyle sharing, and shopping guides with platform-specific optimization.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: openakita
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# 小红书内容创作助手
|
||||
|
||||
专为小红书平台打造的内容创作技能,帮助你生成符合平台调性的高质量笔记,涵盖标题、正文、话题标签和配图建议。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 撰写种草笔记(好物推荐、购物分享)
|
||||
- 撰写产品测评笔记
|
||||
- 撰写教程类笔记(美妆、穿搭、美食、DIY)
|
||||
- 撰写生活分享笔记(旅行、日常、打卡)
|
||||
- 品牌合作内容创作
|
||||
- 小红书账号运营内容规划
|
||||
- 批量生成笔记框架
|
||||
|
||||
## 核心创作规范
|
||||
|
||||
### 一、标题规则
|
||||
|
||||
标题是笔记的第一印象,直接决定点击率。
|
||||
|
||||
**硬性要求:**
|
||||
- 字数限制:**不超过 20 个字符**
|
||||
- Emoji 数量:**1-2 个**,放在标题开头或结尾
|
||||
- 禁止使用:感叹号堆叠(`!!!`)、全大写字母
|
||||
|
||||
**钩子元素(至少使用 1 种):**
|
||||
|
||||
| 钩子类型 | 说明 | 示例 |
|
||||
|----------|------|------|
|
||||
| 数字法 | 用数字制造具体感 | `3步搞定通勤妆🌟` |
|
||||
| 反差法 | 制造意外感 | `月薪3K穿出3W的感觉✨` |
|
||||
| 痛点法 | 直击目标人群痛点 | `黄皮亲妈色号合集🎨` |
|
||||
| 悬念法 | 引发好奇心 | `这个习惯让我瘦了20斤💪` |
|
||||
| 对比法 | 前后/AB对比 | `早C晚A一个月的变化🔥` |
|
||||
| 权威法 | 借势专业背书 | `皮肤科医生推荐的面霜💊` |
|
||||
| 场景法 | 具体使用场景 | `约会前30分钟急救妆容💄` |
|
||||
| 共鸣法 | 引发情感共鸣 | `打工人的高效早餐方案☀️` |
|
||||
|
||||
**标题模板:**
|
||||
```
|
||||
[数字] + [核心关键词] + [利益点] + [emoji]
|
||||
[身份标签] + [动作] + [结果] + [emoji]
|
||||
[场景] + [解决方案] + [emoji]
|
||||
```
|
||||
|
||||
### 二、正文结构
|
||||
|
||||
正文长度控制在 **300-500 字符**,遵循四段式结构:
|
||||
|
||||
```
|
||||
🔥 Hook(开头钩子) —— 1-2 句,抓住注意力
|
||||
📝 Core(核心内容) —— 主体信息,有价值的干货
|
||||
📌 Summary(总结) —— 精炼要点
|
||||
👉 CTA(行动号召) —— 引导互动
|
||||
```
|
||||
|
||||
**Hook 写法:**
|
||||
- 提问式:`你们有没有这种烦恼?`
|
||||
- 共鸣式:`每个打工人都需要这个!`
|
||||
- 悬念式:`用了三年终于找到了最好用的...`
|
||||
- 成果式:`坚持30天,效果太惊人了`
|
||||
|
||||
**Core 写法要点:**
|
||||
- 使用 emoji 分点(📍🔸💡等)替代纯文字列表
|
||||
- 每个要点控制在 1-2 行
|
||||
- 穿插个人体验和感受(增加真实感)
|
||||
- 重要信息加【】或「」标注
|
||||
- 适当使用换行,避免大段文字
|
||||
|
||||
**CTA 常用句式:**
|
||||
- `觉得有用的话记得点赞收藏哦~`
|
||||
- `你们还想看什么类型的分享?评论区告诉我`
|
||||
- `有同款的姐妹举个手🙋♀️`
|
||||
- `关注我,持续分享[领域]干货`
|
||||
|
||||
### 三、话题标签规则
|
||||
|
||||
每篇笔记配 **8 个话题标签**,按以下比例分配:
|
||||
|
||||
| 类别 | 数量 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| 核心词 | 2 个 | 笔记主题精准关键词 | `#面膜推荐` `#保湿面膜` |
|
||||
| 品类词 | 2 个 | 所属品类/领域 | `#护肤` `#美妆好物` |
|
||||
| 场景词 | 2 个 | 使用场景/人群 | `#学生党护肤` `#换季护肤` |
|
||||
| 热门词 | 2 个 | 平台热门话题 | `#好物分享` `#我的爱用物` |
|
||||
|
||||
**选择原则:**
|
||||
- 优先选择搜索量大但竞争适中的标签
|
||||
- 避免过于宽泛的标签(如 `#生活`)
|
||||
- 包含长尾关键词提升搜索曝光
|
||||
- 关注平台当前热门话题榜
|
||||
|
||||
### 四、配图风格建议
|
||||
|
||||
小红书是视觉驱动平台,封面决定 80% 的点击率。
|
||||
|
||||
**10 种推荐视觉风格:**
|
||||
|
||||
| 编号 | 风格 | 适用类型 | 要点 |
|
||||
|------|------|---------|------|
|
||||
| 1 | 对比拼图 | 测评/效果展示 | 左右或上下对比,标注差异 |
|
||||
| 2 | 清单图 | 好物合集/推荐 | 白底九宫格产品陈列 |
|
||||
| 3 | 教程步骤图 | 教程类 | 编号标注步骤,清晰易跟 |
|
||||
| 4 | 文字封面 | 干货分享 | 大字标题+简洁背景色 |
|
||||
| 5 | 场景氛围图 | 生活分享/穿搭 | 自然光,生活感强 |
|
||||
| 6 | 数据图表 | 测评/科普 | 简化数据可视化 |
|
||||
| 7 | 手绘/插画风 | 知识科普 | 可爱风格信息图 |
|
||||
| 8 | Vlog截图 | 日常分享 | 视频关键帧+文字标注 |
|
||||
| 9 | 实拍特写 | 产品种草 | 高清细节,突出质感 |
|
||||
| 10 | Ins风简约 | 穿搭/家居 | 低饱和度,高级感 |
|
||||
|
||||
**封面设计通用原则:**
|
||||
- 尺寸比例:**3:4**(1080×1440px)最佳
|
||||
- 文字不超过封面面积的 **20%**
|
||||
- 核心信息放在画面上半部分(feed 流裁剪安全区)
|
||||
- 色彩鲜明、对比度高
|
||||
- 避免过度 P 图,保持真实感
|
||||
|
||||
## 内容类型工作流
|
||||
|
||||
### 工作流一:种草笔记
|
||||
|
||||
```
|
||||
输入 → 产品名称、品类、价格、目标人群
|
||||
↓
|
||||
Step 1: 生成 3 个标题方案(痛点法/数字法/场景法各一)
|
||||
↓
|
||||
Step 2: 撰写正文
|
||||
- Hook:个人使用感受/发现契机
|
||||
- Core:产品亮点(3-5个)、使用方法、适合人群
|
||||
- Summary:一句话总结推荐理由
|
||||
- CTA:引导收藏和讨论
|
||||
↓
|
||||
Step 3: 生成 8 个话题标签
|
||||
↓
|
||||
Step 4: 封面建议(推荐风格 9 实拍特写 或 风格 2 清单图)
|
||||
↓
|
||||
输出 → 完整笔记(可直接发布)
|
||||
```
|
||||
|
||||
### 工作流二:测评笔记
|
||||
|
||||
```
|
||||
输入 → 产品列表(2-5 个)、测评维度
|
||||
↓
|
||||
Step 1: 标题使用对比法或数字法
|
||||
↓
|
||||
Step 2: 撰写正文
|
||||
- Hook:测评动机/痛点引入
|
||||
- Core:逐项对比(成分/价格/使用感/性价比)
|
||||
- Summary:各产品评分或排名
|
||||
- CTA:`你们用过哪个?评论区聊聊`
|
||||
↓
|
||||
Step 3: 话题标签(增加品牌词标签)
|
||||
↓
|
||||
Step 4: 封面建议(推荐风格 1 对比拼图 或 风格 6 数据图表)
|
||||
↓
|
||||
输出 → 完整测评笔记
|
||||
```
|
||||
|
||||
### 工作流三:教程笔记
|
||||
|
||||
```
|
||||
输入 → 教程主题、难度、目标人群
|
||||
↓
|
||||
Step 1: 标题使用数字法(`X步学会...`)
|
||||
↓
|
||||
Step 2: 撰写正文
|
||||
- Hook:学会后的效果/价值
|
||||
- Core:分步骤讲解(每步 1-2 句)
|
||||
- Summary:关键注意事项
|
||||
- CTA:`学会的打个✅`
|
||||
↓
|
||||
Step 3: 话题标签(增加 `#教程` `#手把手教学` 等)
|
||||
↓
|
||||
Step 4: 封面建议(推荐风格 3 步骤图 或 风格 4 文字封面)
|
||||
↓
|
||||
输出 → 完整教程笔记 + 多图建议(每步一张图)
|
||||
```
|
||||
|
||||
### 工作流四:生活分享笔记
|
||||
|
||||
```
|
||||
输入 → 分享主题、场景、情绪基调
|
||||
↓
|
||||
Step 1: 标题使用共鸣法或场景法
|
||||
↓
|
||||
Step 2: 撰写正文
|
||||
- Hook:故事开头/情感切入
|
||||
- Core:分享细节、个人感受、实用信息
|
||||
- Summary:感悟或建议
|
||||
- CTA:`你们有类似的经历吗?`
|
||||
↓
|
||||
Step 3: 话题标签(增加情感/生活方式标签)
|
||||
↓
|
||||
Step 4: 封面建议(推荐风格 5 场景氛围图 或 风格 8 Vlog截图)
|
||||
↓
|
||||
输出 → 完整分享笔记
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 示例:护肤种草笔记
|
||||
|
||||
**用户输入:** `帮我写一篇关于珂润面霜的种草笔记,目标人群是敏感肌学生党`
|
||||
|
||||
**输出:**
|
||||
|
||||
**标题方案:**
|
||||
1. `敏感肌救星!这罐面霜用了3年回购🧴`
|
||||
2. `学生党百元面霜天花板💰`
|
||||
3. `换季烂脸?皮肤科医生同款面霜🏥`
|
||||
|
||||
**正文(选用标题1):**
|
||||
|
||||
```
|
||||
姐妹们!!敏感肌的福音真的来了
|
||||
|
||||
作为一个烂脸三年的敏感肌 终于找到了真正好用的面霜
|
||||
|
||||
📍珂润面霜使用感受:
|
||||
🔸质地:乳白色霜状 不厚重 好推开
|
||||
🔸保湿:神经酰胺成分 修护屏障一绝
|
||||
🔸温和度:无香精无酒精 敏感期也能用
|
||||
🔸性价比:百元出头 学生党无压力
|
||||
|
||||
💡使用tips:
|
||||
1️⃣ 先乳后霜 吸收更好
|
||||
2️⃣ 换季加量使用 厚涂当睡眠面膜
|
||||
3️⃣ 搭配同系列化妆水效果翻倍
|
||||
|
||||
用了整整三年 每次回购都是囤两罐的节奏
|
||||
敏感肌姐妹真的可以闭眼入!
|
||||
|
||||
觉得有用记得收藏💕 还想看什么评论区告诉我~
|
||||
```
|
||||
|
||||
**话题标签:**
|
||||
`#珂润面霜` `#敏感肌护肤` `#学生党护肤` `#平价面霜推荐` `#神经酰胺` `#换季护肤` `#好物分享` `#护肤干货`
|
||||
|
||||
**封面建议:** 风格 9(实拍特写),自然光下拍摄面霜质地细节图,辅以手写标注关键成分。
|
||||
|
||||
## 高级技巧
|
||||
|
||||
### 发布时间建议
|
||||
|
||||
| 时段 | 说明 |
|
||||
|------|------|
|
||||
| 7:00-9:00 | 通勤高峰,碎片化浏览 |
|
||||
| 12:00-14:00 | 午休时间,浏览高峰 |
|
||||
| 18:00-20:00 | 下班后休闲浏览 |
|
||||
| 21:00-23:00 | 睡前黄金时段,互动率最高 |
|
||||
|
||||
### SEO 优化
|
||||
|
||||
- 标题和正文前 50 字包含核心关键词
|
||||
- 使用平台搜索下拉词作为参考
|
||||
- 正文自然融入 3-5 个相关关键词(避免堆砌)
|
||||
- 评论区补充关键词(自评增加曝光)
|
||||
|
||||
### 互动率提升
|
||||
|
||||
- 正文中设置互动问题(`你们觉得呢?`)
|
||||
- 结尾提供选择题(`A还是B?评论区投票`)
|
||||
- 24 小时内回复所有评论
|
||||
- 置顶评论放重要补充信息
|
||||
|
||||
## 常见误区
|
||||
|
||||
| 误区 | 正确做法 |
|
||||
|------|---------|
|
||||
| 标题过长(超20字) | 精炼到 20 字以内,信息密度优先 |
|
||||
| 正文大段不分行 | 每 2-3 行空一行,用 emoji 分隔 |
|
||||
| 标签太宽泛 | 组合使用泛词+精准词+长尾词 |
|
||||
| 封面文字太多 | 封面突出视觉冲击,详细信息放正文 |
|
||||
| 纯广告无真实感 | 加入个人体验和真实细节 |
|
||||
| 内容同质化严重 | 找到独特切入角度(身份/场景/反差) |
|
||||
| 忽略评论区运营 | 主动回复并引导二次互动 |
|
||||
|
||||
## 输出格式规范
|
||||
|
||||
每次生成内容时,严格按以下格式输出:
|
||||
|
||||
```markdown
|
||||
## 📝 小红书笔记
|
||||
|
||||
### 标题方案
|
||||
1. [方案一]
|
||||
2. [方案二]
|
||||
3. [方案三]
|
||||
|
||||
### 正文
|
||||
[完整正文内容]
|
||||
|
||||
### 话题标签
|
||||
[8个标签]
|
||||
|
||||
### 封面建议
|
||||
- 推荐风格:[编号+名称]
|
||||
- 具体建议:[详细说明]
|
||||
- 配色方案:[色系建议]
|
||||
|
||||
### 发布建议
|
||||
- 推荐时段:[具体时间]
|
||||
- 注意事项:[补充说明]
|
||||
```
|
||||
@@ -491,6 +491,7 @@ func main() {
|
||||
skillGroup.GET("/list", skillHandler.List)
|
||||
skillGroup.GET("/sync", skillHandler.Sync)
|
||||
skillGroup.GET("/:id", skillHandler.GetByID)
|
||||
skillGroup.GET("/content", skillHandler.GetSkillContent)
|
||||
skillGroup.POST("/add", skillHandler.Create)
|
||||
skillGroup.PUT("/:id", skillHandler.Update)
|
||||
skillGroup.DELETE("/:id", skillHandler.Delete)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -234,6 +235,19 @@ func (h *SkillHandler) getProjectRoot() string {
|
||||
// @Param skill body model.Skill true "技能信息"
|
||||
// @Success 200 {object} map[string]interface{} "{"message": "skill updated"}"
|
||||
// @Router /skill/{id} [put]
|
||||
// Update 更新技能
|
||||
// @Summary 更新技能
|
||||
// @Description 更新技能信息,支持文件上传
|
||||
// @Tags 技能管理
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param id path string true "技能ID"
|
||||
// @Param skill_name formData string false "技能名称"
|
||||
// @Param skill_desc formData string false "技能描述"
|
||||
// @Param skill_type formData string false "技能类型"
|
||||
// @Param file formData file false "技能文件(SKILL.md)"
|
||||
// @Success 200 {object} map[string]interface{} "{"message": "skill updated"}"
|
||||
// @Router /skill/{id} [put]
|
||||
func (h *SkillHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
@@ -241,19 +255,142 @@ func (h *SkillHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var skill model.Skill
|
||||
if err := c.ShouldBindJSON(&skill); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
// 获取现有技能信息
|
||||
existingSkill, err := h.skillService.GetSkillByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"})
|
||||
return
|
||||
}
|
||||
|
||||
skill.ID = id
|
||||
if err := h.skillService.UpdateSkill(&skill); err != nil {
|
||||
skillName := c.PostForm("skill_name")
|
||||
skillDesc := c.PostForm("skill_desc")
|
||||
skillType := c.PostForm("skill_type")
|
||||
|
||||
// 如果没有传则使用现有值
|
||||
if skillName == "" {
|
||||
skillName = existingSkill.SkillName
|
||||
}
|
||||
if skillDesc == "" {
|
||||
skillDesc = existingSkill.SkillDesc
|
||||
}
|
||||
if skillType == "" {
|
||||
skillType = existingSkill.SkillType
|
||||
}
|
||||
|
||||
// 获取项目根目录
|
||||
projectRoot := h.getProjectRoot()
|
||||
if projectRoot == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot find project root"})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果技能名称改变了,需要重命名文件夹
|
||||
oldPath := existingSkill.Path
|
||||
if skillName != existingSkill.SkillName && oldPath != "" {
|
||||
// 旧目录
|
||||
oldDir := filepath.Dir(oldPath)
|
||||
// 新目录
|
||||
skillDir := "user"
|
||||
if skillType == "system" {
|
||||
skillDir = "system"
|
||||
}
|
||||
newDir := filepath.Join(projectRoot, "core", "agents", "skills", skillDir, skillName)
|
||||
|
||||
// 重命名目录
|
||||
if err := os.Rename(oldDir, newDir); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to rename skill directory: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新路径
|
||||
existingSkill.Path = filepath.Join(newDir, "SKILL.md")
|
||||
}
|
||||
|
||||
// 处理文件上传
|
||||
file, err := c.FormFile("file")
|
||||
if err == nil {
|
||||
// 保存上传的文件为 SKILL.md
|
||||
skillFilePath := filepath.Join(filepath.Dir(existingSkill.Path), "SKILL.md")
|
||||
if err := c.SaveUploadedFile(file, skillFilePath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save skill file: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 更新技能信息
|
||||
existingSkill.SkillName = skillName
|
||||
existingSkill.SkillDesc = skillDesc
|
||||
existingSkill.SkillType = skillType
|
||||
|
||||
if err := h.skillService.UpdateSkill(existingSkill); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "skill updated"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "skill updated", "skill": existingSkill})
|
||||
}
|
||||
|
||||
// GetSkillContent 获取技能文件内容
|
||||
// @Summary 获取技能文件内容
|
||||
// @Description 获取指定技能对应的 SKILL.md 文件内容
|
||||
// @Tags 技能管理
|
||||
// @Accept json
|
||||
// @Produce text/plain
|
||||
// @Param id path string true "技能ID"
|
||||
// @Success 200 {string} string "文件内容"
|
||||
// @Router /skill/content [get]
|
||||
func (h *SkillHandler) GetSkillContent(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "skill id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取技能信息
|
||||
skill, err := h.skillService.GetSkillByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 确定文件路径
|
||||
filePath := skill.Path
|
||||
if filePath == "" {
|
||||
// 如果 path 为空,根据 skill_name 和 skill_type 构造默认路径
|
||||
projectRoot := h.getProjectRoot()
|
||||
if projectRoot != "" {
|
||||
skillDir := "user"
|
||||
if skill.SkillType == "system" {
|
||||
skillDir = "system"
|
||||
}
|
||||
filePath = filepath.Join(projectRoot, "core", "agents", "skills", skillDir, skill.SkillName, "SKILL.md")
|
||||
fmt.Printf("GetSkillContent: path is empty, constructed path: %s\n", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有路径,返回错误
|
||||
if filePath == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "skill path is empty"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
fmt.Printf("GetSkillContent error: file not found, path=%s\n", filePath)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "skill file not found: " + filePath})
|
||||
return
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Printf("GetSkillContent error: read file failed, path=%s, err=%s\n", filePath, err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("GetSkillContent success: id=%s, path=%s, size=%d\n", id, filePath, len(content))
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", content)
|
||||
}
|
||||
|
||||
// Delete 删除技能
|
||||
|
||||
@@ -9,11 +9,13 @@ const {
|
||||
searchQuery,
|
||||
filterStatus,
|
||||
isEditing,
|
||||
isEditingStep2,
|
||||
isCreating,
|
||||
isEditingContent,
|
||||
editForm,
|
||||
newSkillForm,
|
||||
newSkillContent,
|
||||
editSkillContent,
|
||||
filteredSkills,
|
||||
fetchSkills,
|
||||
openCreate,
|
||||
@@ -23,21 +25,20 @@ const {
|
||||
saveNewSkill,
|
||||
openEdit,
|
||||
closeEdit,
|
||||
saveEdit,
|
||||
goToEditStep2,
|
||||
saveEditStep2,
|
||||
closeEditStep2,
|
||||
toggleStatus,
|
||||
deleteSkill,
|
||||
// 导入相关 - 从 useSkills 引入
|
||||
fileInputRef,
|
||||
// 导入相关
|
||||
isImporting,
|
||||
isImportingDialog,
|
||||
importFileName,
|
||||
importSkillName,
|
||||
importSkillDesc,
|
||||
importSkillContent,
|
||||
isImportStep2,
|
||||
openImportDialog,
|
||||
closeImportDialog,
|
||||
handleFileChange,
|
||||
submitImport,
|
||||
handleFolderSelect,
|
||||
// 下拉菜单
|
||||
@@ -215,7 +216,58 @@ onMounted(() => {
|
||||
|
||||
<div class="flex items-center justify-end gap-3 p-5 border-t border-dark-500">
|
||||
<button @click="closeEdit" class="btn-secondary">Cancel</button>
|
||||
<button @click="saveEdit" class="btn-primary">Save Changes</button>
|
||||
<button @click="goToEditStep2" class="btn-primary">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 编辑弹窗第二步:编辑内容 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="isEditingStep2" class="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4 modal-overlay">
|
||||
<div class="bg-dark-800 rounded-2xl w-full max-w-4xl border border-dark-600 shadow-2xl overflow-hidden modal-content" style="max-height: 90vh;">
|
||||
<div class="flex items-center justify-between p-5 border-b border-dark-600 bg-dark-700/50">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-500 flex items-center justify-center">
|
||||
<Edit class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white">Edit Skill Content</h3>
|
||||
<p class="text-sm text-gray-400">Configure skill details in SKILL.md format</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeEditStep2" class="btn-icon">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4" style="max-height: calc(90vh - 180px); overflow-y: auto;">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Skill Name</label>
|
||||
<input v-model="editForm.skill_name" type="text" class="input-field" readonly>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Category</label>
|
||||
<select v-model="editForm.skill_type" class="input-field" disabled>
|
||||
<option value="user">User</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">SKILL.md Content</label>
|
||||
<textarea v-model="editSkillContent" rows="20" placeholder="Write your skill content in markdown format..." class="input-field resize-none font-mono text-sm" style="min-height: 400px;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
<p>Tip: SKILL.md should contain YAML front matter with name and description, followed by the skill implementation in markdown.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 p-5 border-t border-dark-600 bg-dark-700/50">
|
||||
<button @click="closeEditStep2" class="btn-secondary">Back</button>
|
||||
<button @click="saveEditStep2" class="btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,7 +371,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-white">Import Skill</h3>
|
||||
<p class="text-sm text-gray-400">Select a file or folder to import</p>
|
||||
<p class="text-sm text-gray-400">Select a folder containing SKILL.md</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="closeImportDialog" class="btn-icon">
|
||||
@@ -327,43 +379,24 @@ onMounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- 导入文件选项 -->
|
||||
<div
|
||||
class="border-2 border-dashed border-dark-500 rounded-xl p-6 text-center cursor-pointer hover:border-blue-500 transition-colors"
|
||||
@click="fileInputRef?.click?.()"
|
||||
>
|
||||
<FolderInput class="w-10 h-10 mx-auto text-gray-400 mb-2" />
|
||||
<p class="text-white mb-1">Import File</p>
|
||||
<p class="text-sm text-gray-500">Select a single SKILL.md file</p>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<!-- 导入单个文件选项 -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".md"
|
||||
style="display: none"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
|
||||
<!-- 导入文件夹选项 -->
|
||||
<input
|
||||
type="file"
|
||||
webkitdirectory=""
|
||||
directory=""
|
||||
multiple
|
||||
style="display: none"
|
||||
@change="handleFolderSelect"
|
||||
id="folderInput"
|
||||
/>
|
||||
<label for="folderInput" class="block">
|
||||
<div
|
||||
class="border-2 border-dashed border-dark-500 rounded-xl p-6 text-center cursor-pointer hover:border-green-500 transition-colors"
|
||||
class="border-2 border-dashed border-dark-500 rounded-xl p-10 text-center cursor-pointer hover:border-green-500 transition-colors"
|
||||
>
|
||||
<svg class="w-10 h-10 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
||||
<svg class="w-14 h-14 mx-auto text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<p class="text-white mb-1">Import Folder</p>
|
||||
<p class="text-sm text-gray-500">Select a folder containing SKILL.md</p>
|
||||
<p class="text-white text-lg mb-1">Import SKILL.md</p>
|
||||
<p class="text-sm text-gray-500">Select a SKILL.md file</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -40,8 +40,9 @@ export function useSkills() {
|
||||
|
||||
// 编辑状态
|
||||
const isEditing = ref(false)
|
||||
const isEditingContent = ref(false) // 创建弹窗的第二步
|
||||
const isEditingStep2 = ref(false) // 编辑弹窗的第二步
|
||||
const isCreating = ref(false)
|
||||
const isEditingContent = ref(false)
|
||||
const editingSkill = ref<Skill | null>(null)
|
||||
|
||||
// 表单
|
||||
@@ -58,6 +59,7 @@ export function useSkills() {
|
||||
})
|
||||
|
||||
const newSkillContent = ref('')
|
||||
const editSkillContent = ref('') // 编辑弹窗第二步的 SKILL.md 内容
|
||||
|
||||
// ============ 方法 ============
|
||||
|
||||
@@ -206,38 +208,152 @@ ${newSkillForm.value.skill_desc}
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑弹窗
|
||||
const openEdit = (skill: Skill) => {
|
||||
// 打开编辑弹窗(第一步)
|
||||
const openEdit = async (skill: Skill) => {
|
||||
editingSkill.value = skill
|
||||
editForm.value = {
|
||||
skill_name: skill.skill_name,
|
||||
skill_desc: skill.skill_desc,
|
||||
skill_type: skill.skill_type,
|
||||
}
|
||||
|
||||
// 读取现有的 SKILL.md 内容
|
||||
console.log('Opening edit for skill:', skill)
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/skill/content?id=${skill.id}`)
|
||||
console.log('Content API response:', response.status, response.statusText)
|
||||
if (response.ok) {
|
||||
const text = await response.text()
|
||||
console.log('Skill content:', text.substring(0, 100))
|
||||
editSkillContent.value = text
|
||||
} else {
|
||||
// API 返回 404 说明文件不存在,生成默认内容
|
||||
console.log('File not found, using default content')
|
||||
editSkillContent.value = `---
|
||||
name: ${skill.skill_name}
|
||||
description: ${skill.skill_desc || 'A custom skill'}
|
||||
category: ${skill.skill_type || 'user'}
|
||||
---
|
||||
|
||||
# ${skill.skill_name}
|
||||
|
||||
${skill.skill_desc || 'A custom skill'}
|
||||
|
||||
## Instructions
|
||||
|
||||
Describe what this skill does and how it works.
|
||||
|
||||
## Tools
|
||||
|
||||
List the tools this skill can use:
|
||||
|
||||
-
|
||||
|
||||
## Examples
|
||||
|
||||
Provide some examples of how to use this skill:
|
||||
|
||||
\`\`\`
|
||||
Example 1:
|
||||
\`\`\`
|
||||
`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting skill content:', error)
|
||||
// 网络错误也使用默认内容
|
||||
editSkillContent.value = `---
|
||||
name: ${skill.skill_name}
|
||||
description: ${skill.skill_desc || 'A custom skill'}
|
||||
category: ${skill.skill_type || 'user'}
|
||||
---
|
||||
|
||||
# ${skill.skill_name}
|
||||
|
||||
${skill.skill_desc || 'A custom skill'}
|
||||
|
||||
## Instructions
|
||||
|
||||
Describe what this skill does and how it works.
|
||||
|
||||
## Tools
|
||||
|
||||
List the tools this skill can use:
|
||||
|
||||
-
|
||||
|
||||
## Examples
|
||||
|
||||
Provide some examples of how to use this skill:
|
||||
|
||||
\`\`\`
|
||||
Example 1:
|
||||
\`\`\`
|
||||
`
|
||||
}
|
||||
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
// 关闭编辑弹窗
|
||||
const closeEdit = () => {
|
||||
isEditing.value = false
|
||||
isEditingStep2.value = false
|
||||
editingSkill.value = null
|
||||
editSkillContent.value = ''
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
const saveEdit = async () => {
|
||||
// 编辑第一步:点击 Next,跳转到编辑内容
|
||||
const goToEditStep2 = () => {
|
||||
if (!editForm.value.skill_name || !editForm.value.skill_desc) {
|
||||
ElMessage.warning('Please fill in skill name and description')
|
||||
return
|
||||
}
|
||||
isEditing.value = false
|
||||
isEditingStep2.value = true
|
||||
}
|
||||
|
||||
// 编辑第二步:保存
|
||||
const saveEditStep2 = async () => {
|
||||
if (!editingSkill.value) return
|
||||
|
||||
try {
|
||||
await updateSkill(editingSkill.value!.id, {
|
||||
skill_name: editForm.value.skill_name,
|
||||
skill_desc: editForm.value.skill_desc,
|
||||
skill_type: editForm.value.skill_type,
|
||||
// 将内容转换为文件上传
|
||||
const blob = new Blob([editSkillContent.value], { type: 'text/markdown' })
|
||||
const file = new File([blob], 'SKILL.md', { type: 'text/markdown' })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('skill_name', editForm.value.skill_name)
|
||||
formData.append('skill_desc', editForm.value.skill_desc)
|
||||
formData.append('skill_type', editForm.value.skill_type)
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch(`${API_BASE}/skill/${editingSkill.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
})
|
||||
ElMessage.success('Skill updated successfully')
|
||||
isEditing.value = false
|
||||
|
||||
if (response.ok) {
|
||||
ElMessage.success('Skill updated successfully')
|
||||
await fetchSkills()
|
||||
isEditingStep2.value = false
|
||||
editingSkill.value = null
|
||||
editSkillContent.value = ''
|
||||
} else {
|
||||
const data = await response.json()
|
||||
ElMessage.error(data.error || 'Failed to update skill')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update skill:', error)
|
||||
ElMessage.error('Failed to update skill')
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭编辑第二步弹窗
|
||||
const closeEditStep2 = () => {
|
||||
isEditingStep2.value = false
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
const toggleStatus = async (skill: Skill) => {
|
||||
const newStatus = skill.status === 'active' ? 'inactive' : 'active'
|
||||
@@ -384,7 +500,7 @@ ${newSkillForm.value.skill_desc}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件夹选择
|
||||
// 处理文件选择(单个 SKILL.md 文件)
|
||||
const handleFolderSelect = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
@@ -394,25 +510,13 @@ ${newSkillForm.value.skill_desc}
|
||||
isImporting.value = true
|
||||
|
||||
try {
|
||||
const folder = files[0].webkitRelativePath?.split('/')[0] || files[0].name
|
||||
const file = files[0]
|
||||
|
||||
// 查找 SKILL.md 文件
|
||||
let skillMdFile: File | null = null
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (file.name === 'SKILL.md' || file.webkitRelativePath?.endsWith('/SKILL.md')) {
|
||||
skillMdFile = file
|
||||
break
|
||||
}
|
||||
}
|
||||
// 读取文件内容
|
||||
const content = await file.text()
|
||||
const fileName = file.name.replace('.md', '')
|
||||
|
||||
if (!skillMdFile) {
|
||||
ElMessage.error('导入失败:所选文件夹中未找到 SKILL.md 文件')
|
||||
return
|
||||
}
|
||||
|
||||
const content = await skillMdFile.text()
|
||||
const { skillName, skillDesc } = parseSkillContent(content, folder)
|
||||
const { skillName, skillDesc } = parseSkillContent(content, fileName)
|
||||
|
||||
// 使用导入弹窗显示内容
|
||||
importSkillName.value = skillName
|
||||
@@ -422,7 +526,7 @@ ${newSkillForm.value.skill_desc}
|
||||
isImportStep2.value = true
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error)
|
||||
ElMessage.error('导入失败,请检查文件夹格式是否正确')
|
||||
ElMessage.error('导入失败,请检查文件格式是否正确')
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
input.value = ''
|
||||
@@ -453,12 +557,14 @@ ${newSkillForm.value.skill_desc}
|
||||
searchQuery,
|
||||
filterStatus,
|
||||
isEditing,
|
||||
isEditingStep2,
|
||||
isCreating,
|
||||
isEditingContent,
|
||||
editingSkill,
|
||||
editForm,
|
||||
newSkillForm,
|
||||
newSkillContent,
|
||||
editSkillContent,
|
||||
// Computed
|
||||
filteredSkills,
|
||||
// Methods
|
||||
@@ -470,7 +576,9 @@ ${newSkillForm.value.skill_desc}
|
||||
saveNewSkill,
|
||||
openEdit,
|
||||
closeEdit,
|
||||
saveEdit,
|
||||
goToEditStep2,
|
||||
saveEditStep2,
|
||||
closeEditStep2,
|
||||
toggleStatus,
|
||||
deleteSkill: handleDeleteSkill,
|
||||
// 导入相关
|
||||
|
||||
Reference in New Issue
Block a user