feat: init sproutclaw-web — Go+Gin backend + React frontend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
91
backend/internal/services/commands.go
Normal file
91
backend/internal/services/commands.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// builtinCommands are the slash commands the WebUI handles natively.
|
||||
var builtinCommands = []models.SlashCommand{
|
||||
{Name: "new", Description: "开启新会话", Source: "builtin"},
|
||||
{Name: "compact", Description: "压缩会话历史", Source: "builtin"},
|
||||
{Name: "reload", Description: "重载配置", Source: "builtin"},
|
||||
{Name: "clone", Description: "克隆当前会话", Source: "builtin"},
|
||||
{Name: "name", Description: "重命名当前会话", Source: "builtin"},
|
||||
{Name: "model", Description: "切换模型", Source: "builtin"},
|
||||
{Name: "session", Description: "显示会话统计", Source: "builtin"},
|
||||
{Name: "export", Description: "导出为 HTML", Source: "builtin"},
|
||||
{Name: "copy", Description: "复制最后一条助手消息", Source: "builtin"},
|
||||
{Name: "webui", Description: "WebUI 控制", Source: "builtin"},
|
||||
}
|
||||
|
||||
// ListSlashCommands aggregates all slash commands visible in the WebUI.
|
||||
func ListSlashCommands(agentDir string) []models.SlashCommand {
|
||||
cmds := make([]models.SlashCommand, len(builtinCommands))
|
||||
copy(cmds, builtinCommands)
|
||||
|
||||
// scan skills dir for SKILL.md files
|
||||
skillsDir := filepath.Join(agentDir, "skills")
|
||||
cmds = append(cmds, scanSkillCommands(skillsDir)...)
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func scanSkillCommands(skillsDir string) []models.SlashCommand {
|
||||
entries, err := os.ReadDir(skillsDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cmds []models.SlashCommand
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
skillMd := filepath.Join(skillsDir, e.Name(), "SKILL.md")
|
||||
name, desc := parseSkillMD(skillMd)
|
||||
if name == "" {
|
||||
name = e.Name()
|
||||
}
|
||||
cmds = append(cmds, models.SlashCommand{
|
||||
Name: name,
|
||||
Description: desc,
|
||||
Source: "skill",
|
||||
})
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func parseSkillMD(path string) (name, desc string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
content := string(data)
|
||||
// parse frontmatter between --- delimiters
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
return "", ""
|
||||
}
|
||||
parts := strings.SplitN(content, "---", 3)
|
||||
if len(parts) < 3 {
|
||||
return "", ""
|
||||
}
|
||||
fm := parts[1]
|
||||
for _, line := range strings.Split(fm, "\n") {
|
||||
kv := strings.SplitN(line, ":", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(kv[0])
|
||||
v := strings.TrimSpace(kv[1])
|
||||
switch k {
|
||||
case "name":
|
||||
name = v
|
||||
case "description":
|
||||
desc = v
|
||||
}
|
||||
}
|
||||
return name, desc
|
||||
}
|
||||
105
backend/internal/services/extensions.go
Normal file
105
backend/internal/services/extensions.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// ListExtensions reads the extensions directory and returns metadata.
|
||||
func ListExtensions(agentDir string) []models.ExtensionInfo {
|
||||
extDir := filepath.Join(agentDir, "extensions")
|
||||
entries, err := os.ReadDir(extDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var exts []models.ExtensionInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := buildExtensionInfo(filepath.Join(extDir, e.Name()), e.Name())
|
||||
exts = append(exts, ext)
|
||||
}
|
||||
return exts
|
||||
}
|
||||
|
||||
func buildExtensionInfo(path, id string) models.ExtensionInfo {
|
||||
info := models.ExtensionInfo{
|
||||
ID: id,
|
||||
Name: id,
|
||||
Enabled: true, // assume enabled unless configured otherwise
|
||||
Source: "local",
|
||||
}
|
||||
|
||||
// try package.json for version/name
|
||||
pkgPath := filepath.Join(path, "package.json")
|
||||
if data, err := os.ReadFile(pkgPath); err == nil {
|
||||
var pkg struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.Unmarshal(data, &pkg) == nil {
|
||||
if pkg.Name != "" {
|
||||
info.Name = pkg.Name
|
||||
}
|
||||
info.Version = pkg.Version
|
||||
// detect npm packages
|
||||
if strings.Contains(pkg.Name, "/") || strings.HasPrefix(pkg.Name, "@") {
|
||||
info.Source = "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// ToggleExtension is a placeholder – full implementation requires settings.json management.
|
||||
func ToggleExtension(agentDir, id string, enable bool) error {
|
||||
return updateSettingsExtensions(filepath.Join(agentDir, "settings.json"), id, enable)
|
||||
}
|
||||
|
||||
func updateSettingsExtensions(settingsPath, id string, enable bool) error {
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
} else {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
|
||||
// extensions is a list of enabled extension IDs
|
||||
extList, _ := settings["extensions"].([]any)
|
||||
set := map[string]bool{}
|
||||
for _, e := range extList {
|
||||
if s, ok := e.(string); ok {
|
||||
set[s] = true
|
||||
}
|
||||
}
|
||||
if enable {
|
||||
set[id] = true
|
||||
} else {
|
||||
delete(set, id)
|
||||
}
|
||||
|
||||
newList := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
newList = append(newList, k)
|
||||
}
|
||||
settings["extensions"] = newList
|
||||
|
||||
out, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(settingsPath, append(out, '\n'), 0o644)
|
||||
}
|
||||
42
backend/internal/services/images.go
Normal file
42
backend/internal/services/images.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
maxImages = 8
|
||||
maxImageSize = 10 * 1024 * 1024 // 10 MB
|
||||
)
|
||||
|
||||
var allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
}
|
||||
|
||||
// ValidateImages checks image count, type, and base64-decoded size.
|
||||
func ValidateImages(images []models.ChatImage) error {
|
||||
if len(images) > maxImages {
|
||||
return fmt.Errorf("最多支持 %d 张图片,当前 %d 张", maxImages, len(images))
|
||||
}
|
||||
for i, img := range images {
|
||||
mt := strings.ToLower(img.MediaType)
|
||||
if !allowedImageTypes[mt] {
|
||||
return fmt.Errorf("图片 %d 类型不支持: %s", i+1, img.MediaType)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(img.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("图片 %d base64 解码失败", i+1)
|
||||
}
|
||||
if len(decoded) > maxImageSize {
|
||||
return fmt.Errorf("图片 %d 超过 10MB 限制", i+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
133
backend/internal/services/mcp.go
Normal file
133
backend/internal/services/mcp.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
type mcpConfig struct {
|
||||
MCPServers map[string]any `json:"mcpServers"`
|
||||
MCPServersDisabled []string `json:"mcpServersDisabled,omitempty"`
|
||||
ExcludeTools []string `json:"excludeTools,omitempty"`
|
||||
}
|
||||
|
||||
// ReadMCPServers loads mcp.json and returns the list of servers with their tools.
|
||||
func ReadMCPServers(mcpConfigPath, mcpCachePath string) []models.MCPServer {
|
||||
cfg := loadMCPConfig(mcpConfigPath)
|
||||
cache := loadMCPCache(mcpCachePath)
|
||||
|
||||
disabledSet := map[string]bool{}
|
||||
for _, s := range cfg.MCPServersDisabled {
|
||||
disabledSet[s] = true
|
||||
}
|
||||
excludeSet := map[string]bool{}
|
||||
for _, t := range cfg.ExcludeTools {
|
||||
excludeSet[t] = true
|
||||
}
|
||||
|
||||
var servers []models.MCPServer
|
||||
for name := range cfg.MCPServers {
|
||||
srv := models.MCPServer{
|
||||
Name: name,
|
||||
Disabled: disabledSet[name],
|
||||
}
|
||||
// add tools from cache
|
||||
if toolNames, ok := cache[name]; ok {
|
||||
for _, t := range toolNames {
|
||||
srv.Tools = append(srv.Tools, models.MCPTool{
|
||||
Name: t,
|
||||
Disabled: excludeSet[name+"/"+t] || excludeSet[t],
|
||||
})
|
||||
}
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// ToggleMCPServer enables or disables a server.
|
||||
func ToggleMCPServer(mcpConfigPath, serverName string, enable bool) error {
|
||||
cfg := loadMCPConfig(mcpConfigPath)
|
||||
set := map[string]bool{}
|
||||
for _, s := range cfg.MCPServersDisabled {
|
||||
set[s] = true
|
||||
}
|
||||
if enable {
|
||||
delete(set, serverName)
|
||||
} else {
|
||||
set[serverName] = true
|
||||
}
|
||||
cfg.MCPServersDisabled = keys(set)
|
||||
return saveMCPConfig(mcpConfigPath, cfg)
|
||||
}
|
||||
|
||||
// ToggleMCPTool enables or disables a specific tool on a server.
|
||||
func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) error {
|
||||
cfg := loadMCPConfig(mcpConfigPath)
|
||||
key := serverName + "/" + toolName
|
||||
set := map[string]bool{}
|
||||
for _, t := range cfg.ExcludeTools {
|
||||
set[t] = true
|
||||
}
|
||||
if enable {
|
||||
delete(set, key)
|
||||
} else {
|
||||
set[key] = true
|
||||
}
|
||||
cfg.ExcludeTools = keys(set)
|
||||
return saveMCPConfig(mcpConfigPath, cfg)
|
||||
}
|
||||
|
||||
func loadMCPConfig(path string) mcpConfig {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return mcpConfig{MCPServers: map[string]any{}}
|
||||
}
|
||||
var cfg mcpConfig
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
if cfg.MCPServers == nil {
|
||||
cfg.MCPServers = map[string]any{}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func saveMCPConfig(path string, cfg mcpConfig) error {
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func loadMCPCache(path string) map[string][]string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// cache format: { "serverName": { "tools": [{ "name": "..." }] } }
|
||||
var raw map[string]struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
result := map[string][]string{}
|
||||
for srv, info := range raw {
|
||||
for _, t := range info.Tools {
|
||||
result[srv] = append(result[srv], t.Name)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func keys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
28
backend/internal/services/models_config.go
Normal file
28
backend/internal/services/models_config.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ReadModelsConfig reads models.json as a raw string.
|
||||
func ReadModelsConfig(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "{}", nil
|
||||
}
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// WriteModelsConfig validates JSON and writes models.json.
|
||||
func WriteModelsConfig(path, content string) error {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(content), &v); err != nil {
|
||||
return err
|
||||
}
|
||||
pretty, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(pretty, '\n'), 0o644)
|
||||
}
|
||||
171
backend/internal/services/sessions.go
Normal file
171
backend/internal/services/sessions.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sproutclaw-web/internal/db"
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// jsonlLine is a minimal parse of one line in a session JSONL file.
|
||||
type jsonlLine struct {
|
||||
Type string `json:"type"`
|
||||
// session header
|
||||
ID string `json:"id"`
|
||||
Created string `json:"created"`
|
||||
// message
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
// session_info
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// BuildSessionList reads all *.jsonl files in sessionsDir and assembles summaries.
|
||||
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
|
||||
entries, err := os.ReadDir(sessionsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pinned, _ := database.GetPinnedSessions()
|
||||
pinnedSet := map[string]struct{}{}
|
||||
for _, p := range pinned {
|
||||
pinnedSet[p] = struct{}{}
|
||||
}
|
||||
|
||||
var summaries []models.SessionSummary
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(sessionsDir, e.Name())
|
||||
sum, err := readSessionSummary(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, sum.Pinned = pinnedSet[path]
|
||||
sum.Path = path
|
||||
summaries = append(summaries, sum)
|
||||
}
|
||||
|
||||
// Sort: pinned first, then by modified desc
|
||||
sort.SliceStable(summaries, func(i, j int) bool {
|
||||
pi, pj := summaries[i].Pinned, summaries[j].Pinned
|
||||
if pi != pj {
|
||||
return pi
|
||||
}
|
||||
return summaries[i].Modified > summaries[j].Modified
|
||||
})
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func readSessionSummary(path string) (models.SessionSummary, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return models.SessionSummary{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, _ := f.Stat()
|
||||
modified := ""
|
||||
if info != nil {
|
||||
modified = info.ModTime().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
var sum models.SessionSummary
|
||||
sum.Modified = modified
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 2*1024*1024), 2*1024*1024)
|
||||
msgCount := 0
|
||||
for scanner.Scan() {
|
||||
var line jsonlLine
|
||||
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
|
||||
continue
|
||||
}
|
||||
switch line.Type {
|
||||
case "session":
|
||||
sum.Created = line.Created
|
||||
case "session_info":
|
||||
if line.Name != "" {
|
||||
sum.Name = line.Name
|
||||
}
|
||||
case "message":
|
||||
msgCount++
|
||||
if sum.FirstMessage == "" && line.Role == "user" {
|
||||
sum.FirstMessage = extractTextContent(line.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
sum.MessageCount = msgCount
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
func extractTextContent(content any) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return truncate(v, 120)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
if m["type"] == "text" {
|
||||
if t, ok := m["text"].(string); ok {
|
||||
return truncate(t, 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len([]rune(s)) <= n {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:n]) + "…"
|
||||
}
|
||||
|
||||
// ReadSessionMessages reads all lines from a JSONL session file.
|
||||
func ReadSessionMessages(path string) ([]json.RawMessage, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []json.RawMessage
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
raw := append([]byte(nil), scanner.Bytes()...)
|
||||
lines = append(lines, raw)
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// AppendSessionName appends a session_info rename record to the JSONL file.
|
||||
func AppendSessionName(path, name string) error {
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
record := map[string]string{"type": "session_info", "name": name}
|
||||
b, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(append(b, '\n'))
|
||||
return err
|
||||
}
|
||||
72
backend/internal/services/skills.go
Normal file
72
backend/internal/services/skills.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sproutclaw-web/internal/models"
|
||||
)
|
||||
|
||||
// ListSkills returns all skills (enabled and disabled) under agentDir.
|
||||
func ListSkills(agentDir string) []models.SkillInfo {
|
||||
var skills []models.SkillInfo
|
||||
skills = append(skills, scanSkillsDir(filepath.Join(agentDir, "skills"), true)...)
|
||||
skills = append(skills, scanSkillsDir(filepath.Join(agentDir, "skills-disabled"), false)...)
|
||||
return skills
|
||||
}
|
||||
|
||||
func scanSkillsDir(dir string, enabled bool) []models.SkillInfo {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var skills []models.SkillInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
skillPath := filepath.Join(dir, e.Name())
|
||||
name, desc := parseSkillMD(filepath.Join(skillPath, "SKILL.md"))
|
||||
if name == "" {
|
||||
name = e.Name()
|
||||
}
|
||||
skills = append(skills, models.SkillInfo{
|
||||
Name: name,
|
||||
Description: desc,
|
||||
Enabled: enabled,
|
||||
Path: skillPath,
|
||||
})
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
// ToggleSkill moves a skill between skills/ and skills-disabled/ directories.
|
||||
func ToggleSkill(agentDir, skillPath string, enable bool) error {
|
||||
skillsDir := filepath.Join(agentDir, "skills")
|
||||
disabledDir := filepath.Join(agentDir, "skills-disabled")
|
||||
|
||||
if !isUnderDir(skillPath, skillsDir) && !isUnderDir(skillPath, disabledDir) {
|
||||
return fmt.Errorf("skill path not in managed directories")
|
||||
}
|
||||
|
||||
name := filepath.Base(skillPath)
|
||||
var src, dst string
|
||||
if enable {
|
||||
src = filepath.Join(disabledDir, name)
|
||||
dst = filepath.Join(skillsDir, name)
|
||||
} else {
|
||||
src = filepath.Join(skillsDir, name)
|
||||
dst = filepath.Join(disabledDir, name)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
|
||||
func isUnderDir(path, dir string) bool {
|
||||
return strings.HasPrefix(filepath.Clean(path), filepath.Clean(dir))
|
||||
}
|
||||
57
backend/internal/services/slash_dispatch.go
Normal file
57
backend/internal/services/slash_dispatch.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// supportedWebUICommands is the whitelist of commands the WebUI handles.
|
||||
var supportedWebUICommands = map[string]bool{
|
||||
"compact": true,
|
||||
"new": true,
|
||||
"reload": true,
|
||||
"clone": true,
|
||||
"name": true,
|
||||
"model": true,
|
||||
"session": true,
|
||||
"export": true,
|
||||
"copy": true,
|
||||
}
|
||||
|
||||
// SlashDispatchResult holds the parsed result of a slash command attempt.
|
||||
type SlashDispatchResult struct {
|
||||
Handled bool
|
||||
Command string
|
||||
Args string
|
||||
// For unsupported commands, return a friendly message
|
||||
Message string
|
||||
}
|
||||
|
||||
// TrySlashDispatch checks if the message is a slash command the WebUI supports.
|
||||
func TrySlashDispatch(message string) SlashDispatchResult {
|
||||
if !strings.HasPrefix(message, "/") {
|
||||
return SlashDispatchResult{}
|
||||
}
|
||||
// strip leading slash and split
|
||||
trimmed := strings.TrimPrefix(message, "/")
|
||||
parts := strings.SplitN(trimmed, " ", 2)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
args := ""
|
||||
if len(parts) > 1 {
|
||||
args = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
if !supportedWebUICommands[cmd] {
|
||||
return SlashDispatchResult{
|
||||
Handled: true,
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Message: "该命令在 WebUI 模式下暂不支持,请在终端中使用",
|
||||
}
|
||||
}
|
||||
|
||||
return SlashDispatchResult{
|
||||
Handled: true,
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
17
backend/internal/services/system_prompt.go
Normal file
17
backend/internal/services/system_prompt.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package services
|
||||
|
||||
import "os"
|
||||
|
||||
// ReadSystemPrompt reads AGENTS.md content.
|
||||
func ReadSystemPrompt(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// WriteSystemPrompt writes AGENTS.md content.
|
||||
func WriteSystemPrompt(path, content string) error {
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
Reference in New Issue
Block a user