first commit

This commit is contained in:
2026-06-14 20:31:10 +08:00
parent c33b143176
commit 1ed3f576fa
51 changed files with 3362 additions and 810 deletions

View File

@@ -2,27 +2,43 @@ package services
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sproutclaw-web/internal/models"
)
// ListExtensions reads the extensions directory and returns metadata.
var npmSpecRe = regexp.MustCompile(`^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$`)
// ListExtensions returns local extensions (enabled and disabled) plus npm packages
// declared in settings.json. Local ones live in extensions/ and extensions-disabled/.
func ListExtensions(agentDir string) []models.ExtensionInfo {
extDir := filepath.Join(agentDir, "extensions")
entries, err := os.ReadDir(extDir)
var exts []models.ExtensionInfo
exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions"), true)...)
exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions-disabled"), false)...)
exts = append(exts, listNpmExtensions(agentDir)...)
return exts
}
func scanExtensionsDir(dir string, enabled bool) []models.ExtensionInfo {
entries, err := os.ReadDir(dir)
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())
ext := buildExtensionInfo(filepath.Join(dir, e.Name()), e.Name())
// Anything physically under extensions/ or extensions-disabled/ is a
// local extension and can be toggled, regardless of its package.json name.
ext.Category = "local"
ext.Toggleable = true
ext.Enabled = enabled
exts = append(exts, ext)
}
return exts
@@ -30,13 +46,11 @@ func ListExtensions(agentDir string) []models.ExtensionInfo {
func buildExtensionInfo(path, id string) models.ExtensionInfo {
info := models.ExtensionInfo{
ID: id,
Name: id,
Enabled: true, // assume enabled unless configured otherwise
Source: "local",
Name: id,
Path: path,
}
// try package.json for version/name
// try package.json for name/version
pkgPath := filepath.Join(path, "package.json")
if data, err := os.ReadFile(pkgPath); err == nil {
var pkg struct {
@@ -48,58 +62,149 @@ func buildExtensionInfo(path, id string) models.ExtensionInfo {
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 listNpmExtensions(agentDir string) []models.ExtensionInfo {
sources := readNpmPackageSources(filepath.Join(agentDir, "settings.json"))
if len(sources) == 0 {
return nil
}
seen := make(map[string]bool, len(sources))
var exts []models.ExtensionInfo
for _, source := range sources {
name, ok := npmPackageName(source)
if !ok || seen[name] {
continue
}
seen[name] = true
exts = append(exts, buildNpmExtensionInfo(agentDir, name, source))
}
return exts
}
func updateSettingsExtensions(settingsPath, id string, enable bool) error {
func readNpmPackageSources(settingsPath string) []string {
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 nil
}
var settings struct {
Packages []json.RawMessage `json:"packages"`
Extensions []string `json:"extensions"`
}
if err := json.Unmarshal(data, &settings); err != nil {
return nil
}
var sources []string
for _, raw := range settings.Packages {
if source := parsePackageSource(raw); source != "" {
sources = append(sources, source)
}
}
for _, source := range settings.Extensions {
if strings.HasPrefix(source, "npm:") {
sources = append(sources, source)
}
}
return sources
}
func parsePackageSource(raw json.RawMessage) string {
var source string
if err := json.Unmarshal(raw, &source); err == nil {
return strings.TrimSpace(source)
}
var obj struct {
Source string `json:"source"`
}
if err := json.Unmarshal(raw, &obj); err == nil {
return strings.TrimSpace(obj.Source)
}
return ""
}
func npmPackageName(source string) (string, bool) {
if !strings.HasPrefix(source, "npm:") {
return "", false
}
spec := strings.TrimSpace(strings.TrimPrefix(source, "npm:"))
if spec == "" {
return "", false
}
name, _ := parseNpmSpec(spec)
if name == "" {
return "", false
}
return name, true
}
func parseNpmSpec(spec string) (name, version string) {
match := npmSpecRe.FindStringSubmatch(spec)
if match == nil {
return spec, ""
}
name = match[1]
version = match[2]
return name, version
}
func buildNpmExtensionInfo(agentDir, packageName, source string) models.ExtensionInfo {
modPath := filepath.Join(agentDir, "npm", "node_modules", packageName)
info := models.ExtensionInfo{
Name: packageName,
Path: modPath,
Source: source,
Category: "npm",
Enabled: true,
Toggleable: false,
}
pkgPath := filepath.Join(modPath, "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
}
}
return info
}
// ToggleExtension moves a local extension between extensions/ and
// extensions-disabled/ directories. The agent's loader only scans extensions/,
// so moving a directory out of it disables that extension. npm extensions live
// elsewhere and are not managed here.
func ToggleExtension(agentDir, extPath string, enable bool) error {
extDir := filepath.Join(agentDir, "extensions")
disabledDir := filepath.Join(agentDir, "extensions-disabled")
if !isUnderDir(extPath, extDir) && !isUnderDir(extPath, disabledDir) {
return fmt.Errorf("extension path not in managed directories")
}
name := filepath.Base(extPath)
var src, dst string
if enable {
src = filepath.Join(disabledDir, name)
dst = filepath.Join(extDir, name)
} else {
src = filepath.Join(extDir, name)
dst = filepath.Join(disabledDir, name)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(settingsPath, append(out, '\n'), 0o644)
return os.Rename(src, dst)
}

View File

@@ -0,0 +1,56 @@
package services
import (
"os"
"path/filepath"
"testing"
)
func TestListNpmExtensions(t *testing.T) {
agentDir := t.TempDir()
npmRoot := filepath.Join(agentDir, "npm", "node_modules", "pi-subagents")
if err := os.MkdirAll(npmRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(npmRoot, "package.json"), []byte(`{"name":"pi-subagents","version":"0.28.0"}`), 0o644); err != nil {
t.Fatal(err)
}
settings := `{
"packages": ["npm:pi-subagents", {"source": "npm:pi-mcp-adapter@2.10.0"}]
}`
if err := os.WriteFile(filepath.Join(agentDir, "settings.json"), []byte(settings), 0o644); err != nil {
t.Fatal(err)
}
exts := listNpmExtensions(agentDir)
if len(exts) != 2 {
t.Fatalf("expected 2 npm extensions, got %d", len(exts))
}
found := map[string]modelsExtensionSnapshot{}
for _, ext := range exts {
found[ext.Name] = modelsExtensionSnapshot{
Version: ext.Version,
Category: ext.Category,
Toggleable: ext.Toggleable,
Source: ext.Source,
}
}
if found["pi-subagents"].Version != "0.28.0" {
t.Fatalf("pi-subagents version: %#v", found["pi-subagents"])
}
if found["pi-subagents"].Category != "npm" || found["pi-subagents"].Toggleable {
t.Fatalf("pi-subagents metadata: %#v", found["pi-subagents"])
}
if found["pi-mcp-adapter"].Source != "npm:pi-mcp-adapter@2.10.0" {
t.Fatalf("pi-mcp-adapter source: %#v", found["pi-mcp-adapter"])
}
}
type modelsExtensionSnapshot struct {
Version string
Category string
Toggleable bool
Source string
}

View File

@@ -7,68 +7,80 @@ import (
"sproutclaw-web/internal/models"
)
type mcpConfig struct {
MCPServers map[string]any `json:"mcpServers"`
MCPServersDisabled []string `json:"mcpServersDisabled,omitempty"`
ExcludeTools []string `json:"excludeTools,omitempty"`
}
// mcp.json is shared with pi-mcp-adapter, which only reads the top-level
// "mcpServers", "imports" and "settings" keys. To disable a server we move its
// full definition into the adapter-ignored "mcpServersDisabled" object, so the
// adapter never starts it; enabling moves it back. All operations work on the
// raw JSON map so unrecognized keys (settings, imports, ...) are preserved.
// ReadMCPServers loads mcp.json and returns the list of servers with their tools.
// Servers under "mcpServers" are reported enabled; those parked under
// "mcpServersDisabled" are reported disabled.
func ReadMCPServers(mcpConfigPath, mcpCachePath string) []models.MCPServer {
cfg := loadMCPConfig(mcpConfigPath)
raw := loadRawMCP(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 {
for _, t := range toStringSlice(raw["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],
})
appendServer := func(name string, enabled bool) {
srv := models.MCPServer{Name: name, Configured: true, Enabled: enabled}
for _, t := range cache[name] {
toolEnabled := !(excludeSet[name+"/"+t] || excludeSet[t])
srv.Tools = append(srv.Tools, models.MCPTool{Server: name, Name: t, Enabled: toolEnabled})
srv.ToolCount++
if toolEnabled {
srv.EnabledToolCount++
}
}
servers = append(servers, srv)
}
for name := range asMap(raw["mcpServers"]) {
appendServer(name, true)
}
for name := range asMap(raw["mcpServersDisabled"]) {
appendServer(name, false)
}
return servers
}
// ToggleMCPServer enables or disables a server.
// ToggleMCPServer enables/disables a server by moving its full definition between
// "mcpServers" (adapter reads it -> server can start) and "mcpServersDisabled"
// (adapter ignores it -> server never starts).
func ToggleMCPServer(mcpConfigPath, serverName string, enable bool) error {
cfg := loadMCPConfig(mcpConfigPath)
set := map[string]bool{}
for _, s := range cfg.MCPServersDisabled {
set[s] = true
}
raw := loadRawMCP(mcpConfigPath)
servers := asMap(raw["mcpServers"])
disabled := asMap(raw["mcpServersDisabled"])
if enable {
delete(set, serverName)
if def, ok := disabled[serverName]; ok {
servers[serverName] = def
delete(disabled, serverName)
}
} else {
set[serverName] = true
if def, ok := servers[serverName]; ok {
disabled[serverName] = def
delete(servers, serverName)
}
}
cfg.MCPServersDisabled = keys(set)
return saveMCPConfig(mcpConfigPath, cfg)
raw["mcpServers"] = servers
setOrDeleteMap(raw, "mcpServersDisabled", disabled)
return saveRawMCP(mcpConfigPath, raw)
}
// ToggleMCPTool enables or disables a specific tool on a server.
// ToggleMCPTool enables or disables a specific tool via the top-level
// "excludeTools" list (server/tool keys).
func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) error {
cfg := loadMCPConfig(mcpConfigPath)
raw := loadRawMCP(mcpConfigPath)
key := serverName + "/" + toolName
set := map[string]bool{}
for _, t := range cfg.ExcludeTools {
for _, t := range toStringSlice(raw["excludeTools"]) {
set[t] = true
}
if enable {
@@ -76,31 +88,68 @@ func ToggleMCPTool(mcpConfigPath, serverName, toolName string, enable bool) erro
} else {
set[key] = true
}
cfg.ExcludeTools = keys(set)
return saveMCPConfig(mcpConfigPath, cfg)
list := make([]any, 0, len(set))
for k := range set {
list = append(list, k)
}
if len(list) > 0 {
raw["excludeTools"] = list
} else {
delete(raw, "excludeTools")
}
return saveRawMCP(mcpConfigPath, raw)
}
func loadMCPConfig(path string) mcpConfig {
func loadRawMCP(path string) map[string]any {
data, err := os.ReadFile(path)
if err != nil {
return mcpConfig{MCPServers: map[string]any{}}
return map[string]any{}
}
var cfg mcpConfig
_ = json.Unmarshal(data, &cfg)
if cfg.MCPServers == nil {
cfg.MCPServers = map[string]any{}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil || raw == nil {
return map[string]any{}
}
return cfg
return raw
}
func saveMCPConfig(path string, cfg mcpConfig) error {
data, err := json.MarshalIndent(cfg, "", " ")
func saveRawMCP(path string, raw map[string]any) error {
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(data, '\n'), 0o644)
}
func asMap(v any) map[string]any {
if m, ok := v.(map[string]any); ok {
return m
}
return map[string]any{}
}
func setOrDeleteMap(raw map[string]any, key string, m map[string]any) {
if len(m) > 0 {
raw[key] = m
} else {
delete(raw, key)
}
}
func toStringSlice(v any) []string {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
func loadMCPCache(path string) map[string][]string {
data, err := os.ReadFile(path)
if err != nil {
@@ -123,11 +172,3 @@ func loadMCPCache(path string) map[string][]string {
}
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
}

View File

@@ -1,10 +1,10 @@
package services
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
@@ -13,159 +13,308 @@ import (
"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"`
// sessionLine is one parsed line of a session JSONL file.
type sessionLine struct {
Type string `json:"type"`
ID string `json:"id"`
Timestamp string `json:"timestamp"`
Name string `json:"name"`
Message json.RawMessage `json:"message"`
}
// 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
}
type sessionMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
pinned, _ := database.GetPinnedSessions()
pinnedSet := map[string]struct{}{}
for _, p := range pinned {
pinnedSet[p] = struct{}{}
}
var (
reHex = regexp.MustCompile(`^[0-9a-fA-F]{8,}$`)
reDigits = regexp.MustCompile(`^[0-9]{10,}$`)
reWS = regexp.MustCompile(`\s+`)
reFirstSentence = regexp.MustCompile(`^(.+?[。!?.!?])(\s|$)`)
)
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)
// listSessionFiles recursively collects every *.jsonl under sessionsDir.
// pi stores sessions in cwd-encoded subdirectories, e.g.
// sessions/--D--SmyProjects-AI-sproutclaw--/<id>.jsonl
func listSessionFiles(sessionsDir string) []string {
var files []string
_ = filepath.WalkDir(sessionsDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
continue
return nil
}
_, 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
if !d.IsDir() && strings.HasSuffix(d.Name(), ".jsonl") {
files = append(files, path)
}
return summaries[i].Modified > summaries[j].Modified
return nil
})
return summaries, nil
// newest first by path (ids are time-sortable); final sort happens later
sort.Sort(sort.Reverse(sort.StringSlice(files)))
return files
}
func readSessionSummary(path string) (models.SessionSummary, error) {
f, err := os.Open(path)
if err != nil {
return models.SessionSummary{}, err
func isMachineSessionLabel(text, headerID string) bool {
t := strings.TrimSpace(text)
if t == "" {
return true
}
defer f.Close()
info, _ := f.Stat()
modified := ""
if info != nil {
modified = info.ModTime().UTC().Format(time.RFC3339)
if headerID != "" && t == headerID {
return true
}
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)
}
}
if reHex.MatchString(t) || reDigits.MatchString(t) {
return true
}
sum.MessageCount = msgCount
return sum, nil
return false
}
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)
}
func titleFromFirstUserMessage(text string) string {
const maxChars = 56
cleaned := strings.TrimSpace(reWS.ReplaceAllString(text, " "))
if cleaned == "" {
return ""
}
candidate := cleaned
if m := reFirstSentence.FindStringSubmatch(cleaned); m != nil && m[1] != "" {
candidate = strings.TrimSpace(m[1])
}
runes := []rune(candidate)
if len(runes) > maxChars {
candidate = strings.TrimRight(string(runes[:maxChars]), " ") + "…"
}
return candidate
}
func extractPreview(msg sessionMessage) string {
if len(msg.Content) == 0 {
return ""
}
// content may be a string
var s string
if json.Unmarshal(msg.Content, &s) == nil {
return truncate(s, 200)
}
// or an array of blocks
var blocks []map[string]any
if json.Unmarshal(msg.Content, &blocks) == nil {
var sb strings.Builder
imageCount := 0
for _, b := range blocks {
switch b["type"] {
case "text":
if t, ok := b["text"].(string); ok {
sb.WriteString(t)
}
case "image":
imageCount++
}
}
if txt := sb.String(); txt != "" {
return truncate(txt, 200)
}
if imageCount > 0 {
return "[" + itoa(imageCount) + " 张图片]"
}
}
return ""
}
func truncate(s string, n int) string {
if len([]rune(s)) <= n {
return s
// readSessionSummary parses one session file into a summary (nil if not a session).
func readSessionSummary(path string) (models.SessionSummary, bool) {
data, err := os.ReadFile(path)
if err != nil {
return models.SessionSummary{}, false
}
runes := []rune(s)
return string(runes[:n]) + "…"
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) == 0 {
return models.SessionSummary{}, false
}
var header sessionLine
if json.Unmarshal([]byte(lines[0]), &header) != nil || header.Type != "session" {
return models.SessionSummary{}, false
}
info, _ := os.Stat(path)
modified := ""
if info != nil {
modified = info.ModTime().UTC().Format(time.RFC3339)
}
nameFromInfo := ""
messageCount := 0
firstMessage := ""
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var entry sessionLine
if json.Unmarshal([]byte(line), &entry) != nil {
continue
}
switch entry.Type {
case "session_info":
if entry.Name != "" {
n := strings.TrimSpace(entry.Name)
if n != "" && !isMachineSessionLabel(n, header.ID) {
nameFromInfo = n
}
}
case "message":
messageCount++
if firstMessage == "" && len(entry.Message) > 0 {
var m sessionMessage
if json.Unmarshal(entry.Message, &m) == nil && m.Role == "user" {
firstMessage = extractPreview(m)
}
}
}
}
name := nameFromInfo
if name == "" || isMachineSessionLabel(name, header.ID) {
name = titleFromFirstUserMessage(firstMessage)
}
preview := firstMessage
if preview == "" {
preview = "(空)"
}
return models.SessionSummary{
Path: path,
Name: name,
Created: header.Timestamp,
Modified: modified,
MessageCount: messageCount,
FirstMessage: preview,
}, true
}
// ReadSessionMessages reads all lines from a JSONL session file.
// BuildSessionList recursively reads all sessions and assembles the response.
func BuildSessionList(sessionsDir string, database *db.DB) ([]models.SessionSummary, error) {
if _, err := os.Stat(sessionsDir); err != nil {
return nil, nil
}
pinned, _ := database.GetPinnedSessions()
pinnedOrder := map[string]int{}
for i, p := range pinned {
pinnedOrder[filepath.Clean(p)] = i
}
var summaries []models.SessionSummary
for _, f := range listSessionFiles(sessionsDir) {
sum, ok := readSessionSummary(f)
if !ok {
continue
}
_, sum.Pinned = pinnedOrder[filepath.Clean(f)]
summaries = append(summaries, sum)
}
sort.SliceStable(summaries, func(i, j int) bool {
ci, iPinned := pinnedOrder[filepath.Clean(summaries[i].Path)]
cj, jPinned := pinnedOrder[filepath.Clean(summaries[j].Path)]
if iPinned && jPinned {
return ci < cj
}
if iPinned != jPinned {
return iPinned
}
return sessionSortKey(summaries[i]) > sessionSortKey(summaries[j])
})
return summaries, nil
}
func sessionSortKey(s models.SessionSummary) string {
if s.Modified != "" {
return s.Modified
}
return s.Created
}
// ReadSessionSummaryByPath returns a single session's summary (exported).
func ReadSessionSummaryByPath(path string) (models.SessionSummary, error) {
sum, ok := readSessionSummary(path)
if !ok {
return models.SessionSummary{Path: path}, nil
}
return sum, nil
}
// ReadSessionMessages returns the `message` payload of each message line.
func ReadSessionMessages(path string) ([]json.RawMessage, error) {
f, err := os.Open(path)
data, err := os.ReadFile(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)
var out []json.RawMessage
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
var entry sessionLine
if json.Unmarshal([]byte(line), &entry) != nil {
continue
}
if entry.Type == "message" && len(entry.Message) > 0 {
out = append(out, entry.Message)
}
}
return lines, nil
return out, nil
}
// AppendSessionName appends a session_info rename record to the JSONL file.
func AppendSessionName(path, name string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return os.ErrInvalid
}
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}
record := map[string]any{
"type": "session_info",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"name": trimmed,
}
b, err := json.Marshal(record)
if err != nil {
return err
}
_, err = f.Write(append(b, '\n'))
_, err = f.Write(append([]byte("\n"), b...))
return err
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}

View File

@@ -36,6 +36,7 @@ func scanSkillsDir(dir string, enabled bool) []models.SkillInfo {
Name: name,
Description: desc,
Enabled: enabled,
Toggleable: true,
Path: skillPath,
})
}

View File

@@ -0,0 +1,140 @@
package services
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"sync"
"github.com/Kodecable/crosspty"
"github.com/gorilla/websocket"
)
type terminalWSMessage struct {
Type string `json:"type"`
Data string `json:"data,omitempty"`
Cols uint16 `json:"cols,omitempty"`
Rows uint16 `json:"rows,omitempty"`
}
// ServeWebTerminal attaches a PTY shell session to the websocket connection.
func ServeWebTerminal(conn *websocket.Conn, workDir string) error {
abs, err := filepath.Abs(workDir)
if err != nil {
return fmt.Errorf("解析目录失败: %w", err)
}
info, err := os.Stat(abs)
if err != nil {
return fmt.Errorf("目录不存在: %s", abs)
}
if !info.IsDir() {
return fmt.Errorf("不是有效目录: %s", abs)
}
ptmx, err := crosspty.Start(crosspty.CommandConfig{
Argv: defaultShellArgv(),
Dir: abs,
Env: os.Environ(),
EnvInject: map[string]string{
"TERM": "xterm-256color",
"COLORTERM": "truecolor",
},
Size: crosspty.TermSize{Rows: 24, Cols: 80},
})
if err != nil {
if err == crosspty.ErrConPTYNotSupported {
return fmt.Errorf("当前 Windows 版本不支持 ConPTY需 Windows 10 1809 或更高版本")
}
return fmt.Errorf("启动终端失败: %w", err)
}
defer ptmx.Close()
var writeMu sync.Mutex
writeJSON := func(v any) error {
writeMu.Lock()
defer writeMu.Unlock()
return conn.WriteJSON(v)
}
go func() {
buf := make([]byte, 4096)
for {
n, readErr := ptmx.Read(buf)
if n > 0 {
writeMu.Lock()
werr := conn.WriteMessage(websocket.BinaryMessage, buf[:n])
writeMu.Unlock()
if werr != nil {
return
}
}
if readErr != nil {
return
}
}
}()
for {
msgType, payload, readErr := conn.ReadMessage()
if readErr != nil {
if websocket.IsCloseError(readErr, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
return nil
}
if readErr == io.EOF {
return nil
}
return readErr
}
switch msgType {
case websocket.BinaryMessage:
if _, werr := ptmx.Write(payload); werr != nil {
return werr
}
case websocket.TextMessage:
var msg terminalWSMessage
if err := json.Unmarshal(payload, &msg); err != nil {
if _, werr := ptmx.Write(payload); werr != nil {
return werr
}
continue
}
switch msg.Type {
case "input":
if _, werr := ptmx.Write([]byte(msg.Data)); werr != nil {
return werr
}
case "resize":
if msg.Cols > 0 && msg.Rows > 0 {
_ = ptmx.Resize(crosspty.TermSize{Rows: msg.Rows, Cols: msg.Cols})
}
case "ping":
if err := writeJSON(map[string]string{"type": "pong"}); err != nil {
return err
}
default:
if msg.Data != "" {
if _, werr := ptmx.Write([]byte(msg.Data)); werr != nil {
return werr
}
}
}
}
}
}
func defaultShellArgv() []string {
if runtime.GOOS == "windows" {
if comspec := os.Getenv("COMSPEC"); comspec != "" {
return []string{comspec}
}
return []string{"cmd.exe"}
}
if shell := os.Getenv("SHELL"); shell != "" {
return []string{shell, "-l"}
}
return []string{"/bin/bash", "-l"}
}