Compare commits

..

3 Commits

Author SHA1 Message Date
249e2c2e9c feat: 更新用户自定义skill
- 新增用户123的skill定义
- 新增用户openakita的小红书创作skill
- 更新jimliu的文章配图skill文档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:42:58 +08:00
c1bc4ac91d feat: 更新skill前端页面 - 新增编辑功能
- skill.ts: 新增编辑弹窗第二步、获取skill内容功能
- Skill.vue: 更新skill编辑界面

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:42:48 +08:00
030b21949b feat: 新增获取skill内容的API路由
- 新增 GET /skill/content 获取skill的SKILL.md内容

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:42:33 +08:00
7 changed files with 821 additions and 72 deletions

View File

@@ -0,0 +1,8 @@
---
name: 123
description: 123
---
# 123
123

View File

@@ -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 `![description](path/NN-{type}-{slug}.png)` 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 |

View File

@@ -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个标签]
### 封面建议
- 推荐风格:[编号+名称]
- 具体建议:[详细说明]
- 配色方案:[色系建议]
### 发布建议
- 推荐时段:[具体时间]
- 注意事项:[补充说明]
```

View File

@@ -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)

View File

@@ -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 删除技能

View File

@@ -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>

View File

@@ -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,
// 导入相关