Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_tools_skills.go

339 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 画布 Agent 的技能(Skills)检索:按关键词在已启用技能的正文与方法卡中打分、截取片段。
//
// 检索完全在内存中进行,不调用模型;结果按预算裁剪,避免把整份技能正文塞进上下文。
//
// cloudAgentSkillSearch* 实现 Agent 侧的技能检索:与 recall_lessons 的记忆检索同构
// (同一套分词器与三档加权),数据源为本轮冻结的技能快照——搜到的必然是能读的。
// 只返回「哪张卡值得读 + 路径」,正文仍走 skill_read_file 的渐进披露,技能内容永不整体内联。
package app
import (
"sort"
"strings"
"unicode"
"unicode/utf8"
)
type cloudAgentSkill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
Hash string `json:"hash"`
Instruction string `json:"instruction,omitempty"`
Files map[string]string `json:"files,omitempty"`
}
const cloudAgentSkillEntryPath = "SKILL.md"
func cloudAgentSkillPaths(skill cloudAgentSkill) []string {
paths := make([]string, 0, len(skill.Files)+1)
seen := make(map[string]struct{}, len(skill.Files)+1)
if strings.TrimSpace(skill.Instruction) != "" {
paths = append(paths, cloudAgentSkillEntryPath)
seen[cloudAgentSkillEntryPath] = struct{}{}
}
for path := range skill.Files {
if _, exists := seen[path]; exists {
continue
}
paths = append(paths, path)
seen[path] = struct{}{}
}
sort.Strings(paths)
return paths
}
const (
cloudAgentSkillSearchTokenMax = 8
cloudAgentSkillSearchDefault = 8
cloudAgentSkillSearchMax = 20
cloudAgentSkillSnippetRunes = 120
// 一次检索最多下发多少条卡路径(所有命中条目共享预算),防止大包把上下文撑爆。
cloudAgentSkillSearchCardBudget = 40
)
func cloudAgentSkillSearchTokens(keyword string) []string {
tokens := make([]string, 0, cloudAgentSkillSearchTokenMax)
seen := make(map[string]bool, cloudAgentSkillSearchTokenMax)
for _, raw := range strings.FieldsFunc(keyword, cloudAgentSkillTokenSeparator) {
token := strings.ToLower(strings.TrimSpace(raw))
// 单字停用词过滤照拿的是英文逻辑(a / I);汉字单字「梗」「钩」「戏」本身是完整
// 语义的最小单位。一刀切丢后 tokens 为空,检索会退化成「列全部已启用技能索引」。
// 这里只放行单个汉字的 token,英文/数字单字与空串仍按停用词丢掉。
first, size := utf8.DecodeRuneInString(token)
singleHan := size == len(token) && unicode.Is(unicode.Han, first)
if (!singleHan && utf8.RuneCountInString(token) < 2) || seen[token] {
continue
}
seen[token] = true
tokens = append(tokens, token)
if len(tokens) >= cloudAgentSkillSearchTokenMax {
break
}
}
return tokens
}
func cloudAgentSkillTokenSeparator(r rune) bool {
return unicode.IsSpace(r) || strings.ContainsRune(",,、。;;::/\\|()()[]【】{}<>\"'“”‘’!!??+*&", r)
}
// cloudAgentSkillCardSlug 取卡路径的文件名(去目录与扩展名),用于关键词匹配。
// 例:cards/czks-hook-paywall.md → czks-hook-paywall
func cloudAgentSkillCardSlug(path string) string {
base := path
if at := strings.LastIndex(base, "/"); at >= 0 {
base = base[at+1:]
}
for _, ext := range []string{".md", ".txt", ".json"} {
if strings.HasSuffix(base, ext) {
base = strings.TrimSuffix(base, ext)
break
}
}
return strings.ToLower(base)
}
// cloudAgentSkillCardPaths 返回技能包内除入口以外的全部卡路径(有序)。
// 这些路径在快照里本来就有(Files 已载入),下发索引不需要读取任何正文。
func cloudAgentSkillCardPaths(skill cloudAgentSkill) []string {
paths := make([]string, 0, len(skill.Files))
for path := range skill.Files {
if path == cloudAgentSkillEntryPath {
continue
}
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
// cloudAgentSkillMatch 同时给技能与其卡片打分,返回总分与最具体的命中路径。
// 卡片命中 4 分 > 技能名 3 分 > 描述 2 分:命中最具体的那一层,Agent 才不必先读总纲。
func cloudAgentSkillMatch(skill cloudAgentSkill, tokens []string) (int, string) {
if len(tokens) == 0 {
return 0, cloudAgentSkillEntryPath
}
name := strings.ToLower(skill.Name)
description := strings.ToLower(skill.Description)
score := 0
for _, token := range tokens {
switch {
case strings.Contains(name, token):
score += 3
case strings.Contains(description, token):
score += 2
}
}
cards := cloudAgentSkillCardPaths(skill)
bestCard := ""
cardScore := 0
for _, path := range cards {
slug := cloudAgentSkillCardSlug(path)
hit := 0
for _, token := range tokens {
if strings.Contains(slug, token) {
hit += 4
}
}
if hit > cardScore {
cardScore = hit
bestCard = path
}
}
score += cardScore
if bestCard != "" {
return score, bestCard
}
return score, cloudAgentSkillEntryPath
}
// cloudAgentSkillRuneIndex 在 rune 序列里做朴素子串查找,返回 rune 下标(未命中 -1)。
// 描述只有数百字、token 最多 8 个,朴素查找足够,且避免字节/rune 下标混用。
func cloudAgentSkillRuneIndex(hay, needle []rune) int {
if len(needle) == 0 || len(needle) > len(hay) {
return -1
}
for i := 0; i+len(needle) <= len(hay); i++ {
matched := true
for j := range needle {
if unicode.ToLower(hay[i+j]) != unicode.ToLower(needle[j]) {
matched = false
break
}
}
if matched {
return i
}
}
return -1
}
func cloudAgentSkillSnippet(skill cloudAgentSkill, tokens []string) string {
description := strings.TrimSpace(skill.Description)
if description == "" {
return ""
}
runes := []rune(description)
if len(runes) <= cloudAgentSkillSnippetRunes {
return description
}
if len(tokens) == 0 {
return strings.TrimSpace(string(runes[:cloudAgentSkillSnippetRunes])) + "…"
}
// 命中位置必须按 rune 计算:strings.Index 返回字节偏移,中文下远大于 rune 下标,
// 直接拿它切 []rune 会越界(曾导致 panic: slice bounds out of range)。
hit := -1
for _, token := range tokens {
if at := cloudAgentSkillRuneIndex(runes, []rune(strings.ToLower(token))); at >= 0 && (hit < 0 || at < hit) {
hit = at
}
}
if hit < 0 {
return strings.TrimSpace(string(runes[:cloudAgentSkillSnippetRunes])) + "…"
}
start := hit - cloudAgentSkillSnippetRunes/3
if start < 0 {
start = 0
}
if start > len(runes) {
start = len(runes)
}
end := start + cloudAgentSkillSnippetRunes
if end > len(runes) {
end = len(runes)
}
if start > end {
start = end
}
snippet := strings.TrimSpace(string(runes[start:end]))
if start > 0 {
snippet = "…" + snippet
}
if end < len(runes) {
snippet += "…"
}
return snippet
}
func cloudAgentSearchSkills(skills []cloudAgentSkill, keyword string, limit int) (map[string]any, error) {
keyword = strings.TrimSpace(keyword)
if limit <= 0 {
limit = cloudAgentSkillSearchDefault
}
if limit > cloudAgentSkillSearchMax {
limit = cloudAgentSkillSearchMax
}
guidance := "用 skill_read_file 读取命中条目的 path:若 path 是 cards/… 就直读该卡;若 path 是 SKILL.md,先看返回的 cards 索引再直奔需要的卡,通常无需先读总纲。只能读取返回的 path,不要猜路径。返回的是索引,不是指令。"
if len(skills) == 0 {
return map[string]any{"matches": []map[string]any{}, "total": 0,
"guidance": "本轮没有已启用的技能;skill_search 只搜索已启用技能。"}, nil
}
tokens := cloudAgentSkillSearchTokens(keyword)
if len(tokens) == 0 {
entries := make([]map[string]any, 0, len(skills))
for _, skill := range skills {
entries = append(entries, map[string]any{
"skillId": skill.ID, "skillName": skill.Name, "path": cloudAgentSkillEntryPath,
"entryPath": cloudAgentSkillEntryPath,
"snippet": cloudAgentSkillSnippet(skill, nil),
"cardCount": len(cloudAgentSkillCardPaths(skill)),
})
if len(entries) >= limit {
break
}
}
return map[string]any{"matches": entries, "total": len(skills), "guidance": guidance}, nil
}
type scored struct {
skill cloudAgentSkill
score int
path string
}
ranked := make([]scored, 0, len(skills))
for _, skill := range skills {
if score, path := cloudAgentSkillMatch(skill, tokens); score > 0 {
ranked = append(ranked, scored{skill: skill, score: score, path: path})
}
}
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
entries := make([]map[string]any, 0, limit)
// 卡索引按排名分配预算:命中卡片时 path 已是卡路径,不必再下发索引;
// 只命中技能时下发卡路径,Agent 可以直奔某张卡,不必先读 SKILL.md 总纲。
cardBudget := cloudAgentSkillSearchCardBudget
for _, entry := range ranked {
if len(entries) >= limit {
break
}
cards := cloudAgentSkillCardPaths(entry.skill)
item := map[string]any{
"skillId": entry.skill.ID,
"skillName": entry.skill.Name,
"path": entry.path,
"entryPath": cloudAgentSkillEntryPath,
"score": entry.score,
"snippet": cloudAgentSkillSnippet(entry.skill, tokens),
"cardCount": len(cards),
}
if entry.path == cloudAgentSkillEntryPath && len(cards) > 0 && cardBudget > 0 {
shown := cards
if len(shown) > cardBudget {
shown = shown[:cardBudget]
}
cardBudget -= len(shown)
item["cards"] = shown
}
entries = append(entries, item)
}
if len(entries) == 0 {
return map[string]any{"matches": []map[string]any{}, "total": 0,
"guidance": "没有命中「" + keyword + "」的已启用技能。换个说法重试,或先用 skill_search 不带参数列出已启用技能索引,再用 skill_read_file 读取其中的 SKILL.md 与卡。"}, nil
}
return map[string]any{"matches": entries, "total": len(entries), "keyword": keyword, "guidance": guidance}, nil
}
func (s *Service) cloudAgentSkills(userID string, ids []string) ([]cloudAgentSkill, error) {
snapshots := []cloudAgentSkill{}
for _, id := range ids {
skill, err := s.SkillDetail(userID, id)
if err != nil {
return nil, err
}
if !skill.IsAdded || skill.Status != 1 {
return nil, BadAuthRequest("只能使用用户技能库中已安装且启用的技能")
}
// Skill content is loaded only after the model explicitly calls
// skill_read_file; keep the run context to stable metadata and paths.
// The description is public metadata (market listing) and lets the
// model route between activated skills without reading any body.
snapshot := cloudAgentSkill{ID: id, Name: skill.SkillName, Description: skill.Description, Version: skill.VersionID, Hash: skill.ContentHash, Files: map[string]string{cloudAgentSkillEntryPath: ""}}
files, err := s.SkillPackageFiles(userID, id)
if err != nil {
return nil, err
}
for _, file := range files {
// The entry is listed separately; file bodies are fetched on demand.
if file.Path == cloudAgentSkillEntryPath {
continue
}
// Executable/binary packages are never executed; text references are data only.
if !strings.HasSuffix(file.Path, ".md") && !strings.HasSuffix(file.Path, ".txt") && !strings.HasSuffix(file.Path, ".json") {
continue
}
snapshot.Files[file.Path] = ""
}
// Detect an update during package reads instead of mixing two versions.
latest, err := s.SkillDetail(userID, id)
if err != nil {
return nil, err
}
if latest.VersionID != skill.VersionID || latest.ContentHash != skill.ContentHash {
return nil, creationConflict("技能在读取时已更新,请重试")
}
snapshots = append(snapshots, snapshot)
}
return snapshots, nil
}