Compare commits
10 Commits
8619a7f35c
...
24d34f695f
| Author | SHA1 | Date | |
|---|---|---|---|
| 24d34f695f | |||
| c5faf2beea | |||
| ff316c7e91 | |||
| 9597db7fd8 | |||
| 29f1e172b4 | |||
| 8a2b5a0d5d | |||
| 5d9def5381 | |||
| 5e38284bfd | |||
| b80efea64a | |||
| 9fec5df6f7 |
@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@ -8,15 +9,26 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/orca/orca/internal/config"
|
"github.com/orca/orca/internal/config"
|
||||||
"github.com/orca/orca/internal/tui"
|
"github.com/orca/orca/internal/tui"
|
||||||
|
"github.com/orca/orca/internal/websocket"
|
||||||
"github.com/orca/orca/pkg/kernel"
|
"github.com/orca/orca/pkg/kernel"
|
||||||
|
"github.com/orca/orca/pkg/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
webMode := flag.Bool("web", false, "Run in web mode on port 8081")
|
||||||
|
memoryCmd := flag.Bool("memory", false, "Memory management commands (list, stats, clean)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
cfg, err := config.LoadConfig()
|
cfg, err := config.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *memoryCmd {
|
||||||
|
handleMemoryCommand(flag.Args())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
k := kernel.NewWithConfig(cfg)
|
k := kernel.NewWithConfig(cfg)
|
||||||
if err := k.Start(); err != nil {
|
if err := k.Start(); err != nil {
|
||||||
log.Fatalf("Failed to start kernel: %v", err)
|
log.Fatalf("Failed to start kernel: %v", err)
|
||||||
@ -26,15 +38,72 @@ func main() {
|
|||||||
log.Printf("Warning: failed to load skills: %v", err)
|
log.Printf("Warning: failed to load skills: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := tui.NewModel(k)
|
if *webMode {
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
server := websocket.NewServer(k, 8081)
|
||||||
|
fmt.Println("Starting web server on http://localhost:8081")
|
||||||
|
if err := server.Start(); err != nil {
|
||||||
|
log.Fatalf("Failed to start web server: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m := tui.NewModel(k)
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
|
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := k.Stop(); err != nil {
|
if err := k.Stop(); err != nil {
|
||||||
log.Printf("Warning: error stopping kernel: %v", err)
|
log.Printf("Warning: error stopping kernel: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleMemoryCommand(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Println("Usage: orca -memory <command>")
|
||||||
|
fmt.Println("Commands:")
|
||||||
|
fmt.Println(" list List all memories")
|
||||||
|
fmt.Println(" stats Show memory statistics")
|
||||||
|
fmt.Println(" clean Clean archived memories")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mm, err := session.NewMemoryManager(session.MemoryConfig{
|
||||||
|
DBPath: cfg.Session.StorageDir + "/memory.db",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create memory manager: %v", err)
|
||||||
|
}
|
||||||
|
defer mm.Close()
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
memories, err := mm.GetLongTermMemory("")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to list memories: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Total memories: %d\n", len(memories))
|
||||||
|
for _, m := range memories {
|
||||||
|
fmt.Printf(" [%d] %s (weight=%.2f)\n", m.ID, m.Content, m.Weight)
|
||||||
|
}
|
||||||
|
case "stats":
|
||||||
|
size, hits, misses := mm.CacheStats()
|
||||||
|
fmt.Printf("Cache size: %d\n", size)
|
||||||
|
fmt.Printf("Cache hits: %d\n", hits)
|
||||||
|
fmt.Printf("Cache misses: %d\n", misses)
|
||||||
|
case "clean":
|
||||||
|
count, err := mm.ArchiveLowWeightMemories(0.3)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to clean memories: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Archived %d memories\n", count)
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown command: %s\n", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
34
go.mod
34
go.mod
@ -3,41 +3,41 @@ module github.com/orca/orca
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
modernc.org/sqlite v1.50.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
|
||||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/glamour v1.0.0 // indirect
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.7.13 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
|
||||||
golang.org/x/net v0.38.0 // indirect
|
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
|
||||||
golang.org/x/term v0.36.0 // indirect
|
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
modernc.org/libc v1.72.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
97
go.sum
97
go.sum
@ -1,37 +1,25 @@
|
|||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
|
||||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
|
|
||||||
github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
|
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
@ -40,52 +28,79 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
|||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
|
||||||
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
|
|
||||||
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
|
||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
|
||||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||||
|
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||||
|
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||||
|
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||||
|
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||||
|
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@ -23,12 +23,16 @@ const (
|
|||||||
|
|
||||||
// Config 保存 Orca 框架的所有配置信息。
|
// Config 保存 Orca 框架的所有配置信息。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Provider string `toml:"provider"`
|
Provider string `toml:"provider"`
|
||||||
Ollama OllamaConfig `toml:"ollama"`
|
Ollama OllamaConfig `toml:"ollama"`
|
||||||
DeepSeek DeepSeekConfig `toml:"deepseek"`
|
DeepSeek DeepSeekConfig `toml:"deepseek"`
|
||||||
Sandbox SandboxConfig `toml:"sandbox"`
|
Sandbox SandboxConfig `toml:"sandbox"`
|
||||||
Session SessionConfig `toml:"session"`
|
Session SessionConfig `toml:"session"`
|
||||||
Agent AgentConfig `toml:"agent"`
|
Agent AgentConfig `toml:"agent"`
|
||||||
|
Embedding EmbeddingConfig `toml:"embedding"`
|
||||||
|
Memory MemoryConfig `toml:"memory"`
|
||||||
|
MemoryAgent MemoryAgentConfig `toml:"memory_agent"`
|
||||||
|
SiliconFlow SiliconConfig `toml:"siliconflow"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OllamaConfig 保存 Ollama LLM 后端的设置。
|
// OllamaConfig 保存 Ollama LLM 后端的设置。
|
||||||
@ -59,6 +63,68 @@ type SessionConfig struct {
|
|||||||
MaxHistory int `toml:"max_history"`
|
MaxHistory int `toml:"max_history"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EmbeddingConfig struct {
|
||||||
|
Provider string `toml:"provider"`
|
||||||
|
Model string `toml:"model"`
|
||||||
|
Dim int `toml:"dimensions"`
|
||||||
|
MaxCtx int `toml:"max_context"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
MaxHistory int `toml:"max_history"`
|
||||||
|
ShortTerm ShortTermConfig `toml:"short_term"`
|
||||||
|
LongTerm LongTermConfig `toml:"long_term"`
|
||||||
|
Inject InjectConfig `toml:"inject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShortTermConfig struct {
|
||||||
|
MaxItems int `toml:"max_items"`
|
||||||
|
CompressionThreshold int `toml:"compression_threshold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LongTermConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
VectorIndex bool `toml:"vector_index"`
|
||||||
|
MaxReturn int `toml:"max_return"`
|
||||||
|
ArchiveThreshold float64 `toml:"archive_threshold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InjectConfig struct {
|
||||||
|
FirstRoundEmpty bool `toml:"first_round_empty"`
|
||||||
|
ShortTermCount int `toml:"short_term_count"`
|
||||||
|
LongTermTrigger string `toml:"long_term_trigger"`
|
||||||
|
MinQueryLength int `toml:"min_query_length"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryAgentConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Provider string `toml:"provider"`
|
||||||
|
Model string `toml:"model"`
|
||||||
|
APIKey string `toml:"api_key"`
|
||||||
|
BaseURL string `toml:"base_url"`
|
||||||
|
Timeout time.Duration `toml:"timeout"`
|
||||||
|
Extract ExtractConfig `toml:"extract"`
|
||||||
|
Summarize SummarizeConfig `toml:"summarize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractConfig struct {
|
||||||
|
BatchSize int `toml:"batch_size"`
|
||||||
|
MaxFacts int `toml:"max_facts"`
|
||||||
|
MinConfidence float64 `toml:"min_confidence"`
|
||||||
|
AutoTag bool `toml:"auto_tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummarizeConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
TriggerTokens int `toml:"trigger_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiliconConfig struct {
|
||||||
|
APIKey string `toml:"api_key"`
|
||||||
|
BaseURL string `toml:"base_url"`
|
||||||
|
}
|
||||||
|
|
||||||
// AgentConfig 保存 Agent 身份和行为的配置。
|
// AgentConfig 保存 Agent 身份和行为的配置。
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
// Role 是 Agent 的角色标识,如 "assistant", "coder", "reviewer" 等。
|
// Role 是 Agent 的角色标识,如 "assistant", "coder", "reviewer" 等。
|
||||||
@ -105,9 +171,71 @@ func DefaultConfig() *Config {
|
|||||||
SystemPrompt: "",
|
SystemPrompt: "",
|
||||||
PromptFile: "",
|
PromptFile: "",
|
||||||
},
|
},
|
||||||
|
Embedding: EmbeddingConfig{
|
||||||
|
Provider: "siliconflow",
|
||||||
|
Model: "Pro/BAAI/bge-m3",
|
||||||
|
Dim: 1024,
|
||||||
|
MaxCtx: 8192,
|
||||||
|
},
|
||||||
|
Memory: MemoryConfig{
|
||||||
|
Enabled: true,
|
||||||
|
MaxHistory: 100,
|
||||||
|
ShortTerm: ShortTermConfig{
|
||||||
|
MaxItems: 10,
|
||||||
|
CompressionThreshold: 200,
|
||||||
|
},
|
||||||
|
LongTerm: LongTermConfig{
|
||||||
|
Enabled: true,
|
||||||
|
VectorIndex: true,
|
||||||
|
MaxReturn: 2,
|
||||||
|
ArchiveThreshold: 0.3,
|
||||||
|
},
|
||||||
|
Inject: InjectConfig{
|
||||||
|
FirstRoundEmpty: true,
|
||||||
|
ShortTermCount: 3,
|
||||||
|
LongTermTrigger: "technical",
|
||||||
|
MinQueryLength: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MemoryAgent: MemoryAgentConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Provider: "deepseek",
|
||||||
|
Model: "deepseek-v4-flash",
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
Extract: ExtractConfig{
|
||||||
|
BatchSize: 5,
|
||||||
|
MaxFacts: 10,
|
||||||
|
MinConfidence: 0.6,
|
||||||
|
AutoTag: true,
|
||||||
|
},
|
||||||
|
Summarize: SummarizeConfig{
|
||||||
|
Enabled: true,
|
||||||
|
TriggerTokens: 4000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SiliconFlow: SiliconConfig{
|
||||||
|
APIKey: "",
|
||||||
|
BaseURL: "https://api.siliconflow.cn/v1",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) expandPaths() {
|
||||||
|
c.Session.StorageDir = expandHomeDir(c.Session.StorageDir)
|
||||||
|
c.Sandbox.WorkingDir = expandHomeDir(c.Sandbox.WorkingDir)
|
||||||
|
c.Agent.PromptFile = expandHomeDir(c.Agent.PromptFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandHomeDir(path string) string {
|
||||||
|
if len(path) > 0 && path[0] == '~' {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
return home + path[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
// LoadConfigFromFile 从指定路径加载 TOML 配置文件。
|
// LoadConfigFromFile 从指定路径加载 TOML 配置文件。
|
||||||
// 如果文件不存在,返回默认配置。
|
// 如果文件不存在,返回默认配置。
|
||||||
func LoadConfigFromFile(path string) (*Config, error) {
|
func LoadConfigFromFile(path string) (*Config, error) {
|
||||||
@ -121,6 +249,8 @@ func LoadConfigFromFile(path string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("config: failed to load %q: %w", path, err)
|
return nil, fmt.Errorf("config: failed to load %q: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.expandPaths()
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -212,17 +212,18 @@ func (m *Model) updateLayout() {
|
|||||||
|
|
||||||
rightWidth := 42
|
rightWidth := 42
|
||||||
leftWidth := m.width - rightWidth - 3
|
leftWidth := m.width - rightWidth - 3
|
||||||
headerHeight := 3
|
|
||||||
mainHeight := m.height - headerHeight - 1
|
headerHeight := 2
|
||||||
inputHeight := 5
|
mainHeight := m.height - headerHeight
|
||||||
chatHeight := mainHeight - inputHeight
|
if mainHeight < 10 {
|
||||||
if chatHeight < 10 {
|
mainHeight = 10
|
||||||
chatHeight = 10
|
}
|
||||||
|
chatHeight := mainHeight - 9
|
||||||
|
if chatHeight < 5 {
|
||||||
|
chatHeight = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
m.textarea.SetWidth(leftWidth)
|
m.textarea.SetWidth(leftWidth)
|
||||||
m.viewport.Width = leftWidth
|
|
||||||
m.viewport.Height = chatHeight
|
|
||||||
|
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
m.viewport = viewport.New(leftWidth, chatHeight)
|
m.viewport = viewport.New(leftWidth, chatHeight)
|
||||||
@ -265,33 +266,18 @@ func (m Model) View() string {
|
|||||||
return "Initializing..."
|
return "Initializing..."
|
||||||
}
|
}
|
||||||
|
|
||||||
rightWidth := 42
|
rightWidth := 40
|
||||||
leftWidth := m.width - rightWidth - 3
|
leftWidth := m.width - rightWidth - 2
|
||||||
|
|
||||||
header := m.renderHeader()
|
header := m.renderHeader()
|
||||||
headerHeight := lipgloss.Height(header)
|
|
||||||
mainHeight := m.height - headerHeight
|
|
||||||
inputHeight := 5
|
|
||||||
chatHeight := mainHeight - inputHeight
|
|
||||||
if chatHeight < 10 {
|
|
||||||
chatHeight = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
chatBox := boxStyle.Width(leftWidth).Height(chatHeight).Render(m.viewport.View())
|
chatBox := boxStyle.Width(leftWidth).Height(m.height - 8).Render(m.viewport.View())
|
||||||
inputBox := boxStyle.Width(leftWidth).Render(m.textarea.View())
|
inputBox := boxStyle.Width(leftWidth).Render(m.textarea.View())
|
||||||
leftPanel := lipgloss.JoinVertical(lipgloss.Left, chatBox, inputBox)
|
leftPanel := lipgloss.JoinVertical(lipgloss.Left, chatBox, inputBox)
|
||||||
|
|
||||||
stats := m.renderStats()
|
stats := m.renderStats()
|
||||||
agents := m.renderAgents()
|
agents := m.renderAgents()
|
||||||
|
empty := boxStyle.Width(rightWidth - 2).Height(m.height - lipgloss.Height(stats) - lipgloss.Height(agents) - 5).Render("")
|
||||||
statsHeight := lipgloss.Height(stats)
|
|
||||||
agentsHeight := lipgloss.Height(agents)
|
|
||||||
remainingHeight := mainHeight - statsHeight - agentsHeight - 2
|
|
||||||
if remainingHeight < 3 {
|
|
||||||
remainingHeight = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
empty := boxStyle.Width(rightWidth - 4).Height(remainingHeight).Render("")
|
|
||||||
rightPanel := lipgloss.JoinVertical(lipgloss.Left, stats, agents, empty)
|
rightPanel := lipgloss.JoinVertical(lipgloss.Left, stats, agents, empty)
|
||||||
|
|
||||||
if m.loading {
|
if m.loading {
|
||||||
@ -299,19 +285,21 @@ func (m Model) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
|
body := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, header, body)
|
return header + "\n" + body
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderHeader() string {
|
func (m Model) renderHeader() string {
|
||||||
version := "v0.1.0"
|
version := "v0.1.0"
|
||||||
title := titleStyle.Render("orca.agent ") + mutedStyle.Render(version)
|
title := "orca.agent " + version
|
||||||
|
line := strings.Repeat("─", m.width-2)
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Width(m.width).
|
Width(m.width).
|
||||||
Padding(0, 1).
|
Padding(0, 1).
|
||||||
BorderBottom(true).
|
Bold(true).
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
Foreground(lipgloss.Color(colors.primary)).
|
||||||
BorderForeground(lipgloss.Color(colors.border)).
|
Render(title) + "\n" + lipgloss.NewStyle().
|
||||||
Render(title)
|
Foreground(lipgloss.Color(colors.border)).
|
||||||
|
Render(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderStats() string {
|
func (m Model) renderStats() string {
|
||||||
@ -319,16 +307,22 @@ func (m Model) renderStats() string {
|
|||||||
b.WriteString(titleStyle.Render("Statistics") + "\n\n")
|
b.WriteString(titleStyle.Render("Statistics") + "\n\n")
|
||||||
|
|
||||||
tools := 0
|
tools := 0
|
||||||
if tm := m.kernel.ToolManager(); tm != nil {
|
if m.kernel != nil {
|
||||||
tools = tm.Count()
|
if tm := m.kernel.ToolManager(); tm != nil {
|
||||||
|
tools = tm.Count()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
skills := 0
|
skills := 0
|
||||||
if sm := m.kernel.SkillManager(); sm != nil {
|
if m.kernel != nil {
|
||||||
skills = len(sm.ListSkills())
|
if sm := m.kernel.SkillManager(); sm != nil {
|
||||||
|
skills = len(sm.ListSkills())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
agents := 0
|
agents := 0
|
||||||
if as := m.kernel.ActorSystem(); as != nil {
|
if m.kernel != nil {
|
||||||
agents = as.AgentCount()
|
if as := m.kernel.ActorSystem(); as != nil {
|
||||||
|
agents = as.AgentCount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(statLabelStyle.Render("Tools: "))
|
b.WriteString(statLabelStyle.Render("Tools: "))
|
||||||
@ -345,15 +339,17 @@ func (m Model) renderAgents() string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(titleStyle.Render("Active Agents") + "\n\n")
|
b.WriteString(titleStyle.Render("Active Agents") + "\n\n")
|
||||||
|
|
||||||
if as := m.kernel.ActorSystem(); as != nil {
|
if m.kernel != nil {
|
||||||
for _, info := range as.AgentInfos() {
|
if as := m.kernel.ActorSystem(); as != nil {
|
||||||
status := "idle"
|
for _, info := range as.AgentInfos() {
|
||||||
style := idleAgentStyle
|
status := "idle"
|
||||||
if info.Status == actor.StatusProcessing || m.agentRuns[info.ID] {
|
style := idleAgentStyle
|
||||||
status = "running"
|
if info.Status == actor.StatusProcessing || m.agentRuns[info.ID] {
|
||||||
style = activeAgentStyle
|
status = "running"
|
||||||
|
style = activeAgentStyle
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("• %s: %s\n", info.ID, style.Render(status)))
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("• %s: %s\n", info.ID, style.Render(status)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEventWriter(t *testing.T) {
|
func TestEventWriter(t *testing.T) {
|
||||||
@ -74,3 +77,26 @@ func TestModelFormatMessage(t *testing.T) {
|
|||||||
t.Error("system message should not be empty")
|
t.Error("system message should not be empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestViewContainsHeader(t *testing.T) {
|
||||||
|
ta := textarea.New()
|
||||||
|
ta.ShowLineNumbers = false
|
||||||
|
ta.KeyMap.InsertNewline.SetEnabled(false)
|
||||||
|
|
||||||
|
m := Model{
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
textarea: ta,
|
||||||
|
messages: []ChatMessage{},
|
||||||
|
events: make(chan Event, 10),
|
||||||
|
agentRuns: make(map[string]bool),
|
||||||
|
}
|
||||||
|
m.updateLayout()
|
||||||
|
view := m.View()
|
||||||
|
if view == "Initializing..." {
|
||||||
|
t.Fatal("view should not be initializing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(view, "orca.agent") {
|
||||||
|
t.Errorf("view should contain 'orca.agent', got:\n%s", view[:min(200, len(view))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
469
internal/websocket/server.go
Normal file
469
internal/websocket/server.go
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/orca/orca/pkg/actor"
|
||||||
|
"github.com/orca/orca/pkg/bus"
|
||||||
|
"github.com/orca/orca/pkg/kernel"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
kernel *kernel.Kernel
|
||||||
|
port int
|
||||||
|
clients map[string]*Client
|
||||||
|
clientsMu sync.RWMutex
|
||||||
|
counter int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
ID string
|
||||||
|
Conn *websocket.Conn
|
||||||
|
Send chan []byte
|
||||||
|
Server *Server
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Agent string `json:"agent,omitempty"`
|
||||||
|
Stats Stats `json:"stats,omitempty"`
|
||||||
|
Agents []AgentInfo `json:"agents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stats struct {
|
||||||
|
Tools int `json:"tools"`
|
||||||
|
Skills int `json:"skills"`
|
||||||
|
Agents int `json:"agents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(k *kernel.Kernel, port int) *Server {
|
||||||
|
s := &Server{
|
||||||
|
kernel: k,
|
||||||
|
port: port,
|
||||||
|
clients: make(map[string]*Client),
|
||||||
|
}
|
||||||
|
|
||||||
|
if mb := k.Bus(); mb != nil {
|
||||||
|
mb.Subscribe("agent_events", func(msg bus.Message) {
|
||||||
|
s.broadcastAgentEvent(msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// WebSocket endpoint
|
||||||
|
mux.HandleFunc("/ws", s.handleWebSocket)
|
||||||
|
|
||||||
|
// API endpoints - must be registered before static files
|
||||||
|
mux.HandleFunc("/api/stats", s.handleStats)
|
||||||
|
mux.HandleFunc("/api/agents", s.handleAgents)
|
||||||
|
mux.HandleFunc("/api/sessions", s.handleSessions)
|
||||||
|
mux.HandleFunc("/api/sessions/", s.handleSessionMessages)
|
||||||
|
|
||||||
|
// Static files - serve React build
|
||||||
|
webDir := filepath.Join("web", "dist")
|
||||||
|
if _, err := os.Stat(webDir); err == nil {
|
||||||
|
fs := http.FileServer(http.Dir(webDir))
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Skip API paths
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/ws" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(webDir, r.URL.Path)
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if os.IsNotExist(err) || r.URL.Path == "/" {
|
||||||
|
http.ServeFile(w, r, filepath.Join(webDir, "index.html"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", s.port)
|
||||||
|
log.Printf("WebSocket server starting on http://localhost%s", addr)
|
||||||
|
return http.ListenAndServe(addr, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WebSocket upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.counter++
|
||||||
|
client := &Client{
|
||||||
|
ID: fmt.Sprintf("client-%d", s.counter),
|
||||||
|
Conn: conn,
|
||||||
|
Send: make(chan []byte, 256),
|
||||||
|
Server: s,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.clientsMu.Lock()
|
||||||
|
s.clients[client.ID] = client
|
||||||
|
s.clientsMu.Unlock()
|
||||||
|
|
||||||
|
// Send initial stats and agents
|
||||||
|
s.broadcastStats()
|
||||||
|
s.broadcastAgents()
|
||||||
|
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readPump() {
|
||||||
|
defer func() {
|
||||||
|
c.Server.removeClient(c)
|
||||||
|
c.Conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.Conn.SetReadLimit(512 * 1024)
|
||||||
|
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
c.Conn.SetPongHandler(func(string) error {
|
||||||
|
c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, message, err := c.Conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
|
log.Printf("WebSocket error: %v", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(message, &msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Type == "chat" {
|
||||||
|
go c.handleChat(msg.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleChat(userMessage string) {
|
||||||
|
writer := &wsWriter{client: c}
|
||||||
|
c.Server.kernel.SetStreamWriter(writer)
|
||||||
|
|
||||||
|
resp, err := c.Server.kernel.SendMessage("user", "llm", userMessage)
|
||||||
|
if err != nil {
|
||||||
|
c.sendJSON(Message{Type: "error", Content: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendJSON(Message{Type: "complete", Content: resp})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writePump() {
|
||||||
|
ticker := time.NewTicker(54 * time.Second)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
c.Conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-c.Send:
|
||||||
|
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if !ok {
|
||||||
|
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Conn.WriteMessage(websocket.TextMessage, message)
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendJSON(v interface{}) {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.Send <- data:
|
||||||
|
default:
|
||||||
|
// Channel full, drop message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) removeClient(c *Client) {
|
||||||
|
s.clientsMu.Lock()
|
||||||
|
delete(s.clients, c.ID)
|
||||||
|
s.clientsMu.Unlock()
|
||||||
|
close(c.Send)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) broadcastJSON(v interface{}) {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.clientsMu.RLock()
|
||||||
|
defer s.clientsMu.RUnlock()
|
||||||
|
|
||||||
|
for _, client := range s.clients {
|
||||||
|
select {
|
||||||
|
case client.Send <- data:
|
||||||
|
default:
|
||||||
|
// Channel full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) broadcastAgentEvent(msg bus.Message) {
|
||||||
|
content, ok := msg.Content.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventType, _ := content["event"].(string)
|
||||||
|
|
||||||
|
switch eventType {
|
||||||
|
case "token":
|
||||||
|
text, _ := content["text"].(string)
|
||||||
|
agent, _ := content["agent"].(string)
|
||||||
|
if text == "" || agent == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := Message{
|
||||||
|
Type: "agent_token",
|
||||||
|
Agent: agent,
|
||||||
|
Content: text,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
s.broadcast(data)
|
||||||
|
default:
|
||||||
|
event := Message{
|
||||||
|
Type: eventType,
|
||||||
|
Agent: content["agent"].(string),
|
||||||
|
Message: "",
|
||||||
|
}
|
||||||
|
if task, ok := content["task"].(string); ok {
|
||||||
|
event.Message = task
|
||||||
|
}
|
||||||
|
if result, ok := content["result"].(string); ok && result != "" {
|
||||||
|
event.Content = result
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.broadcast(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) broadcast(data []byte) {
|
||||||
|
s.clientsMu.RLock()
|
||||||
|
defer s.clientsMu.RUnlock()
|
||||||
|
|
||||||
|
for _, client := range s.clients {
|
||||||
|
select {
|
||||||
|
case client.Send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) broadcastStats() {
|
||||||
|
stats := Stats{}
|
||||||
|
if tm := s.kernel.ToolManager(); tm != nil {
|
||||||
|
stats.Tools = tm.Count()
|
||||||
|
}
|
||||||
|
if sm := s.kernel.SkillManager(); sm != nil {
|
||||||
|
stats.Skills = len(sm.ListSkills())
|
||||||
|
}
|
||||||
|
if as := s.kernel.ActorSystem(); as != nil {
|
||||||
|
stats.Agents = as.AgentCount()
|
||||||
|
}
|
||||||
|
s.broadcastJSON(Message{Type: "stats", Stats: stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) broadcastAgents() {
|
||||||
|
var agents []AgentInfo
|
||||||
|
if as := s.kernel.ActorSystem(); as != nil {
|
||||||
|
for _, info := range as.AgentInfos() {
|
||||||
|
status := "idle"
|
||||||
|
if info.Status == actor.StatusProcessing {
|
||||||
|
status = "running"
|
||||||
|
}
|
||||||
|
agents = append(agents, AgentInfo{ID: info.ID, Status: status})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.broadcastJSON(Message{Type: "agents", Agents: agents})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
stats := Stats{}
|
||||||
|
if tm := s.kernel.ToolManager(); tm != nil {
|
||||||
|
stats.Tools = tm.Count()
|
||||||
|
}
|
||||||
|
if sm := s.kernel.SkillManager(); sm != nil {
|
||||||
|
stats.Skills = len(sm.ListSkills())
|
||||||
|
}
|
||||||
|
if as := s.kernel.ActorSystem(); as != nil {
|
||||||
|
stats.Agents = as.AgentCount()
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var agents []AgentInfo
|
||||||
|
if as := s.kernel.ActorSystem(); as != nil {
|
||||||
|
for _, info := range as.AgentInfos() {
|
||||||
|
status := "idle"
|
||||||
|
if info.Status == actor.StatusProcessing {
|
||||||
|
status = "running"
|
||||||
|
}
|
||||||
|
agents = append(agents, AgentInfo{ID: info.ID, Status: status})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
sessionMgr := s.kernel.SessionManager()
|
||||||
|
if sessionMgr == nil {
|
||||||
|
json.NewEncoder(w).Encode([]SessionInfo{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionIDs, err := sessionMgr.ListSessions()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []SessionInfo
|
||||||
|
for _, id := range sessionIDs {
|
||||||
|
session, err := sessionMgr.GetSession(id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, SessionInfo{
|
||||||
|
ID: session.ID,
|
||||||
|
MessageCount: len(session.Messages),
|
||||||
|
CreatedAt: session.CreatedAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSessionMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/sessions/")
|
||||||
|
if path == "" || path == "/api/sessions" {
|
||||||
|
http.Error(w, "session ID required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr := s.kernel.SessionManager()
|
||||||
|
if sessionMgr == nil {
|
||||||
|
json.NewEncoder(w).Encode([]SessionMessage{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := sessionMgr.GetSession(path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []SessionMessage
|
||||||
|
for _, msg := range session.Messages {
|
||||||
|
result = append(result, SessionMessage{
|
||||||
|
Role: string(msg.Role),
|
||||||
|
Content: msg.Content,
|
||||||
|
Timestamp: msg.Timestamp.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsWriter implements kernel.StreamWriter
|
||||||
|
type wsWriter struct {
|
||||||
|
client *Client
|
||||||
|
mu sync.Mutex
|
||||||
|
buf strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *wsWriter) Write(p []byte) (n int, err error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
w.buf.Write(p)
|
||||||
|
text := w.buf.String()
|
||||||
|
w.buf.Reset()
|
||||||
|
|
||||||
|
msg := Message{Type: "token", Text: text}
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case w.client.Send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *wsWriter) Flush() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/orca/orca/pkg/bus"
|
"github.com/orca/orca/pkg/bus"
|
||||||
"github.com/orca/orca/pkg/llm"
|
"github.com/orca/orca/pkg/llm"
|
||||||
@ -23,16 +24,17 @@ import (
|
|||||||
// back to the LLM for final response generation.
|
// back to the LLM for final response generation.
|
||||||
type LLMAgent struct {
|
type LLMAgent struct {
|
||||||
*BaseAgent
|
*BaseAgent
|
||||||
llm llm.LLM
|
llm llm.LLM
|
||||||
sessionMgr *session.Manager
|
sessionMgr *session.Manager
|
||||||
sessionID string
|
sessionID string
|
||||||
toolManager *tool.Manager
|
toolManager *tool.Manager
|
||||||
skillManager *skill.Manager
|
skillManager *skill.Manager
|
||||||
toolWorker *ToolWorker
|
toolWorker *ToolWorker
|
||||||
windowSize int
|
windowSize int
|
||||||
streamWriter io.Writer
|
streamWriter io.Writer
|
||||||
systemPrompt string
|
systemPrompt string
|
||||||
subAgents map[string]string
|
subAgents map[string]string
|
||||||
|
memoryManager *session.MemoryManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLMAgentOption is a functional option for configuring the LLMAgent.
|
// LLMAgentOption is a functional option for configuring the LLMAgent.
|
||||||
@ -98,6 +100,12 @@ func WithSubAgents(agents map[string]string) LLMAgentOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithMemoryManager(mm *session.MemoryManager) LLMAgentOption {
|
||||||
|
return func(a *LLMAgent) {
|
||||||
|
a.memoryManager = mm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewLLMAgent creates a new LLMAgent with the given LLM backend and options.
|
// NewLLMAgent creates a new LLMAgent with the given LLM backend and options.
|
||||||
// The agent is started automatically upon creation.
|
// The agent is started automatically upon creation.
|
||||||
func NewLLMAgent(id string, backend llm.LLM, opts ...LLMAgentOption) *LLMAgent {
|
func NewLLMAgent(id string, backend llm.LLM, opts ...LLMAgentOption) *LLMAgent {
|
||||||
@ -149,6 +157,18 @@ func (a *LLMAgent) handleUserMessage(ctx context.Context, msg bus.Message) (bus.
|
|||||||
return bus.Message{}, fmt.Errorf("llm_agent: expected string content, got %T", msg.Content)
|
return bus.Message{}, fmt.Errorf("llm_agent: expected string content, got %T", msg.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理特殊命令
|
||||||
|
if content == "/context" || content == "/debug" {
|
||||||
|
a.LogContextDetails(content)
|
||||||
|
return bus.Message{
|
||||||
|
ID: msg.ID + "-response",
|
||||||
|
Type: bus.MsgTypeTaskResponse,
|
||||||
|
From: a.ID(),
|
||||||
|
To: msg.From,
|
||||||
|
Content: "[上下文详情已输出到日志]",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure session exists
|
// Ensure session exists
|
||||||
if a.sessionMgr != nil && a.sessionID != "" {
|
if a.sessionMgr != nil && a.sessionID != "" {
|
||||||
// Check if session exists; create if not
|
// Check if session exists; create if not
|
||||||
@ -162,7 +182,15 @@ func (a *LLMAgent) handleUserMessage(ctx context.Context, msg bus.Message) (bus.
|
|||||||
a.sessionMgr.AddMessage(a.sessionID, session.RoleUser, content, nil)
|
a.sessionMgr.AddMessage(a.sessionID, session.RoleUser, content, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
llmMessages := a.buildLLMMessages()
|
if a.memoryManager != nil && a.sessionID != "" {
|
||||||
|
a.memoryManager.SaveMessage(a.sessionID, session.SessionMessage{
|
||||||
|
Role: session.RoleUser,
|
||||||
|
Content: content,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
llmMessages := a.buildLLMMessages(content)
|
||||||
|
|
||||||
if a.skillManager != nil {
|
if a.skillManager != nil {
|
||||||
matchedSkills := a.skillManager.FindSkill(content)
|
matchedSkills := a.skillManager.FindSkill(content)
|
||||||
@ -187,6 +215,15 @@ func (a *LLMAgent) handleUserMessage(ctx context.Context, msg bus.Message) (bus.
|
|||||||
a.sessionMgr.AddMessage(a.sessionID, session.RoleAssistant, finalResponse, nil)
|
a.sessionMgr.AddMessage(a.sessionID, session.RoleAssistant, finalResponse, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.memoryManager != nil && a.sessionID != "" {
|
||||||
|
a.memoryManager.SaveMessage(a.sessionID, session.SessionMessage{
|
||||||
|
Role: session.RoleAssistant,
|
||||||
|
Content: finalResponse,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
go a.memoryManager.MaintainSessionMemory(a.sessionID, content, finalResponse)
|
||||||
|
}
|
||||||
|
|
||||||
return bus.Message{
|
return bus.Message{
|
||||||
ID: msg.ID + "-response",
|
ID: msg.ID + "-response",
|
||||||
Type: bus.MsgTypeTaskResponse,
|
Type: bus.MsgTypeTaskResponse,
|
||||||
@ -196,8 +233,25 @@ func (a *LLMAgent) handleUserMessage(ctx context.Context, msg bus.Message) (bus.
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *LLMAgent) buildLLMMessages() []llm.Message {
|
type contextStats struct {
|
||||||
|
systemPromptTokens int
|
||||||
|
toolPromptTokens int
|
||||||
|
memoryTokens int
|
||||||
|
historyTokens int
|
||||||
|
memoryShortTerm int
|
||||||
|
memoryLongTerm int
|
||||||
|
historyCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LLMAgent) buildLLMMessages(query string) []llm.Message {
|
||||||
|
return a.buildLLMMessagesWithStats(query, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *LLMAgent) buildLLMMessagesWithStats(query string, stats *contextStats) []llm.Message {
|
||||||
messages := make([]llm.Message, 0)
|
messages := make([]llm.Message, 0)
|
||||||
|
if stats == nil {
|
||||||
|
stats = &contextStats{}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 用户自定义 system prompt(配置式身份描述)
|
// 1. 用户自定义 system prompt(配置式身份描述)
|
||||||
if a.systemPrompt != "" {
|
if a.systemPrompt != "" {
|
||||||
@ -205,6 +259,7 @@ func (a *LLMAgent) buildLLMMessages() []llm.Message {
|
|||||||
Role: "system",
|
Role: "system",
|
||||||
Content: a.systemPrompt,
|
Content: a.systemPrompt,
|
||||||
})
|
})
|
||||||
|
stats.systemPromptTokens = estimateTokens(a.systemPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 运行时工具说明(动态生成)
|
// 2. 运行时工具说明(动态生成)
|
||||||
@ -214,41 +269,122 @@ func (a *LLMAgent) buildLLMMessages() []llm.Message {
|
|||||||
Role: "system",
|
Role: "system",
|
||||||
Content: toolPrompt,
|
Content: toolPrompt,
|
||||||
})
|
})
|
||||||
|
stats.toolPromptTokens = estimateTokens(toolPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.sessionMgr == nil || a.sessionID == "" {
|
if a.memoryManager != nil && a.sessionID != "" {
|
||||||
return messages
|
if a.memoryManager.ShouldInjectMemory(a.sessionID, query) {
|
||||||
}
|
memoryCtx, memStats := a.memoryManager.BuildMemoryContextWithStats(a.sessionID, query)
|
||||||
|
if memoryCtx != "" {
|
||||||
sessionMsgs, err := a.sessionMgr.GetContext(a.sessionID, a.windowSize)
|
messages = append(messages, llm.Message{
|
||||||
if err != nil {
|
Role: "system",
|
||||||
return messages
|
Content: memoryCtx,
|
||||||
}
|
})
|
||||||
|
stats.memoryTokens = memStats.TotalTokens
|
||||||
for _, sm := range sessionMsgs {
|
stats.memoryShortTerm = memStats.ShortTermCount
|
||||||
msg := llm.Message{
|
stats.memoryLongTerm = memStats.LongTermCount
|
||||||
Role: string(sm.Role),
|
}
|
||||||
Content: sm.Content,
|
|
||||||
}
|
}
|
||||||
if sm.Role == session.RoleTool && sm.Metadata != nil {
|
}
|
||||||
msg.ToolCallID = sm.Metadata["tool_call_id"]
|
|
||||||
|
if a.sessionMgr != nil && a.sessionID != "" {
|
||||||
|
sessionMsgs, err := a.sessionMgr.GetContext(a.sessionID, a.windowSize)
|
||||||
|
if err == nil {
|
||||||
|
stats.historyCount = len(sessionMsgs)
|
||||||
|
for _, sm := range sessionMsgs {
|
||||||
|
msg := llm.Message{
|
||||||
|
Role: string(sm.Role),
|
||||||
|
Content: sm.Content,
|
||||||
|
}
|
||||||
|
if sm.Role == session.RoleTool && sm.Metadata != nil {
|
||||||
|
msg.ToolCallID = sm.Metadata["tool_call_id"]
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
|
stats.historyTokens += estimateTokens(sm.Content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
messages = append(messages, msg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *LLMAgent) LogContextDetails(query string) {
|
||||||
|
fmt.Println("\n========== 上下文详情 ==========")
|
||||||
|
|
||||||
|
if a.memoryManager != nil && a.sessionID != "" {
|
||||||
|
fmt.Println("\n[记忆内容]")
|
||||||
|
shortTerm, _ := a.memoryManager.GetShortTermMemory(a.sessionID, query)
|
||||||
|
if len(shortTerm) > 0 {
|
||||||
|
fmt.Println(" 短期记忆:")
|
||||||
|
for i, m := range shortTerm {
|
||||||
|
fmt.Printf(" [%d] %s\n", i+1, truncateForDisplay(m, 80))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
longTerm, _ := a.memoryManager.GetLongTermMemory(query)
|
||||||
|
if len(longTerm) > 0 {
|
||||||
|
fmt.Println(" 长期记忆:")
|
||||||
|
for i, m := range longTerm {
|
||||||
|
fmt.Printf(" [%d] %s\n", i+1, truncateForDisplay(m.Content, 80))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(shortTerm) == 0 && len(longTerm) == 0 {
|
||||||
|
fmt.Println(" (无记忆)")
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheSize, cacheHits, cacheMisses := a.memoryManager.CacheStats()
|
||||||
|
fmt.Printf("\n[Embedding缓存] 大小=%d, 命中=%d, 未命中=%d, 命中率=%.1f%%\n",
|
||||||
|
cacheSize, cacheHits, cacheMisses,
|
||||||
|
float64(cacheHits)*100/float64(cacheHits+cacheMisses+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.sessionMgr != nil && a.sessionID != "" {
|
||||||
|
fmt.Println("\n[历史对话]")
|
||||||
|
sessionMsgs, err := a.sessionMgr.GetContext(a.sessionID, a.windowSize)
|
||||||
|
if err == nil && len(sessionMsgs) > 0 {
|
||||||
|
start := 0
|
||||||
|
if len(sessionMsgs) > 10 {
|
||||||
|
start = len(sessionMsgs) - 10
|
||||||
|
fmt.Printf(" (显示最近 10/%d 条)\n", len(sessionMsgs))
|
||||||
|
}
|
||||||
|
for i := start; i < len(sessionMsgs); i++ {
|
||||||
|
sm := sessionMsgs[i]
|
||||||
|
role := string(sm.Role)
|
||||||
|
if role == "" {
|
||||||
|
role = "unknown"
|
||||||
|
}
|
||||||
|
fmt.Printf(" [%s] %s\n", role, truncateForDisplay(sm.Content, 80))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println(" (无历史)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("================================\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForDisplay(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateTokens(text string) int {
|
||||||
|
return len([]rune(text)) / 4
|
||||||
|
}
|
||||||
|
|
||||||
// buildToolPrompt 生成工具说明提示词(不包含身份描述)。
|
// buildToolPrompt 生成工具说明提示词(不包含身份描述)。
|
||||||
// 将可用工具和调用规则注入给 LLM,支持基于提示词的工具调用。
|
// 将可用工具和调用规则注入给 LLM,支持基于提示词的工具调用。
|
||||||
func (a *LLMAgent) buildToolPrompt() string {
|
func (a *LLMAgent) buildToolPrompt() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
if a.toolManager != nil {
|
if a.toolManager != nil {
|
||||||
|
tools := a.toolManager.List()
|
||||||
|
|
||||||
b.WriteString("你可以使用以下工具来完成用户的请求。\n\n")
|
b.WriteString("你可以使用以下工具来完成用户的请求。\n\n")
|
||||||
b.WriteString("可用工具列表:\n")
|
b.WriteString("可用工具列表:\n")
|
||||||
|
|
||||||
for _, t := range a.toolManager.List() {
|
for _, t := range tools {
|
||||||
b.WriteString(fmt.Sprintf("\n工具名: %s\n", t.Name()))
|
b.WriteString(fmt.Sprintf("\n工具名: %s\n", t.Name()))
|
||||||
b.WriteString(fmt.Sprintf("描述: %s\n", t.Description()))
|
b.WriteString(fmt.Sprintf("描述: %s\n", t.Description()))
|
||||||
paramsJSON, _ := json.Marshal(t.Parameters())
|
paramsJSON, _ := json.Marshal(t.Parameters())
|
||||||
@ -261,7 +397,8 @@ func (a *LLMAgent) buildToolPrompt() string {
|
|||||||
b.WriteString("2. 如果需要同时调用多个工具(并行执行),请输出 JSON 数组格式:\n")
|
b.WriteString("2. 如果需要同时调用多个工具(并行执行),请输出 JSON 数组格式:\n")
|
||||||
b.WriteString(` [{"tool": "工具名1", "arguments": {...}}, {"tool": "工具名2", "arguments": {...}}]` + "\n")
|
b.WriteString(` [{"tool": "工具名1", "arguments": {...}}, {"tool": "工具名2", "arguments": {...}}]` + "\n")
|
||||||
b.WriteString("3. 如果你已经看到了工具返回的结果,请直接根据结果回答用户,不要再次调用工具。\n")
|
b.WriteString("3. 如果你已经看到了工具返回的结果,请直接根据结果回答用户,不要再次调用工具。\n")
|
||||||
b.WriteString("4. 如果你不需要调用工具,请直接回复用户。\n")
|
b.WriteString("4. 当你不需要调用工具时,请直接回复用户。\n")
|
||||||
|
b.WriteString("5. 当用户的请求涉及代码、架构、数学计算等专业领域时,你必须调用相应的子Agent,不要自己直接回答。\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(a.subAgents) > 0 {
|
if len(a.subAgents) > 0 {
|
||||||
@ -272,7 +409,8 @@ func (a *LLMAgent) buildToolPrompt() string {
|
|||||||
b.WriteString("\n调用方式:使用 agent_call 工具,指定 agent 名称和任务描述。\n")
|
b.WriteString("\n调用方式:使用 agent_call 工具,指定 agent 名称和任务描述。\n")
|
||||||
b.WriteString("示例:{\"tool\": \"agent_call\", \"arguments\": {\"agent\": \"coder\", \"task\": \"写个快速排序\"}}\n")
|
b.WriteString("示例:{\"tool\": \"agent_call\", \"arguments\": {\"agent\": \"coder\", \"task\": \"写个快速排序\"}}\n")
|
||||||
b.WriteString("如果用户有多个独立任务,请同时调用多个 agent_call(JSON数组格式),让它们并行执行。\n")
|
b.WriteString("如果用户有多个独立任务,请同时调用多个 agent_call(JSON数组格式),让它们并行执行。\n")
|
||||||
b.WriteString("\n重要:当用户的请求涉及上述专业领域时,你必须调用相应的子Agent,不要自己直接回答。\n")
|
b.WriteString("\n【强制规则】当用户的请求涉及代码编程、系统架构、数学计算、代码审查等专业领域时,你必须调用相应的子Agent。\n")
|
||||||
|
b.WriteString("你绝对不能自己直接回答编程或架构问题,必须通过 agent_call 工具委托给专业Agent处理。\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.skillManager != nil {
|
if a.skillManager != nil {
|
||||||
@ -305,6 +443,47 @@ func (a *LLMAgent) buildToolPrompt() string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *LLMAgent) buildToolDefs() []llm.ToolDef {
|
||||||
|
var tools []llm.ToolDef
|
||||||
|
|
||||||
|
if a.toolManager != nil {
|
||||||
|
for _, t := range a.toolManager.List() {
|
||||||
|
params := t.Parameters()
|
||||||
|
properties := make(map[string]llm.ToolProperty)
|
||||||
|
required := []string{}
|
||||||
|
|
||||||
|
for name, param := range params {
|
||||||
|
prop := llm.ToolProperty{
|
||||||
|
Type: param.Type,
|
||||||
|
Description: param.Description,
|
||||||
|
}
|
||||||
|
if len(param.Enum) > 0 {
|
||||||
|
prop.Enum = param.Enum
|
||||||
|
}
|
||||||
|
properties[name] = prop
|
||||||
|
if param.Required {
|
||||||
|
required = append(required, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tools = append(tools, llm.ToolDef{
|
||||||
|
Type: "function",
|
||||||
|
Function: llm.ToolFunction{
|
||||||
|
Name: t.Name(),
|
||||||
|
Description: t.Description(),
|
||||||
|
Parameters: llm.ToolFunctionParameters{
|
||||||
|
Type: "object",
|
||||||
|
Required: required,
|
||||||
|
Properties: properties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
func (a *LLMAgent) chatWithToolLoop(ctx context.Context, messages []llm.Message) (string, error) {
|
func (a *LLMAgent) chatWithToolLoop(ctx context.Context, messages []llm.Message) (string, error) {
|
||||||
maxRounds := 10
|
maxRounds := 10
|
||||||
|
|
||||||
@ -332,6 +511,7 @@ func (a *LLMAgent) chatWithToolLoop(ctx context.Context, messages []llm.Message)
|
|||||||
})
|
})
|
||||||
|
|
||||||
results := a.executeToolCallsParallel(ctx, toolCalls)
|
results := a.executeToolCallsParallel(ctx, toolCalls)
|
||||||
|
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
messages = append(messages, llm.Message{
|
messages = append(messages, llm.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
@ -452,20 +632,35 @@ func (a *LLMAgent) parseToolCallsFromContent(content string) []llm.ToolCall {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *LLMAgent) extractJSONFromMarkdown(content string) string {
|
func (a *LLMAgent) extractJSONFromMarkdown(content string) string {
|
||||||
start := strings.Index(content, "```")
|
start := strings.Index(content, "`"+"``json")
|
||||||
if start == -1 {
|
if start == -1 {
|
||||||
return content
|
start = strings.Index(content, "`"+"``")
|
||||||
|
if start == -1 {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
start += 7
|
||||||
}
|
}
|
||||||
|
|
||||||
start = strings.Index(content[start:], "\n")
|
if !strings.HasPrefix(content[start:], "`"+"``") {
|
||||||
if start == -1 {
|
newline := strings.Index(content[start:], "\n")
|
||||||
return content
|
if newline != -1 {
|
||||||
|
start = start + newline + 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
start += 3
|
||||||
|
newline := strings.Index(content[start:], "\n")
|
||||||
|
if newline != -1 {
|
||||||
|
start = start + newline + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
start++
|
|
||||||
|
|
||||||
end := strings.LastIndex(content[start:], "```")
|
end := strings.Index(content[start:], "\n`"+"``")
|
||||||
if end == -1 {
|
if end == -1 {
|
||||||
return content
|
end = strings.Index(content[start:], "`"+"``")
|
||||||
|
}
|
||||||
|
if end == -1 {
|
||||||
|
return strings.TrimSpace(content[start:])
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(content[start : start+end])
|
return strings.TrimSpace(content[start : start+end])
|
||||||
|
|||||||
165
pkg/actor/memory_extractor.go
Normal file
165
pkg/actor/memory_extractor.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/orca/orca/pkg/bus"
|
||||||
|
"github.com/orca/orca/pkg/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoryExtractorAgent struct {
|
||||||
|
*SubAgent
|
||||||
|
config ExtractConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractConfig struct {
|
||||||
|
BatchSize int
|
||||||
|
MaxFacts int
|
||||||
|
MinConfidence float64
|
||||||
|
AutoTag bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dialogue struct {
|
||||||
|
UserQuery string
|
||||||
|
AssistantResponse string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fact struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
Replace *string `json:"replace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractResult struct {
|
||||||
|
Facts []Fact `json:"facts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultMemoryExtractorPrompt = `# Memory Extractor Agent
|
||||||
|
|
||||||
|
你是一个专门从对话中提取用户信息的 Agent。你的工作是将非结构化的对话转化为结构化的长期记忆。
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
|
||||||
|
分析给定的对话记录,提取以下类型的信息:
|
||||||
|
|
||||||
|
1. **事实 (fact)**:客观信息
|
||||||
|
- 工作:公司、职位、技术栈、行业
|
||||||
|
- 技术:擅长语言、框架偏好、架构经验
|
||||||
|
- 个人:教育背景、所在城市(仅用户明确提及)
|
||||||
|
|
||||||
|
2. **偏好 (preference)**:主观倾向
|
||||||
|
- 回答风格:简洁/详细/代码示例/架构图
|
||||||
|
- 技术偏好:语言、数据库、部署方式
|
||||||
|
- 沟通偏好:正式/ casual
|
||||||
|
|
||||||
|
3. **项目 (project)**:当前工作
|
||||||
|
- 项目名称、技术方案、当前阶段、遇到的挑战
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
只输出 JSON,不要任何解释:
|
||||||
|
|
||||||
|
` + "```json" + `
|
||||||
|
{
|
||||||
|
"facts": [
|
||||||
|
{
|
||||||
|
"content": "用户在电商公司担任后端工程师",
|
||||||
|
"type": "fact",
|
||||||
|
"tags": ["工作", "后端", "电商"],
|
||||||
|
"confidence": 0.95,
|
||||||
|
"replace": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "用户偏好简洁的技术回答,不要过多解释",
|
||||||
|
"type": "preference",
|
||||||
|
"tags": ["沟通风格", "偏好"],
|
||||||
|
"confidence": 0.85,
|
||||||
|
"replace": "用户喜欢详细的回答"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
|
||||||
|
- confidence < 0.6 的事实不输出
|
||||||
|
- 如果新事实与旧事实冲突:
|
||||||
|
- 在 replace 字段填入被替换的旧事实 content
|
||||||
|
- 只替换同一 type + 同一 tags 的事实
|
||||||
|
- 不猜测、不推断,只提取用户明确表达的信息
|
||||||
|
- 标签从预设列表选择:工作、技术、偏好、项目、沟通风格、行业`
|
||||||
|
|
||||||
|
func loadAgentPrompt(agentName string) string {
|
||||||
|
path := filepath.Join(os.Getenv("HOME"), ".orca", "agents", "_builtin", agentName+".md")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return DefaultMemoryExtractorPrompt
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryExtractorAgent(id string, llmBackend llm.LLM, cfg ExtractConfig) *MemoryExtractorAgent {
|
||||||
|
prompt := loadAgentPrompt("memory_extractor")
|
||||||
|
|
||||||
|
sa := NewSubAgent(id, llmBackend,
|
||||||
|
WithSubAgentRole("memory_extractor"),
|
||||||
|
WithSubAgentSystemPrompt(prompt),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &MemoryExtractorAgent{
|
||||||
|
SubAgent: sa,
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mea *MemoryExtractorAgent) ExtractFacts(dialogues []Dialogue) ([]Fact, error) {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("请分析以下对话记录,提取用户的关键信息:\n\n")
|
||||||
|
for i, d := range dialogues {
|
||||||
|
sb.WriteString(fmt.Sprintf("--- 对话 %d ---\n", i+1))
|
||||||
|
sb.WriteString(fmt.Sprintf("用户:%s\n", d.UserQuery))
|
||||||
|
sb.WriteString(fmt.Sprintf("助手:%s\n\n", d.AssistantResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := bus.Message{Type: bus.MsgTypeTaskRequest, Content: sb.String()}
|
||||||
|
resp, err := mea.Process(context.Background(), msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract facts failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseFactJSON(resp.Content.(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFactJSON(content string) ([]Fact, error) {
|
||||||
|
content = extractJSONFromMarkdown(content)
|
||||||
|
|
||||||
|
var result ExtractResult
|
||||||
|
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse fact json failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Facts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractJSONFromMarkdown(content string) string {
|
||||||
|
if idx := strings.Index(content, "```json"); idx != -1 {
|
||||||
|
start := idx + 7
|
||||||
|
if end := strings.Index(content[start:], "```"); end != -1 {
|
||||||
|
return strings.TrimSpace(content[start : start+end])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx := strings.Index(content, "```"); idx != -1 {
|
||||||
|
start := idx + 3
|
||||||
|
if end := strings.Index(content[start:], "```"); end != -1 {
|
||||||
|
return strings.TrimSpace(content[start : start+end])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(content)
|
||||||
|
}
|
||||||
@ -5,17 +5,27 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/orca/orca/pkg/bus"
|
"github.com/orca/orca/pkg/bus"
|
||||||
"github.com/orca/orca/pkg/llm"
|
"github.com/orca/orca/pkg/llm"
|
||||||
|
"github.com/orca/orca/pkg/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SubAgentStore interface {
|
||||||
|
SaveSubAgentMessage(parentSessionID, sessionID, agentName string, msg session.SessionMessage) error
|
||||||
|
LoadSubAgentMessages(sessionID string) ([]session.SessionMessage, error)
|
||||||
|
}
|
||||||
|
|
||||||
type SubAgent struct {
|
type SubAgent struct {
|
||||||
*BaseAgent
|
*BaseAgent
|
||||||
llmBackend llm.LLM
|
llmBackend llm.LLM
|
||||||
systemPrompt string
|
systemPrompt string
|
||||||
role string
|
role string
|
||||||
streamWriter io.Writer
|
streamWriter io.Writer
|
||||||
|
store SubAgentStore
|
||||||
|
parentSessionID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubAgentOption func(*SubAgent)
|
type SubAgentOption func(*SubAgent)
|
||||||
@ -38,6 +48,18 @@ func WithSubAgentStreamWriter(w io.Writer) SubAgentOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithSubAgentStore(store SubAgentStore) SubAgentOption {
|
||||||
|
return func(a *SubAgent) {
|
||||||
|
a.store = store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSubAgentParentSessionID(parentSessionID string) SubAgentOption {
|
||||||
|
return func(a *SubAgent) {
|
||||||
|
a.parentSessionID = parentSessionID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewSubAgent(id string, llmBackend llm.LLM, opts ...SubAgentOption) *SubAgent {
|
func NewSubAgent(id string, llmBackend llm.LLM, opts ...SubAgentOption) *SubAgent {
|
||||||
sa := &SubAgent{
|
sa := &SubAgent{
|
||||||
BaseAgent: NewBaseAgent(id, "subagent"),
|
BaseAgent: NewBaseAgent(id, "subagent"),
|
||||||
@ -71,6 +93,10 @@ func (sa *SubAgent) SetStreamWriter(w io.Writer) {
|
|||||||
sa.streamWriter = w
|
sa.streamWriter = w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sa *SubAgent) SetParentSessionID(parentSessionID string) {
|
||||||
|
sa.parentSessionID = parentSessionID
|
||||||
|
}
|
||||||
|
|
||||||
func (sa *SubAgent) handleMessage(ctx context.Context, msg bus.Message) (bus.Message, error) {
|
func (sa *SubAgent) handleMessage(ctx context.Context, msg bus.Message) (bus.Message, error) {
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case bus.MsgTypeTaskRequest:
|
case bus.MsgTypeTaskRequest:
|
||||||
@ -83,6 +109,25 @@ func (sa *SubAgent) handleMessage(ctx context.Context, msg bus.Message) (bus.Mes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (sa *SubAgent) handleTask(ctx context.Context, msg bus.Message) (bus.Message, error) {
|
func (sa *SubAgent) handleTask(ctx context.Context, msg bus.Message) (bus.Message, error) {
|
||||||
|
sessionID := uuid.New().String()
|
||||||
|
parentSessionID := sa.parentSessionID
|
||||||
|
if parentSessionID == "" {
|
||||||
|
parentSessionID = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
if sa.store != nil {
|
||||||
|
sa.store.SaveSubAgentMessage(parentSessionID, sessionID, sa.ID(), session.SessionMessage{
|
||||||
|
Role: session.RoleSystem,
|
||||||
|
Content: sa.systemPrompt,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
sa.store.SaveSubAgentMessage(parentSessionID, sessionID, sa.ID(), session.SessionMessage{
|
||||||
|
Role: session.RoleUser,
|
||||||
|
Content: fmt.Sprintf("%v", msg.Content),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
messages := []llm.Message{
|
messages := []llm.Message{
|
||||||
{
|
{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
@ -99,6 +144,14 @@ func (sa *SubAgent) handleTask(ctx context.Context, msg bus.Message) (bus.Messag
|
|||||||
return bus.Message{}, fmt.Errorf("subagent %s: LLM call failed: %w", sa.ID(), err)
|
return bus.Message{}, fmt.Errorf("subagent %s: LLM call failed: %w", sa.ID(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sa.store != nil {
|
||||||
|
sa.store.SaveSubAgentMessage(parentSessionID, sessionID, sa.ID(), session.SessionMessage{
|
||||||
|
Role: session.RoleAssistant,
|
||||||
|
Content: content,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return bus.Message{
|
return bus.Message{
|
||||||
ID: msg.ID + "-response",
|
ID: msg.ID + "-response",
|
||||||
Type: bus.MsgTypeTaskResponse,
|
Type: bus.MsgTypeTaskResponse,
|
||||||
@ -106,8 +159,10 @@ func (sa *SubAgent) handleTask(ctx context.Context, msg bus.Message) (bus.Messag
|
|||||||
To: msg.From,
|
To: msg.From,
|
||||||
Content: content,
|
Content: content,
|
||||||
Metadata: map[string]string{
|
Metadata: map[string]string{
|
||||||
"processed_by": sa.ID(),
|
"processed_by": sa.ID(),
|
||||||
"agent_role": sa.role,
|
"agent_role": sa.role,
|
||||||
|
"session_id": sessionID,
|
||||||
|
"parent_session_id": parentSessionID,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
124
pkg/embedding/client.go
Normal file
124
pkg/embedding/client.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package embedding
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
apiKey string
|
||||||
|
baseURL string
|
||||||
|
model string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
APIKey string
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg Config) *Client {
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
cfg.BaseURL = "https://api.siliconflow.cn/v1"
|
||||||
|
}
|
||||||
|
if cfg.Model == "" {
|
||||||
|
cfg.Model = "Pro/BAAI/bge-m3"
|
||||||
|
}
|
||||||
|
if cfg.Timeout == 0 {
|
||||||
|
cfg.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
apiKey: cfg.APIKey,
|
||||||
|
baseURL: cfg.BaseURL,
|
||||||
|
model: cfg.Model,
|
||||||
|
client: &http.Client{Timeout: cfg.Timeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input []string `json:"input"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
Embedding []float32 `json:"embedding"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Embed(texts []string) ([][]float32, error) {
|
||||||
|
if len(texts) == 0 {
|
||||||
|
return nil, fmt.Errorf("no texts to embed")
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, err := json.Marshal(embedRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Input: texts,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", c.baseURL+"/embeddings", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("embedding API returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var embedResp embedResponse
|
||||||
|
if err := json.Unmarshal(body, &embedResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if embedResp.Error != nil {
|
||||||
|
return nil, fmt.Errorf("embedding API error: %s", embedResp.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([][]float32, len(texts))
|
||||||
|
for _, d := range embedResp.Data {
|
||||||
|
if d.Index < len(results) {
|
||||||
|
results[d.Index] = d.Embedding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) EmbedSingle(text string) ([]float32, error) {
|
||||||
|
results, err := c.Embed([]string{text})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, fmt.Errorf("no embedding returned")
|
||||||
|
}
|
||||||
|
return results[0], nil
|
||||||
|
}
|
||||||
@ -13,10 +13,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/orca/orca/internal/config"
|
"github.com/orca/orca/internal/config"
|
||||||
"github.com/orca/orca/pkg/actor"
|
"github.com/orca/orca/pkg/actor"
|
||||||
"github.com/orca/orca/pkg/bus"
|
"github.com/orca/orca/pkg/bus"
|
||||||
|
"github.com/orca/orca/pkg/embedding"
|
||||||
"github.com/orca/orca/pkg/llm"
|
"github.com/orca/orca/pkg/llm"
|
||||||
"github.com/orca/orca/pkg/plugin"
|
"github.com/orca/orca/pkg/plugin"
|
||||||
"github.com/orca/orca/pkg/session"
|
"github.com/orca/orca/pkg/session"
|
||||||
@ -24,16 +26,6 @@ import (
|
|||||||
"github.com/orca/orca/pkg/tool"
|
"github.com/orca/orca/pkg/tool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Kernel 是 Orca 框架的微内核核心。
|
|
||||||
//
|
|
||||||
// 它编排插件生命周期、消息路由和组件间通信。
|
|
||||||
// 内核初始化并管理以下组件:
|
|
||||||
// - 消息总线,用于组件间通信
|
|
||||||
// - 插件注册表,支持扩展
|
|
||||||
// - 会话管理器,用于对话持久化
|
|
||||||
// - 工具管理器,包含内置工具
|
|
||||||
// - 技能管理器,用于基于技能的自动化
|
|
||||||
// - Actor 系统,包含编排器、工作者和 LLM 代理
|
|
||||||
type Kernel struct {
|
type Kernel struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
mb bus.MessageBus
|
mb bus.MessageBus
|
||||||
@ -41,16 +33,17 @@ type Kernel struct {
|
|||||||
plugins []plugin.Plugin
|
plugins []plugin.Plugin
|
||||||
started bool
|
started bool
|
||||||
|
|
||||||
// Integration components
|
config *config.Config
|
||||||
config *config.Config
|
sessionMgr *session.Manager
|
||||||
sessionMgr *session.Manager
|
sessionStore *session.SQLiteStore
|
||||||
toolMgr *tool.Manager
|
memoryManager *session.MemoryManager
|
||||||
skillMgr *skill.Manager
|
toolMgr *tool.Manager
|
||||||
actorSystem *actor.System
|
skillMgr *skill.Manager
|
||||||
orch *actor.Orchestrator
|
actorSystem *actor.System
|
||||||
llmAgent *actor.LLMAgent
|
orch *actor.Orchestrator
|
||||||
toolWorker *actor.ToolWorker
|
llmAgent *actor.LLMAgent
|
||||||
subAgents map[string]actor.Agent
|
toolWorker *actor.ToolWorker
|
||||||
|
subAgents map[string]actor.Agent
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 从配置文件创建一个新的 Kernel 实例。
|
// New 从配置文件创建一个新的 Kernel 实例。
|
||||||
@ -78,12 +71,34 @@ func NewWithConfig(cfg *config.Config) *Kernel {
|
|||||||
subAgents: make(map[string]actor.Agent),
|
subAgents: make(map[string]actor.Agent),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize session manager
|
storageDir := expandHomeDir(cfg.Session.StorageDir)
|
||||||
store, err := session.NewJSONLStore(cfg.Session.StorageDir)
|
dbPath := filepath.Join(storageDir, "orcasession.db")
|
||||||
|
store, err := session.NewSQLiteStore(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("kernel: warning: failed to create session store: %v", err)
|
log.Printf("kernel: warning: failed to create session store: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
k.sessionStore = store
|
||||||
k.sessionMgr = session.NewManager(store, k.mb)
|
k.sessionMgr = session.NewManager(store, k.mb)
|
||||||
|
log.Printf("kernel: session manager initialized with SQLite storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SiliconFlow.APIKey != "" && store != nil {
|
||||||
|
memoryCfg := session.MemoryConfig{
|
||||||
|
DBPath: dbPath,
|
||||||
|
ModelWindow: cfg.Embedding.MaxCtx,
|
||||||
|
EmbedConfig: embedding.Config{
|
||||||
|
APIKey: cfg.SiliconFlow.APIKey,
|
||||||
|
BaseURL: cfg.SiliconFlow.BaseURL,
|
||||||
|
Model: cfg.Embedding.Model,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
k.memoryManager, err = session.NewMemoryManagerWithStore(memoryCfg, store)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("kernel: warning: failed to create memory manager: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("kernel: memory manager initialized with %s embedding (shared storage)", cfg.Embedding.Model)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize tool manager with all built-in tools
|
// Initialize tool manager with all built-in tools
|
||||||
@ -137,6 +152,9 @@ func (k *Kernel) initializeActorSystem() {
|
|||||||
k.createSubAgents(ollama)
|
k.createSubAgents(ollama)
|
||||||
|
|
||||||
agentCallTool := tool.NewAgentCallTool(k.findAgent)
|
agentCallTool := tool.NewAgentCallTool(k.findAgent)
|
||||||
|
if acTool, ok := agentCallTool.(interface{ SetEventBus(bus.MessageBus) }); ok {
|
||||||
|
acTool.SetEventBus(k.mb)
|
||||||
|
}
|
||||||
if err := k.toolMgr.Register(agentCallTool); err != nil {
|
if err := k.toolMgr.Register(agentCallTool); err != nil {
|
||||||
log.Printf("kernel: warning: failed to register agent_call tool: %v", err)
|
log.Printf("kernel: warning: failed to register agent_call tool: %v", err)
|
||||||
}
|
}
|
||||||
@ -148,7 +166,10 @@ func (k *Kernel) initializeActorSystem() {
|
|||||||
actor.WithWindowSize(k.config.Session.MaxHistory),
|
actor.WithWindowSize(k.config.Session.MaxHistory),
|
||||||
}
|
}
|
||||||
|
|
||||||
if prompt := k.config.GetSystemPrompt(); prompt != "" {
|
builtinPromptPath := expandHomeDir("~/.orca/agents/_builtin/assistant.md")
|
||||||
|
if data, err := os.ReadFile(builtinPromptPath); err == nil && len(data) > 0 {
|
||||||
|
llmOpts = append(llmOpts, actor.WithSystemPrompt(string(data)))
|
||||||
|
} else if prompt := k.config.GetSystemPrompt(); prompt != "" {
|
||||||
llmOpts = append(llmOpts, actor.WithSystemPrompt(prompt))
|
llmOpts = append(llmOpts, actor.WithSystemPrompt(prompt))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,6 +191,10 @@ func (k *Kernel) initializeActorSystem() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if k.memoryManager != nil {
|
||||||
|
llmOpts = append(llmOpts, actor.WithMemoryManager(k.memoryManager))
|
||||||
|
}
|
||||||
|
|
||||||
if len(k.subAgents) > 0 {
|
if len(k.subAgents) > 0 {
|
||||||
agentDescs := make(map[string]string)
|
agentDescs := make(map[string]string)
|
||||||
for name, agent := range k.subAgents {
|
for name, agent := range k.subAgents {
|
||||||
@ -183,16 +208,20 @@ func (k *Kernel) initializeActorSystem() {
|
|||||||
llmAgent := actor.NewLLMAgent(llmAgentID, ollama, llmOpts...)
|
llmAgent := actor.NewLLMAgent(llmAgentID, ollama, llmOpts...)
|
||||||
k.llmAgent = llmAgent
|
k.llmAgent = llmAgent
|
||||||
|
|
||||||
|
if k.memoryManager != nil {
|
||||||
|
k.memoryManager.SetLLM(ollama)
|
||||||
|
}
|
||||||
|
|
||||||
k.orch.AddWorker(llmAgent)
|
k.orch.AddWorker(llmAgent)
|
||||||
k.orch.AddWorker(tw)
|
k.orch.AddWorker(tw)
|
||||||
k.orch.SetDefaultWorker(llmAgent)
|
k.orch.SetDefaultWorker(llmAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *Kernel) createSubAgents(llmBackend llm.LLM) {
|
func (k *Kernel) createSubAgents(llmBackend llm.LLM) {
|
||||||
promptDir := expandHomeDir("~/.orca/prompts")
|
agentsDir := expandHomeDir("~/.orca/agents")
|
||||||
entries, err := os.ReadDir(promptDir)
|
entries, err := os.ReadDir(agentsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("kernel: warning: cannot read prompts dir %s: %v", promptDir, err)
|
log.Printf("kernel: warning: cannot read agents dir %s: %v", agentsDir, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,31 +233,30 @@ func (k *Kernel) createSubAgents(llmBackend llm.LLM) {
|
|||||||
if !strings.HasSuffix(name, ".md") {
|
if !strings.HasSuffix(name, ".md") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if name == "assistant.md" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
agentName := strings.TrimSuffix(name, ".md")
|
agentName := strings.TrimSuffix(name, ".md")
|
||||||
promptPath := filepath.Join(promptDir, name)
|
agentPath := filepath.Join(agentsDir, name)
|
||||||
content, err := os.ReadFile(promptPath)
|
content, err := os.ReadFile(agentPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("kernel: warning: failed to read prompt %s: %v", promptPath, err)
|
log.Printf("kernel: warning: failed to read agent prompt %s: %v", agentPath, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt := string(content)
|
prompt := string(content)
|
||||||
if strings.TrimSpace(prompt) == "" {
|
if strings.TrimSpace(prompt) == "" {
|
||||||
log.Printf("kernel: warning: empty prompt file %s, skipping", promptPath)
|
log.Printf("kernel: warning: empty agent prompt file %s, skipping", agentPath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
agent := actor.NewSubAgent(agentName, llmBackend,
|
agent := actor.NewSubAgent(agentName, llmBackend,
|
||||||
actor.WithSubAgentRole(agentName),
|
actor.WithSubAgentRole(agentName),
|
||||||
actor.WithSubAgentSystemPrompt(prompt),
|
actor.WithSubAgentSystemPrompt(prompt),
|
||||||
|
actor.WithSubAgentStore(k.sessionStore),
|
||||||
|
actor.WithSubAgentParentSessionID("default"),
|
||||||
)
|
)
|
||||||
k.subAgents[agentName] = agent
|
k.subAgents[agentName] = agent
|
||||||
k.orch.AddWorker(agent)
|
k.orch.AddWorker(agent)
|
||||||
log.Printf("kernel: created sub-agent %q from %s", agentName, promptPath)
|
log.Printf("kernel: created sub-agent %q from %s", agentName, agentPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("kernel: created %d sub-agents", len(k.subAgents))
|
log.Printf("kernel: created %d sub-agents", len(k.subAgents))
|
||||||
@ -330,6 +358,11 @@ func (k *Kernel) LLMAgent() *actor.LLMAgent {
|
|||||||
return k.llmAgent
|
return k.llmAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MemoryManager 返回记忆管理器。
|
||||||
|
func (k *Kernel) MemoryManager() *session.MemoryManager {
|
||||||
|
return k.memoryManager
|
||||||
|
}
|
||||||
|
|
||||||
// SetStreamWriter 设置用于流式 LLM 输出的写入器。
|
// SetStreamWriter 设置用于流式 LLM 输出的写入器。
|
||||||
func (k *Kernel) SetStreamWriter(w io.Writer) {
|
func (k *Kernel) SetStreamWriter(w io.Writer) {
|
||||||
if k.llmAgent != nil {
|
if k.llmAgent != nil {
|
||||||
|
|||||||
@ -160,6 +160,52 @@ func (c *DeepSeekClient) Stream(ctx context.Context, messages []Message, handler
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *DeepSeekClient) ChatWithTools(ctx context.Context, messages []Message, tools []ToolDef) (*Response, error) {
|
||||||
|
reqBody := deepSeekChatRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Messages: messages,
|
||||||
|
Stream: false,
|
||||||
|
Tools: tools,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("deepseek: failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("deepseek: failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("deepseek: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("deepseek: API returned %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp deepSeekChatResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("deepseek: failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apiResp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("deepseek: no choices in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := apiResp.Choices[0]
|
||||||
|
return &Response{
|
||||||
|
Content: choice.Message.Content,
|
||||||
|
ToolCalls: choice.Message.ToolCalls,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DeepSeekClient) buildChatRequest(messages []Message, stream bool) deepSeekChatRequest {
|
func (c *DeepSeekClient) buildChatRequest(messages []Message, stream bool) deepSeekChatRequest {
|
||||||
return deepSeekChatRequest{
|
return deepSeekChatRequest{
|
||||||
Model: c.model,
|
Model: c.model,
|
||||||
@ -172,6 +218,7 @@ type deepSeekChatRequest struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
|
Tools []ToolDef `json:"tools,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deepSeekChatResponse struct {
|
type deepSeekChatResponse struct {
|
||||||
|
|||||||
@ -17,6 +17,10 @@ type LLM interface {
|
|||||||
// If the model decides to call tools, the response contains ToolCalls.
|
// If the model decides to call tools, the response contains ToolCalls.
|
||||||
Chat(ctx context.Context, messages []Message) (*Response, error)
|
Chat(ctx context.Context, messages []Message) (*Response, error)
|
||||||
|
|
||||||
|
// ChatWithTools sends messages with available tools and returns a response.
|
||||||
|
// The model may return ToolCalls that the caller should execute and feed back.
|
||||||
|
ChatWithTools(ctx context.Context, messages []Message, tools []ToolDef) (*Response, error)
|
||||||
|
|
||||||
// Stream sends messages and streams the response token-by-token.
|
// Stream sends messages and streams the response token-by-token.
|
||||||
// The handler is called for each chunk. The final response is not
|
// The handler is called for each chunk. The final response is not
|
||||||
// collected; use Chat for complete responses.
|
// collected; use Chat for complete responses.
|
||||||
|
|||||||
@ -79,6 +79,10 @@ func NewOllamaClient(opts ...OllamaOption) *OllamaClient {
|
|||||||
// Chat sends a chat request to Ollama and returns the complete response.
|
// Chat sends a chat request to Ollama and returns the complete response.
|
||||||
// If the Ollama model returns tool calls, they are parsed and included
|
// If the Ollama model returns tool calls, they are parsed and included
|
||||||
// in the Response.
|
// in the Response.
|
||||||
|
func (c *OllamaClient) ChatWithTools(ctx context.Context, messages []Message, tools []ToolDef) (*Response, error) {
|
||||||
|
return c.Chat(ctx, messages)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *OllamaClient) Chat(ctx context.Context, messages []Message) (*Response, error) {
|
func (c *OllamaClient) Chat(ctx context.Context, messages []Message) (*Response, error) {
|
||||||
req := OllamaChatRequest{
|
req := OllamaChatRequest{
|
||||||
Model: c.model,
|
Model: c.model,
|
||||||
|
|||||||
@ -11,9 +11,10 @@ package llm
|
|||||||
// or "tool". For tool results, ToolCallID links the result back to the
|
// or "tool". For tool results, ToolCallID links the result back to the
|
||||||
// tool call that produced it.
|
// tool call that produced it.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolCall represents a function calling request from the LLM.
|
// ToolCall represents a function calling request from the LLM.
|
||||||
|
|||||||
870
pkg/session/memory_manager.go
Normal file
870
pkg/session/memory_manager.go
Normal file
@ -0,0 +1,870 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/orca/orca/pkg/embedding"
|
||||||
|
"github.com/orca/orca/pkg/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoryManager struct {
|
||||||
|
store *SQLiteStore
|
||||||
|
vectorStore *VectorStore
|
||||||
|
embedClient *embedding.Client
|
||||||
|
llmBackend llm.LLM
|
||||||
|
tokenBudget TokenBudget
|
||||||
|
ownsStore bool
|
||||||
|
|
||||||
|
embedQueue chan embedTask
|
||||||
|
embedWg sync.WaitGroup
|
||||||
|
embedCtx context.Context
|
||||||
|
embedCancel context.CancelFunc
|
||||||
|
|
||||||
|
embedCache map[string]*embedCacheEntry
|
||||||
|
embedCacheMu sync.RWMutex
|
||||||
|
embedCacheMax int
|
||||||
|
embedCacheHit int64
|
||||||
|
embedCacheMiss int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedCacheEntry struct {
|
||||||
|
embedding []float32
|
||||||
|
lastUsed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedTask struct {
|
||||||
|
msgID int64
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenBudget struct {
|
||||||
|
Total int
|
||||||
|
Working int
|
||||||
|
ShortTerm int
|
||||||
|
LongTerm int
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryConfig struct {
|
||||||
|
DBPath string
|
||||||
|
EmbedConfig embedding.Config
|
||||||
|
ModelWindow int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryManager(cfg MemoryConfig) (*MemoryManager, error) {
|
||||||
|
store, err := NewSQLiteStore(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vectorStore, err := NewVectorStore(store.DB())
|
||||||
|
if err != nil {
|
||||||
|
store.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create vector store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
budget := calculateBudget(cfg.ModelWindow)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
mm := &MemoryManager{
|
||||||
|
store: store,
|
||||||
|
vectorStore: vectorStore,
|
||||||
|
tokenBudget: budget,
|
||||||
|
ownsStore: true,
|
||||||
|
embedQueue: make(chan embedTask, 100),
|
||||||
|
embedCtx: ctx,
|
||||||
|
embedCancel: cancel,
|
||||||
|
embedCache: make(map[string]*embedCacheEntry),
|
||||||
|
embedCacheMax: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.EmbedConfig.APIKey != "" {
|
||||||
|
mm.embedClient = embedding.NewClient(cfg.EmbedConfig)
|
||||||
|
mm.startEmbedWorker()
|
||||||
|
}
|
||||||
|
|
||||||
|
return mm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryManagerWithStore(cfg MemoryConfig, store *SQLiteStore) (*MemoryManager, error) {
|
||||||
|
vectorStore, err := NewVectorStore(store.DB())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create vector store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
budget := calculateBudget(cfg.ModelWindow)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
mm := &MemoryManager{
|
||||||
|
store: store,
|
||||||
|
vectorStore: vectorStore,
|
||||||
|
tokenBudget: budget,
|
||||||
|
ownsStore: false,
|
||||||
|
embedQueue: make(chan embedTask, 100),
|
||||||
|
embedCtx: ctx,
|
||||||
|
embedCancel: cancel,
|
||||||
|
embedCache: make(map[string]*embedCacheEntry),
|
||||||
|
embedCacheMax: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.EmbedConfig.APIKey != "" {
|
||||||
|
mm.embedClient = embedding.NewClient(cfg.EmbedConfig)
|
||||||
|
mm.startEmbedWorker()
|
||||||
|
}
|
||||||
|
|
||||||
|
return mm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateBudget(modelWindow int) TokenBudget {
|
||||||
|
if modelWindow <= 0 {
|
||||||
|
modelWindow = 8192
|
||||||
|
}
|
||||||
|
total := int(float64(modelWindow) * 0.6)
|
||||||
|
return TokenBudget{
|
||||||
|
Total: total,
|
||||||
|
Working: int(float64(total) * 0.5),
|
||||||
|
ShortTerm: int(float64(total) * 0.3),
|
||||||
|
LongTerm: int(float64(total) * 0.2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) Close() error {
|
||||||
|
mm.embedCancel()
|
||||||
|
mm.embedWg.Wait()
|
||||||
|
if mm.ownsStore {
|
||||||
|
return mm.store.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) SaveMessage(sessionID string, msg SessionMessage) error {
|
||||||
|
if err := mm.store.Save(sessionID, msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if mm.embedClient != nil && len(msg.Content) > 10 &&
|
||||||
|
(msg.Role == RoleUser || msg.Role == RoleAssistant) {
|
||||||
|
msgID, err := mm.getLastMessageID(sessionID)
|
||||||
|
if err == nil {
|
||||||
|
select {
|
||||||
|
case mm.embedQueue <- embedTask{msgID: msgID, content: msg.Content}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) getLastMessageID(sessionID string) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := mm.store.DB().QueryRow(
|
||||||
|
"SELECT id FROM main_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1",
|
||||||
|
sessionID,
|
||||||
|
).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) startEmbedWorker() {
|
||||||
|
mm.embedWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer mm.embedWg.Done()
|
||||||
|
batch := make([]embedTask, 0, 5)
|
||||||
|
timer := time.NewTimer(5 * time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case task := <- mm.embedQueue:
|
||||||
|
batch = append(batch, task)
|
||||||
|
if len(batch) >= 5 {
|
||||||
|
mm.processBatch(batch)
|
||||||
|
batch = batch[:0]
|
||||||
|
timer.Reset(5 * time.Second)
|
||||||
|
}
|
||||||
|
case <- timer.C:
|
||||||
|
if len(batch) > 0 {
|
||||||
|
mm.processBatch(batch)
|
||||||
|
batch = batch[:0]
|
||||||
|
}
|
||||||
|
timer.Reset(5 * time.Second)
|
||||||
|
case <- mm.embedCtx.Done():
|
||||||
|
if len(batch) > 0 {
|
||||||
|
mm.processBatch(batch)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) processBatch(tasks []embedTask) {
|
||||||
|
texts := make([]string, len(tasks))
|
||||||
|
for i, t := range tasks {
|
||||||
|
texts[i] = t.content
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddings, err := mm.embedClient.Embed(texts)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[memory] Embedding batch failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, emb := range embeddings {
|
||||||
|
if err := mm.vectorStore.SaveEmbedding(tasks[i].msgID, emb); err != nil {
|
||||||
|
log.Printf("[memory] Save embedding failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) GetWorkingMemory(sessionID string) ([]SessionMessage, error) {
|
||||||
|
rows, err := mm.store.DB().Query(
|
||||||
|
`SELECT role, content, timestamp, metadata FROM main_messages
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY timestamp DESC`,
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var messages []SessionMessage
|
||||||
|
totalTokens := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var msg SessionMessage
|
||||||
|
var timestampStr string
|
||||||
|
var metadataStr string
|
||||||
|
if err := rows.Scan(&msg.Role, &msg.Content, ×tampStr, &metadataStr); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg.Timestamp, _ = time.Parse(time.RFC3339, timestampStr)
|
||||||
|
|
||||||
|
tokens := estimateTokens(msg.Content)
|
||||||
|
if totalTokens+tokens > mm.tokenBudget.Working && len(messages) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
totalTokens += tokens
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
reverseMessages(messages)
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) getEmbeddingWithCache(query string) ([]float32, error) {
|
||||||
|
if mm.embedClient == nil {
|
||||||
|
return nil, fmt.Errorf("no embed client")
|
||||||
|
}
|
||||||
|
|
||||||
|
mm.embedCacheMu.RLock()
|
||||||
|
if entry, ok := mm.embedCache[query]; ok {
|
||||||
|
entry.lastUsed = time.Now()
|
||||||
|
mm.embedCacheHit++
|
||||||
|
mm.embedCacheMu.RUnlock()
|
||||||
|
return entry.embedding, nil
|
||||||
|
}
|
||||||
|
mm.embedCacheMu.RUnlock()
|
||||||
|
|
||||||
|
embedding, err := mm.embedClient.EmbedSingle(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mm.embedCacheMu.Lock()
|
||||||
|
mm.embedCache[query] = &embedCacheEntry{
|
||||||
|
embedding: embedding,
|
||||||
|
lastUsed: time.Now(),
|
||||||
|
}
|
||||||
|
mm.embedCacheMiss++
|
||||||
|
|
||||||
|
if len(mm.embedCache) > mm.embedCacheMax {
|
||||||
|
mm.evictLRU()
|
||||||
|
}
|
||||||
|
mm.embedCacheMu.Unlock()
|
||||||
|
|
||||||
|
return embedding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) evictLRU() {
|
||||||
|
var oldestKey string
|
||||||
|
var oldestTime time.Time
|
||||||
|
first := true
|
||||||
|
for k, v := range mm.embedCache {
|
||||||
|
if first || v.lastUsed.Before(oldestTime) {
|
||||||
|
oldestKey = k
|
||||||
|
oldestTime = v.lastUsed
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestKey != "" {
|
||||||
|
delete(mm.embedCache, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) GetShortTermMemory(sessionID string, query string) ([]string, error) {
|
||||||
|
if query != "" && mm.embedClient != nil {
|
||||||
|
embedding, err := mm.getEmbeddingWithCache(query)
|
||||||
|
if err == nil {
|
||||||
|
msgIDs, err := mm.vectorStore.SearchSimilarInSession(sessionID, embedding, 3)
|
||||||
|
if err == nil && len(msgIDs) > 0 {
|
||||||
|
return mm.loadMemoryContents(msgIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := mm.store.DB().Query(
|
||||||
|
`SELECT content FROM short_term_memories
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 3`,
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var memories []string
|
||||||
|
for rows.Next() {
|
||||||
|
var content string
|
||||||
|
if err := rows.Scan(&content); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
memories = append(memories, content)
|
||||||
|
}
|
||||||
|
return memories, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) GetLongTermMemory(query string) ([]struct {
|
||||||
|
ID int64
|
||||||
|
Content string
|
||||||
|
Weight float64
|
||||||
|
}, error) {
|
||||||
|
var results []struct {
|
||||||
|
ID int64
|
||||||
|
Content string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
if query != "" && mm.embedClient != nil {
|
||||||
|
embedding, err := mm.getEmbeddingWithCache(query)
|
||||||
|
if err == nil {
|
||||||
|
vecResults, err := mm.vectorStore.SearchLongTermSimilar(embedding, 5)
|
||||||
|
if err == nil && len(vecResults) > 0 {
|
||||||
|
for _, vr := range vecResults {
|
||||||
|
var content string
|
||||||
|
var weight float64
|
||||||
|
err := mm.store.DB().QueryRow(
|
||||||
|
"SELECT content, weight FROM long_term_memories WHERE id = ? AND archived = 0",
|
||||||
|
vr.MemoryID,
|
||||||
|
).Scan(&content, &weight)
|
||||||
|
if err == nil {
|
||||||
|
results = append(results, struct {
|
||||||
|
ID int64
|
||||||
|
Content string
|
||||||
|
Weight float64
|
||||||
|
}{ID: vr.MemoryID, Content: content, Weight: weight})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
rows, err := mm.store.DB().Query(
|
||||||
|
`SELECT id, content, weight FROM long_term_memories
|
||||||
|
WHERE archived = 0
|
||||||
|
ORDER BY weight DESC, access_count DESC
|
||||||
|
LIMIT 2`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var r struct {
|
||||||
|
ID int64
|
||||||
|
Content string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&r.ID, &r.Content, &r.Weight); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, r := range results {
|
||||||
|
mm.store.DB().Exec(
|
||||||
|
"UPDATE long_term_memories SET access_count = access_count + 1, last_accessed = ? WHERE id = ?",
|
||||||
|
now, r.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) loadMemoryContents(msgIDs []int64) ([]string, error) {
|
||||||
|
if len(msgIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders := make([]string, len(msgIDs))
|
||||||
|
args := make([]interface{}, len(msgIDs))
|
||||||
|
for i, id := range msgIDs {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
"SELECT content FROM main_messages WHERE id IN (%s)",
|
||||||
|
strings.Join(placeholders, ","),
|
||||||
|
)
|
||||||
|
rows, err := mm.store.DB().Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var contents []string
|
||||||
|
for rows.Next() {
|
||||||
|
var content string
|
||||||
|
if err := rows.Scan(&content); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contents = append(contents, content)
|
||||||
|
}
|
||||||
|
return contents, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) AddShortTermMemory(sessionID string, content string) error {
|
||||||
|
_, err := mm.store.DB().Exec(
|
||||||
|
`INSERT INTO short_term_memories (session_id, content, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(session_id, content) DO UPDATE SET
|
||||||
|
source_count = source_count + 1,
|
||||||
|
updated_at = ?`,
|
||||||
|
sessionID, content, time.Now(), time.Now(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) AddLongTermMemory(content string, memoryType string) error {
|
||||||
|
_, err := mm.store.DB().Exec(
|
||||||
|
`INSERT INTO long_term_memories (content, memory_type, confidence)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(content) DO UPDATE SET
|
||||||
|
access_count = access_count + 1,
|
||||||
|
confidence = MAX(confidence, ?)`,
|
||||||
|
content, memoryType, 0.8, 0.8,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) Cleanup() error {
|
||||||
|
_, err := mm.store.DB().Exec(
|
||||||
|
`DELETE FROM short_term_memories
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT id FROM short_term_memories
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateTokens(text string) int {
|
||||||
|
return len([]rune(text)) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseMessages(msgs []SessionMessage) {
|
||||||
|
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInterfaceSlice(strings []string) []interface{} {
|
||||||
|
result := make([]interface{}, len(strings))
|
||||||
|
for i, s := range strings {
|
||||||
|
result[i] = s
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryContextStats struct {
|
||||||
|
ShortTermCount int
|
||||||
|
LongTermCount int
|
||||||
|
TotalTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) BuildMemoryContextWithStats(sessionID string, query string) (string, MemoryContextStats) {
|
||||||
|
var parts []string
|
||||||
|
stats := MemoryContextStats{}
|
||||||
|
|
||||||
|
shortTerm, err := mm.GetShortTermMemory(sessionID, query)
|
||||||
|
if err == nil && len(shortTerm) > 0 {
|
||||||
|
parts = append(parts, "## 相关上下文\n"+strings.Join(shortTerm, "\n"))
|
||||||
|
stats.ShortTermCount = len(shortTerm)
|
||||||
|
for _, m := range shortTerm {
|
||||||
|
stats.TotalTokens += estimateTokens(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
longTerm, err := mm.GetLongTermMemory(query)
|
||||||
|
if err == nil && len(longTerm) > 0 {
|
||||||
|
var contents []string
|
||||||
|
for _, m := range longTerm {
|
||||||
|
contents = append(contents, m.Content)
|
||||||
|
mm.RecordMemoryUsage(m.ID, sessionID, query, true)
|
||||||
|
}
|
||||||
|
parts = append(parts, "## 背景知识\n"+strings.Join(contents, "\n"))
|
||||||
|
stats.LongTermCount = len(longTerm)
|
||||||
|
for _, m := range longTerm {
|
||||||
|
stats.TotalTokens += estimateTokens(m.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "", stats
|
||||||
|
}
|
||||||
|
|
||||||
|
return "## 记忆信息\n" + strings.Join(parts, "\n\n"), stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) BuildMemoryContext(sessionID string, query string) string {
|
||||||
|
ctx, _ := mm.BuildMemoryContextWithStats(sessionID, query)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) ShouldInjectMemory(sessionID string, query string) bool {
|
||||||
|
if sessionID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
msgCount, err := mm.getSessionMessageCount(sessionID)
|
||||||
|
if err != nil || msgCount == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(query) < 10 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) getSessionMessageCount(sessionID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := mm.store.DB().QueryRow(
|
||||||
|
"SELECT COUNT(*) FROM main_messages WHERE session_id = ?",
|
||||||
|
sessionID,
|
||||||
|
).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) MaintainSessionMemory(sessionID string, userQuery string, assistantResponse string) {
|
||||||
|
if len(assistantResponse) < 20 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := fmt.Sprintf("用户问:%s\n回答:%s", userQuery, truncateString(assistantResponse, 100))
|
||||||
|
if err := mm.AddShortTermMemory(sessionID, summary); err != nil {
|
||||||
|
log.Printf("[memory] Failed to add short-term memory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mm.bufferDialogue(sessionID, userQuery, assistantResponse)
|
||||||
|
|
||||||
|
if mm.shouldExtract(sessionID) {
|
||||||
|
mm.triggerExtraction(sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) shouldExtract(sessionID string) bool {
|
||||||
|
var count int
|
||||||
|
err := mm.store.DB().QueryRow(
|
||||||
|
"SELECT COUNT(*) FROM dialogue_buffer WHERE session_id = ?",
|
||||||
|
sessionID,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return count >= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) SetLLM(backend llm.LLM) {
|
||||||
|
mm.llmBackend = backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) triggerExtraction(sessionID string) {
|
||||||
|
dialogues, err := mm.FlushDialogueBuffer(sessionID)
|
||||||
|
if err != nil || len(dialogues) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mm.llmBackend == nil {
|
||||||
|
log.Printf("[memory] No LLM backend configured, skipping extraction for session=%s", sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
facts, err := mm.extractFacts(dialogues)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[memory] Extraction failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fact := range facts {
|
||||||
|
if fact.Confidence < 0.6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := mm.AddLongTermMemory(fact.Content, fact.Type); err != nil {
|
||||||
|
log.Printf("[memory] Failed to save long-term memory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) bufferDialogue(sessionID string, userQuery string, assistantResponse string) {
|
||||||
|
_, err := mm.store.DB().Exec(
|
||||||
|
"INSERT INTO dialogue_buffer (session_id, user_query, assistant_response) VALUES (?, ?, ?)",
|
||||||
|
sessionID, userQuery, assistantResponse,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[memory] Failed to buffer dialogue: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) FlushDialogueBuffer(sessionID string) ([]struct {
|
||||||
|
UserQuery string
|
||||||
|
AssistantResponse string
|
||||||
|
}, error) {
|
||||||
|
rows, err := mm.store.DB().Query(
|
||||||
|
"SELECT user_query, assistant_response FROM dialogue_buffer WHERE session_id = ? ORDER BY created_at ASC",
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var dialogues []struct {
|
||||||
|
UserQuery string
|
||||||
|
AssistantResponse string
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var d struct {
|
||||||
|
UserQuery string
|
||||||
|
AssistantResponse string
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&d.UserQuery, &d.AssistantResponse); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dialogues = append(dialogues, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dialogues) > 0 {
|
||||||
|
_, err = mm.store.DB().Exec("DELETE FROM dialogue_buffer WHERE session_id = ?", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[memory] Failed to clear dialogue buffer: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialogues, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateString(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) RecordMemoryUsage(memoryID int64, sessionID, query string, referenced bool) error {
|
||||||
|
_, err := mm.store.DB().Exec(
|
||||||
|
"INSERT INTO memory_usage_log (memory_id, session_id, query, was_referenced) VALUES (?, ?, ?, ?)",
|
||||||
|
memoryID, sessionID, query, referenced,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := 0.5
|
||||||
|
if !referenced {
|
||||||
|
delta = -0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = mm.store.DB().Exec(
|
||||||
|
"UPDATE long_term_memories SET weight = weight + ?, access_count = access_count + 1, last_accessed = ? WHERE id = ?",
|
||||||
|
delta, time.Now(), memoryID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) ArchiveLowWeightMemories(threshold float64) (int, error) {
|
||||||
|
result, err := mm.store.DB().Exec(
|
||||||
|
"UPDATE long_term_memories SET archived = 1 WHERE weight < ? AND archived = 0",
|
||||||
|
threshold,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
count, _ := result.RowsAffected()
|
||||||
|
return int(count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) GetCoreMemories(minWeight float64) ([]struct {
|
||||||
|
ID int64
|
||||||
|
Content string
|
||||||
|
Weight float64
|
||||||
|
}, error) {
|
||||||
|
rows, err := mm.store.DB().Query(
|
||||||
|
"SELECT id, content, weight FROM long_term_memories WHERE weight >= ? AND archived = 0 ORDER BY weight DESC",
|
||||||
|
minWeight,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var memories []struct {
|
||||||
|
ID int64
|
||||||
|
Content string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var m struct {
|
||||||
|
ID int64
|
||||||
|
Content string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&m.ID, &m.Content, &m.Weight); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
memories = append(memories, m)
|
||||||
|
}
|
||||||
|
return memories, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) CacheStats() (size int, hits, misses int64) {
|
||||||
|
mm.embedCacheMu.RLock()
|
||||||
|
defer mm.embedCacheMu.RUnlock()
|
||||||
|
return len(mm.embedCache), mm.embedCacheHit, mm.embedCacheMiss
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryExtractionPrompt = `# Memory Extractor
|
||||||
|
|
||||||
|
你是一个专门从对话中提取用户信息的助手。将非结构化的对话转化为结构化的长期记忆。
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
|
||||||
|
分析给定的对话记录,提取以下类型的信息:
|
||||||
|
|
||||||
|
1. **事实 (fact)**:客观信息
|
||||||
|
- 工作:公司、职位、技术栈、行业
|
||||||
|
- 技术:擅长语言、框架偏好、架构经验
|
||||||
|
- 个人:教育背景、所在城市(仅用户明确提及)
|
||||||
|
|
||||||
|
2. **偏好 (preference)**:主观倾向
|
||||||
|
- 回答风格:简洁/详细/代码示例/架构图
|
||||||
|
- 技术偏好:语言、数据库、部署方式
|
||||||
|
- 沟通偏好:正式/ casual
|
||||||
|
|
||||||
|
3. **项目 (project)**:当前工作
|
||||||
|
- 项目名称、技术方案、当前阶段、遇到的挑战
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
只输出 JSON,不要任何解释:
|
||||||
|
|
||||||
|
` + "```json" + `
|
||||||
|
{
|
||||||
|
"facts": [
|
||||||
|
{
|
||||||
|
"content": "用户在电商公司担任后端工程师",
|
||||||
|
"type": "fact",
|
||||||
|
"confidence": 0.95
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "用户偏好简洁的技术回答",
|
||||||
|
"type": "preference",
|
||||||
|
"confidence": 0.85
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
|
||||||
|
- confidence < 0.6 的事实不输出
|
||||||
|
- 不猜测、不推断,只提取用户明确表达的信息`
|
||||||
|
|
||||||
|
type extractedFact struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type extractionResult struct {
|
||||||
|
Facts []extractedFact `json:"facts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) extractFacts(dialogues []struct {
|
||||||
|
UserQuery string
|
||||||
|
AssistantResponse string
|
||||||
|
}) ([]extractedFact, error) {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(memoryExtractionPrompt)
|
||||||
|
sb.WriteString("\n\n## 对话记录\n\n")
|
||||||
|
for i, d := range dialogues {
|
||||||
|
sb.WriteString(fmt.Sprintf("--- 对话 %d ---\n", i+1))
|
||||||
|
sb.WriteString(fmt.Sprintf("用户:%s\n", d.UserQuery))
|
||||||
|
sb.WriteString(fmt.Sprintf("助手:%s\n\n", d.AssistantResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := []llm.Message{
|
||||||
|
{Role: "system", Content: "你是一个专门从对话中提取用户信息的助手。"},
|
||||||
|
{Role: "user", Content: sb.String()},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := mm.llmBackend.Chat(context.Background(), messages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("llm chat failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseExtractionJSON(resp.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExtractionJSON(content string) ([]extractedFact, error) {
|
||||||
|
content = extractJSONBlock(content)
|
||||||
|
|
||||||
|
var result extractionResult
|
||||||
|
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse extraction json failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Facts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractJSONBlock(content string) string {
|
||||||
|
if idx := strings.Index(content, "```json"); idx != -1 {
|
||||||
|
start := idx + 7
|
||||||
|
if end := strings.Index(content[start:], "```"); end != -1 {
|
||||||
|
return strings.TrimSpace(content[start : start+end])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx := strings.Index(content, "```"); idx != -1 {
|
||||||
|
start := idx + 3
|
||||||
|
if end := strings.Index(content[start:], "```"); end != -1 {
|
||||||
|
return strings.TrimSpace(content[start : start+end])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(content)
|
||||||
|
}
|
||||||
577
pkg/session/memory_test.go
Normal file
577
pkg/session/memory_test.go
Normal file
@ -0,0 +1,577 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/orca/orca/pkg/embedding"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupMemoryManager(t *testing.T) (*MemoryManager, func()) {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.MkdirTemp("", "orca-memory-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := MemoryConfig{
|
||||||
|
DBPath: filepath.Join(dir, "memory.db"),
|
||||||
|
ModelWindow: 8192,
|
||||||
|
EmbedConfig: embedding.Config{
|
||||||
|
APIKey: os.Getenv("SILICONFLOW_API_KEY"),
|
||||||
|
BaseURL: "https://api.siliconflow.cn/v1",
|
||||||
|
Model: "Pro/BAAI/bge-m3",
|
||||||
|
Timeout: 5000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mm, err := NewMemoryManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
t.Fatalf("NewMemoryManager failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
mm.Close()
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
return mm, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestSQLiteStore_SaveAndLoad(t *testing.T) {
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "test-session-1"
|
||||||
|
msg := SessionMessage{
|
||||||
|
Role: RoleUser,
|
||||||
|
Content: "你好,请介绍一下自己",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := mm.SaveMessage(sessionID, msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveMessage failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := mm.GetWorkingMemory(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkingMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d", len(messages))
|
||||||
|
}
|
||||||
|
if messages[0].Content != msg.Content {
|
||||||
|
t.Errorf("expected content %q, got %q", msg.Content, messages[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteStore_MultipleMessages(t *testing.T) {
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "test-session-multi"
|
||||||
|
baseTime := time.Now()
|
||||||
|
messages := []SessionMessage{
|
||||||
|
{Role: RoleUser, Content: "什么是机器学习?", Timestamp: baseTime},
|
||||||
|
{Role: RoleAssistant, Content: "机器学习是人工智能的一个分支...", Timestamp: baseTime.Add(time.Second)},
|
||||||
|
{Role: RoleUser, Content: "能举个例子吗?", Timestamp: baseTime.Add(2 * time.Second)},
|
||||||
|
{Role: RoleAssistant, Content: "比如垃圾邮件过滤器...", Timestamp: baseTime.Add(3 * time.Second)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
if err := mm.SaveMessage(sessionID, msg); err != nil {
|
||||||
|
t.Fatalf("SaveMessage failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := mm.GetWorkingMemory(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkingMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded) != len(messages) {
|
||||||
|
t.Fatalf("expected %d messages, got %d", len(messages), len(loaded))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, msg := range loaded {
|
||||||
|
if msg.Content != messages[i].Content {
|
||||||
|
t.Errorf("message %d: expected %q, got %q", i, messages[i].Content, msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestCalculateBudget(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
modelWindow int
|
||||||
|
wantTotal int
|
||||||
|
wantWorking int
|
||||||
|
}{
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
budget := calculateBudget(tt.modelWindow)
|
||||||
|
if budget.Total != tt.wantTotal {
|
||||||
|
t.Errorf("modelWindow=%d: expected total %d, got %d",
|
||||||
|
tt.modelWindow, tt.wantTotal, budget.Total)
|
||||||
|
}
|
||||||
|
if budget.Working != tt.wantWorking {
|
||||||
|
t.Errorf("modelWindow=%d: expected working %d, got %d",
|
||||||
|
tt.modelWindow, tt.wantWorking, budget.Working)
|
||||||
|
}
|
||||||
|
if budget.ShortTerm != int(float64(budget.Total)*0.3) {
|
||||||
|
t.Errorf("ShortTerm budget incorrect: %d", budget.ShortTerm)
|
||||||
|
}
|
||||||
|
if budget.LongTerm != int(float64(budget.Total)*0.2) {
|
||||||
|
t.Errorf("LongTerm budget incorrect: %d", budget.LongTerm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWorkingMemory_TokenBudget(t *testing.T) {
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "test-budget"
|
||||||
|
|
||||||
|
longContent := "这是一个很长的消息。" +
|
||||||
|
"重复多次以消耗token预算。" +
|
||||||
|
"重复多次以消耗token预算。" +
|
||||||
|
"重复多次以消耗token预算。" +
|
||||||
|
"重复多次以消耗token预算。" +
|
||||||
|
"重复多次以消耗token预算。" +
|
||||||
|
"重复多次以消耗token预算。" +
|
||||||
|
"重复多次以消耗token预算。" +
|
||||||
|
"重复多次以消耗token预算。" +
|
||||||
|
"重复多次以消耗token预算。"
|
||||||
|
|
||||||
|
messages := []SessionMessage{
|
||||||
|
{Role: RoleUser, Content: "第一条消息", Timestamp: time.Now()},
|
||||||
|
{Role: RoleAssistant, Content: longContent, Timestamp: time.Now()},
|
||||||
|
{Role: RoleUser, Content: "第三条消息", Timestamp: time.Now()},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
if err := mm.SaveMessage(sessionID, msg); err != nil {
|
||||||
|
t.Fatalf("SaveMessage failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get working memory with budget
|
||||||
|
loaded, err := mm.GetWorkingMemory(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkingMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should return messages within budget
|
||||||
|
// The long message should cause earlier messages to be excluded
|
||||||
|
t.Logf("Loaded %d messages within budget", len(loaded))
|
||||||
|
for i, msg := range loaded {
|
||||||
|
t.Logf("Message %d: role=%s, len=%d", i, msg.Role, len(msg.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestVectorStore_SaveAndSearch(t *testing.T) {
|
||||||
|
apiKey := os.Getenv("SILICONFLOW_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
t.Skip("Skipping vector test: SILICONFLOW_API_KEY not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "test-vectors"
|
||||||
|
|
||||||
|
messages := []SessionMessage{
|
||||||
|
{Role: RoleUser, Content: "Python 是什么编程语言?", Timestamp: time.Now()},
|
||||||
|
{Role: RoleAssistant, Content: "Python 是一种高级编程语言,以其简洁的语法而闻名", Timestamp: time.Now()},
|
||||||
|
{Role: RoleUser, Content: "Go 语言的特点是什么?", Timestamp: time.Now()},
|
||||||
|
{Role: RoleAssistant, Content: "Go 语言由 Google 开发,强调并发和性能", Timestamp: time.Now()},
|
||||||
|
{Role: RoleUser, Content: "今天天气怎么样?", Timestamp: time.Now()},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
if err := mm.SaveMessage(sessionID, msg); err != nil {
|
||||||
|
t.Fatalf("SaveMessage failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
results, err := mm.GetShortTermMemory(sessionID, "编程语言")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetShortTermMemory with vector search failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Vector search returned %d results", len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
t.Logf("Result %d: %s", i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVectorStore_CrossSessionSearch(t *testing.T) {
|
||||||
|
apiKey := os.Getenv("SILICONFLOW_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
t.Skip("Skipping vector test: SILICONFLOW_API_KEY not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
session1 := "session-ai"
|
||||||
|
for _, msg := range []SessionMessage{
|
||||||
|
{Role: RoleUser, Content: "什么是深度学习?", Timestamp: time.Now()},
|
||||||
|
{Role: RoleAssistant, Content: "深度学习是机器学习的一个子集,使用神经网络", Timestamp: time.Now()},
|
||||||
|
} {
|
||||||
|
if err := mm.SaveMessage(session1, msg); err != nil {
|
||||||
|
t.Fatalf("SaveMessage failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session2 := "session-cooking"
|
||||||
|
for _, msg := range []SessionMessage{
|
||||||
|
{Role: RoleUser, Content: "如何做红烧肉?", Timestamp: time.Now()},
|
||||||
|
{Role: RoleAssistant, Content: "红烧肉需要五花肉、酱油、糖等材料", Timestamp: time.Now()},
|
||||||
|
} {
|
||||||
|
if err := mm.SaveMessage(session2, msg); err != nil {
|
||||||
|
t.Fatalf("SaveMessage failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
results, err := mm.GetLongTermMemory("神经网络")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLongTermMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Cross-session search returned %d results", len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
t.Logf("Result %d: %s (weight=%.2f)", i, r.Content, r.Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestMaintainSessionMemory(t *testing.T) {
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "test-maintenance"
|
||||||
|
userQuery := "什么是REST API?"
|
||||||
|
assistantResponse := "REST API(Representational State Transfer)是一种软件架构风格,用于设计网络应用程序。它使用HTTP方法(GET、POST、PUT、DELETE)来操作资源。"
|
||||||
|
|
||||||
|
mm.MaintainSessionMemory(sessionID, userQuery, assistantResponse)
|
||||||
|
|
||||||
|
memories, err := mm.GetShortTermMemory(sessionID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetShortTermMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(memories) == 0 {
|
||||||
|
t.Fatal("Expected short-term memory to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Created %d short-term memories", len(memories))
|
||||||
|
for i, m := range memories {
|
||||||
|
t.Logf("Memory %d: %s", i, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddLongTermMemory(t *testing.T) {
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
memories := []struct {
|
||||||
|
content string
|
||||||
|
mType string
|
||||||
|
}{
|
||||||
|
{"用户喜欢使用Python进行数据分析", "preference"},
|
||||||
|
{"项目使用Go语言开发", "project"},
|
||||||
|
{"用户偏好简洁的代码风格", "preference"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range memories {
|
||||||
|
if err := mm.AddLongTermMemory(m.content, m.mType); err != nil {
|
||||||
|
t.Fatalf("AddLongTermMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := mm.GetLongTermMemory("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLongTermMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
t.Fatal("Expected long-term memories to be retrieved")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Retrieved %d long-term memories", len(results))
|
||||||
|
for i, r := range results {
|
||||||
|
t.Logf("Memory %d: %s (weight=%.2f)", i, r.Content, r.Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMemoryContext(t *testing.T) {
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "test-context"
|
||||||
|
|
||||||
|
mm.AddShortTermMemory(sessionID, "用户正在学习Go语言")
|
||||||
|
mm.AddShortTermMemory(sessionID, "用户之前问过关于goroutine的问题")
|
||||||
|
|
||||||
|
mm.AddLongTermMemory("用户是后端开发工程师", "fact")
|
||||||
|
mm.AddLongTermMemory("用户偏好技术文档", "preference")
|
||||||
|
|
||||||
|
context := mm.BuildMemoryContext(sessionID, "并发编程")
|
||||||
|
if context == "" {
|
||||||
|
t.Fatal("Expected memory context to be built")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Memory context:\n%s", context)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestMemoryManager_WithoutAPIKey(t *testing.T) {
|
||||||
|
dir, err := os.MkdirTemp("", "orca-memory-test-nokey-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
cfg := MemoryConfig{
|
||||||
|
DBPath: filepath.Join(dir, "memory.db"),
|
||||||
|
ModelWindow: 8192,
|
||||||
|
EmbedConfig: embedding.Config{
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mm, err := NewMemoryManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewMemoryManager should work without API key: %v", err)
|
||||||
|
}
|
||||||
|
defer mm.Close()
|
||||||
|
|
||||||
|
sessionID := "test-nokey"
|
||||||
|
msg := SessionMessage{
|
||||||
|
Role: RoleUser,
|
||||||
|
Content: "测试无API Key模式",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mm.SaveMessage(sessionID, msg); err != nil {
|
||||||
|
t.Fatalf("SaveMessage should work without API key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := mm.GetWorkingMemory(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkingMemory should work without API key: %v", err)
|
||||||
|
}
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d", len(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
memories, err := mm.GetShortTermMemory(sessionID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetShortTermMemory should fallback to SQL: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("Short-term memories (no API key): %d", len(memories))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestCleanup(t *testing.T) {
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "test-cleanup"
|
||||||
|
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
content := fmt.Sprintf("Short-term memory %d", i)
|
||||||
|
if err := mm.AddShortTermMemory(sessionID, content); err != nil {
|
||||||
|
t.Fatalf("AddShortTermMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mm.Cleanup(); err != nil {
|
||||||
|
t.Fatalf("Cleanup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memories, err := mm.GetShortTermMemory(sessionID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetShortTermMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(memories) > 10 {
|
||||||
|
t.Errorf("Expected at most 10 memories after cleanup, got %d", len(memories))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestEstimateTokens(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{"", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := estimateTokens(tt.input)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("estimateTokens(%q) = %d, want %d", tt.input, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverseMessages(t *testing.T) {
|
||||||
|
msgs := []SessionMessage{
|
||||||
|
{Content: "first"},
|
||||||
|
{Content: "second"},
|
||||||
|
{Content: "third"},
|
||||||
|
}
|
||||||
|
|
||||||
|
reverseMessages(msgs)
|
||||||
|
|
||||||
|
expected := []string{"third", "second", "first"}
|
||||||
|
for i, msg := range msgs {
|
||||||
|
if msg.Content != expected[i] {
|
||||||
|
t.Errorf("reverseMessages: position %d expected %q, got %q",
|
||||||
|
i, expected[i], msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
maxLen int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"hello", 10, "hello"},
|
||||||
|
{"hello world", 5, "hello..."},
|
||||||
|
{"", 5, ""},
|
||||||
|
{"short", 5, "short"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := truncateString(tt.input, tt.maxLen)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("truncateString(%q, %d) = %q, want %q",
|
||||||
|
tt.input, tt.maxLen, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestFullConversationFlow(t *testing.T) {
|
||||||
|
mm, cleanup := setupMemoryManager(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "test-conversation"
|
||||||
|
|
||||||
|
conversation := []struct {
|
||||||
|
role MessageRole
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{RoleUser, "你好,我想学习Go语言"},
|
||||||
|
{RoleAssistant, "Go语言是一种由Google开发的开源编程语言,以其简洁、高效和强大的并发支持而闻名。它特别适合构建网络服务和分布式系统。"},
|
||||||
|
{RoleUser, "Go语言的并发是怎么实现的?"},
|
||||||
|
{RoleAssistant, "Go语言使用goroutine和channel实现并发。Goroutine是轻量级线程,由Go运行时管理。Channel用于goroutine之间的通信和同步。"},
|
||||||
|
{RoleUser, "能推荐一些学习资源吗?"},
|
||||||
|
{RoleAssistant, "推荐以下学习资源:1. Go官方文档 2. 《Go程序设计语言》 3. Go by Example 网站"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range conversation {
|
||||||
|
sessionMsg := SessionMessage{
|
||||||
|
Role: msg.role,
|
||||||
|
Content: msg.content,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
if err := mm.SaveMessage(sessionID, sessionMsg); err != nil {
|
||||||
|
t.Fatalf("SaveMessage failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workingMem, err := mm.GetWorkingMemory(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWorkingMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("Working memory: %d messages", len(workingMem))
|
||||||
|
|
||||||
|
for i := 1; i < len(conversation); i += 2 {
|
||||||
|
mm.MaintainSessionMemory(sessionID, conversation[i-1].content, conversation[i].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
shortTerm, err := mm.GetShortTermMemory(sessionID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetShortTermMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("Short-term memories: %d", len(shortTerm))
|
||||||
|
for i, mem := range shortTerm {
|
||||||
|
t.Logf(" Memory %d: %s", i, mem)
|
||||||
|
}
|
||||||
|
|
||||||
|
context := mm.BuildMemoryContext(sessionID, "学习资源")
|
||||||
|
if context != "" {
|
||||||
|
t.Logf("Memory context for '学习资源':\n%s", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func BenchmarkSaveMessage(b *testing.B) {
|
||||||
|
mm, cleanup := setupMemoryManager(&testing.T{})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "bench-session"
|
||||||
|
msg := SessionMessage{
|
||||||
|
Role: RoleUser,
|
||||||
|
Content: "这是一条测试消息",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if err := mm.SaveMessage(sessionID, msg); err != nil {
|
||||||
|
b.Fatalf("SaveMessage failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkGetWorkingMemory(b *testing.B) {
|
||||||
|
mm, cleanup := setupMemoryManager(&testing.T{})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "bench-session"
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
msg := SessionMessage{
|
||||||
|
Role: RoleUser,
|
||||||
|
Content: fmt.Sprintf("Message %d", i),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
mm.SaveMessage(sessionID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if _, err := mm.GetWorkingMemory(sessionID); err != nil {
|
||||||
|
b.Fatalf("GetWorkingMemory failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkBuildMemoryContext(b *testing.B) {
|
||||||
|
mm, cleanup := setupMemoryManager(&testing.T{})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sessionID := "bench-context"
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
mm.AddShortTermMemory(sessionID, fmt.Sprintf("Memory %d", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = mm.BuildMemoryContext(sessionID, "test query")
|
||||||
|
}
|
||||||
|
}
|
||||||
368
pkg/session/sqlite_store.go
Normal file
368
pkg/session/sqlite_store.go
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
_ "modernc.org/sqlite/vec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SQLiteStore struct {
|
||||||
|
dbPath string
|
||||||
|
db *sql.DB
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制连接池为单连接,避免 SQLite 并发冲突
|
||||||
|
// WAL 模式下支持并发读,但写操作仍需串行化
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(0)
|
||||||
|
|
||||||
|
store := &SQLiteStore{
|
||||||
|
dbPath: dbPath,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.initSchema(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) initSchema() error {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS main_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
msg_type TEXT DEFAULT 'normal' CHECK(msg_type IN ('normal', 'fact', 'todo', 'decision', 'preference', 'error')),
|
||||||
|
token_count INTEGER DEFAULT 0,
|
||||||
|
has_embedding BOOLEAN DEFAULT FALSE,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
metadata TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_main_session_time ON main_messages(session_id, timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_main_role ON main_messages(role) WHERE role IN ('user', 'assistant');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'archived')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
metadata TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS short_term_memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
source_count INTEGER DEFAULT 1,
|
||||||
|
confidence REAL DEFAULT 0.8 CHECK(confidence BETWEEN 0 AND 1),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME,
|
||||||
|
UNIQUE(session_id, content)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stm_session ON short_term_memories(session_id, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS long_term_memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT NOT NULL UNIQUE,
|
||||||
|
memory_type TEXT NOT NULL DEFAULT 'fact' CHECK(memory_type IN ('preference', 'fact', 'decision', 'project')),
|
||||||
|
source_session TEXT,
|
||||||
|
confidence REAL NOT NULL DEFAULT 0.8,
|
||||||
|
weight REAL NOT NULL DEFAULT 1.0,
|
||||||
|
tags TEXT,
|
||||||
|
access_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_accessed DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ltm_type ON long_term_memories(memory_type, confidence DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ltm_weight ON long_term_memories(weight DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ltm_archived ON long_term_memories(archived);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS vec_long_term_memories USING vec0(
|
||||||
|
memory_id INTEGER PRIMARY KEY,
|
||||||
|
embedding FLOAT[1024]
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_usage_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
memory_id INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
was_referenced INTEGER NOT NULL DEFAULT 0,
|
||||||
|
used_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_memory ON memory_usage_log(memory_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_session ON memory_usage_log(session_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dialogue_buffer (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
user_query TEXT NOT NULL,
|
||||||
|
assistant_response TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_buffer_session ON dialogue_buffer(session_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS subagent_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
parent_session_id TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
agent_name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('assistant', 'system', 'user')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subagent_parent ON subagent_messages(parent_session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subagent_session ON subagent_messages(session_id);
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := s.db.Exec(schema)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) Save(sessionID string, msg SessionMessage) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`INSERT INTO sessions (id, updated_at) VALUES (?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET updated_at = ?`,
|
||||||
|
sessionID, msg.Timestamp, msg.Timestamp,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to upsert session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataJSON := "{}"
|
||||||
|
if len(msg.Metadata) > 0 {
|
||||||
|
metadataJSON = fmt.Sprintf("%v", msg.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
`INSERT INTO main_messages (session_id, role, content, timestamp, metadata)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
sessionID, string(msg.Role), msg.Content, msg.Timestamp, metadataJSON,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) Load(sessionID string) ([]SessionMessage, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var status string
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
"SELECT status FROM sessions WHERE id = ?",
|
||||||
|
sessionID,
|
||||||
|
).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("session %q not found", sessionID)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to query session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT role, content, timestamp, metadata FROM main_messages
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY timestamp ASC, id ASC`,
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query messages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var messages []SessionMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var msg SessionMessage
|
||||||
|
var timestampStr string
|
||||||
|
var metadataStr string
|
||||||
|
|
||||||
|
if err := rows.Scan(&msg.Role, &msg.Content, ×tampStr, &metadataStr); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Timestamp, _ = time.Parse(time.RFC3339, timestampStr)
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) List() ([]string, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
"SELECT id FROM sessions WHERE status = 'active' ORDER BY updated_at DESC",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query sessions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sessions []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan session: %w", err)
|
||||||
|
}
|
||||||
|
sessions = append(sessions, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) Exists(sessionID string) (bool, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
"SELECT COUNT(*) FROM sessions WHERE id = ?",
|
||||||
|
sessionID,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) Archive(sessionID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
result, err := s.db.Exec(
|
||||||
|
"UPDATE sessions SET status = 'archived' WHERE id = ?",
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to archive session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return fmt.Errorf("session %q not found", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) Delete(sessionID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
_, err = tx.Exec("DELETE FROM main_messages WHERE session_id = ?", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec("DELETE FROM sessions WHERE id = ?", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) SaveSubAgentMessage(parentSessionID, sessionID, agentName string, msg SessionMessage) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO subagent_messages (parent_session_id, session_id, agent_name, role, content, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
parentSessionID, sessionID, agentName, string(msg.Role), msg.Content, msg.Timestamp,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save subagent message: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) LoadSubAgentMessages(sessionID string) ([]SessionMessage, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT role, content, timestamp FROM subagent_messages
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY timestamp ASC, id ASC`,
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query subagent messages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var messages []SessionMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var msg SessionMessage
|
||||||
|
var timestampStr string
|
||||||
|
if err := rows.Scan(&msg.Role, &msg.Content, ×tampStr); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan subagent message: %w", err)
|
||||||
|
}
|
||||||
|
msg.Timestamp, _ = time.Parse(time.RFC3339, timestampStr)
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
return messages, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) DB() *sql.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
196
pkg/session/vector_store.go
Normal file
196
pkg/session/vector_store.go
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VectorStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVectorStore(db *sql.DB) (*VectorStore, error) {
|
||||||
|
vs := &VectorStore{db: db}
|
||||||
|
if err := vs.initSchema(); err != nil {
|
||||||
|
return &VectorStore{db: db, enabled: false}, nil
|
||||||
|
}
|
||||||
|
vs.enabled = true
|
||||||
|
return vs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs *VectorStore) initSchema() error {
|
||||||
|
_, err := vs.db.Exec(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS vec_main_messages USING vec0(
|
||||||
|
msg_id INTEGER PRIMARY KEY,
|
||||||
|
embedding FLOAT[1024]
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs *VectorStore) SaveEmbedding(msgID int64, embedding []float32) error {
|
||||||
|
if !vs.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embedding) != 1024 {
|
||||||
|
return fmt.Errorf("expected 1024 dimensions, got %d", len(embedding))
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddingStr := formatEmbedding(embedding)
|
||||||
|
_, err := vs.db.Exec(
|
||||||
|
"INSERT INTO vec_main_messages (msg_id, embedding) VALUES (?, ?)",
|
||||||
|
msgID, embeddingStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save embedding: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = vs.db.Exec(
|
||||||
|
"UPDATE main_messages SET has_embedding = TRUE WHERE id = ?",
|
||||||
|
msgID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs *VectorStore) SaveLongTermEmbedding(memoryID int64, embedding []float32) error {
|
||||||
|
if !vs.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embedding) != 1024 {
|
||||||
|
return fmt.Errorf("expected 1024 dimensions, got %d", len(embedding))
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddingStr := formatEmbedding(embedding)
|
||||||
|
_, err := vs.db.Exec(
|
||||||
|
"INSERT INTO vec_long_term_memories (memory_id, embedding) VALUES (?, ?)",
|
||||||
|
memoryID, embeddingStr,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save long-term embedding: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs *VectorStore) SearchLongTermSimilar(embedding []float32, limit int) ([]struct {
|
||||||
|
MemoryID int64
|
||||||
|
Distance float64
|
||||||
|
}, error) {
|
||||||
|
if !vs.enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embedding) != 1024 {
|
||||||
|
return nil, fmt.Errorf("expected 1024 dimensions, got %d", len(embedding))
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddingStr := formatEmbedding(embedding)
|
||||||
|
rows, err := vs.db.Query(
|
||||||
|
`SELECT memory_id, distance FROM vec_long_term_memories
|
||||||
|
WHERE embedding MATCH ?
|
||||||
|
ORDER BY distance
|
||||||
|
LIMIT ?`,
|
||||||
|
embeddingStr, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search long-term vectors: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []struct {
|
||||||
|
MemoryID int64
|
||||||
|
Distance float64
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var r struct {
|
||||||
|
MemoryID int64
|
||||||
|
Distance float64
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&r.MemoryID, &r.Distance); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs *VectorStore) SearchSimilar(embedding []float32, limit int) ([]int64, error) {
|
||||||
|
if !vs.enabled {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embedding) != 1024 {
|
||||||
|
return nil, fmt.Errorf("expected 1024 dimensions, got %d", len(embedding))
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddingStr := formatEmbedding(embedding)
|
||||||
|
rows, err := vs.db.Query(
|
||||||
|
`SELECT msg_id FROM vec_main_messages
|
||||||
|
WHERE embedding MATCH ?
|
||||||
|
ORDER BY distance
|
||||||
|
LIMIT ?`,
|
||||||
|
embeddingStr, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search vectors: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var msgIDs []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgIDs = append(msgIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgIDs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vs *VectorStore) SearchSimilarInSession(sessionID string, embedding []float32, limit int) ([]int64, error) {
|
||||||
|
if !vs.enabled {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embedding) != 1024 {
|
||||||
|
return nil, fmt.Errorf("expected 1024 dimensions, got %d", len(embedding))
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddingStr := formatEmbedding(embedding)
|
||||||
|
rows, err := vs.db.Query(
|
||||||
|
`SELECT v.msg_id FROM vec_main_messages v
|
||||||
|
JOIN main_messages m ON v.msg_id = m.id
|
||||||
|
WHERE m.session_id = ? AND v.embedding MATCH ?
|
||||||
|
ORDER BY distance
|
||||||
|
LIMIT ?`,
|
||||||
|
sessionID, embeddingStr, limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search session vectors: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var msgIDs []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgIDs = append(msgIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgIDs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatEmbedding(embedding []float32) string {
|
||||||
|
parts := make([]string, len(embedding))
|
||||||
|
for i, v := range embedding {
|
||||||
|
parts[i] = fmt.Sprintf("%f", v)
|
||||||
|
}
|
||||||
|
return "[" + strings.Join(parts, ",") + "]"
|
||||||
|
}
|
||||||
@ -3,22 +3,47 @@ package tool
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
"github.com/orca/orca/pkg/bus"
|
"github.com/orca/orca/pkg/bus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AgentStreamWriter struct {
|
||||||
|
agentName string
|
||||||
|
eventBus bus.MessageBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *AgentStreamWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if w.eventBus != nil {
|
||||||
|
w.eventBus.Publish("agent_events", bus.Message{
|
||||||
|
Type: bus.MsgTypeLog,
|
||||||
|
Content: map[string]interface{}{
|
||||||
|
"event": "token",
|
||||||
|
"agent": w.agentName,
|
||||||
|
"text": string(p),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
type Agent interface {
|
type Agent interface {
|
||||||
Process(ctx context.Context, msg bus.Message) (bus.Message, error)
|
Process(ctx context.Context, msg bus.Message) (bus.Message, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type agentCallTool struct {
|
type agentCallTool struct {
|
||||||
agentRegistry func(string) (Agent, bool)
|
agentRegistry func(string) (Agent, bool)
|
||||||
|
eventBus bus.MessageBus
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgentCallTool(registry func(string) (Agent, bool)) Tool {
|
func NewAgentCallTool(registry func(string) (Agent, bool)) Tool {
|
||||||
return &agentCallTool{agentRegistry: registry}
|
return &agentCallTool{agentRegistry: registry}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *agentCallTool) SetEventBus(mb bus.MessageBus) {
|
||||||
|
t.eventBus = mb
|
||||||
|
}
|
||||||
|
|
||||||
func (t *agentCallTool) Name() string { return "agent_call" }
|
func (t *agentCallTool) Name() string { return "agent_call" }
|
||||||
|
|
||||||
func (t *agentCallTool) Description() string {
|
func (t *agentCallTool) Description() string {
|
||||||
@ -63,7 +88,39 @@ func (t *agentCallTool) Execute(ctx context.Context, args map[string]interface{}
|
|||||||
Content: task,
|
Content: task,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.eventBus != nil {
|
||||||
|
t.eventBus.Publish("agent_events", bus.Message{
|
||||||
|
Type: bus.MsgTypeToolCall,
|
||||||
|
From: "llm",
|
||||||
|
To: agentName,
|
||||||
|
Content: map[string]interface{}{"event": "start", "agent": agentName, "task": task},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if setter, ok := agent.(interface{ SetStreamWriter(io.Writer) }); ok && t.eventBus != nil {
|
||||||
|
writer := &AgentStreamWriter{agentName: agentName, eventBus: t.eventBus}
|
||||||
|
setter.SetStreamWriter(writer)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := agent.Process(ctx, msg)
|
resp, err := agent.Process(ctx, msg)
|
||||||
|
|
||||||
|
if t.eventBus != nil {
|
||||||
|
status := "completed"
|
||||||
|
if err != nil {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
resultContent := ""
|
||||||
|
if resp.Content != nil {
|
||||||
|
resultContent = fmt.Sprintf("%v", resp.Content)
|
||||||
|
}
|
||||||
|
t.eventBus.Publish("agent_events", bus.Message{
|
||||||
|
Type: bus.MsgTypeToolResult,
|
||||||
|
From: agentName,
|
||||||
|
To: "llm",
|
||||||
|
Content: map[string]interface{}{"event": "end", "agent": agentName, "status": status, "result": resultContent},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("agent '%s' execution failed: %v", agentName, err)), nil
|
return ErrorResult(fmt.Sprintf("agent '%s' execution failed: %v", agentName, err)), nil
|
||||||
}
|
}
|
||||||
|
|||||||
11
server.log
Normal file
11
server.log
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
2026/05/10 20:16:21 kernel: created DeepSeek client (model=deepseek-v4-flash)
|
||||||
|
2026/05/10 20:16:21 kernel: created sub-agent "architect" from /Users/wang/.orca/prompts/architect.md
|
||||||
|
2026/05/10 20:16:21 kernel: created sub-agent "calculator" from /Users/wang/.orca/prompts/calculator.md
|
||||||
|
2026/05/10 20:16:21 kernel: created sub-agent "coder" from /Users/wang/.orca/prompts/coder.md
|
||||||
|
2026/05/10 20:16:21 kernel: created sub-agent "reviewer" from /Users/wang/.orca/prompts/reviewer.md
|
||||||
|
2026/05/10 20:16:21 kernel: created 4 sub-agents
|
||||||
|
2026/05/10 20:16:21 kernel: started (tools=6)
|
||||||
|
2026/05/10 20:16:21 kernel: warning: skill loading had errors: skill: loaded 8 skills with 1 errors: art-design-pro: skill: "/Users/wang/.agents/skills/art-design-pro/SKILL.md" is missing 'name' in frontmatter
|
||||||
|
2026/05/10 20:16:21 kernel: loaded 8 skills
|
||||||
|
Starting web server on http://localhost:8081
|
||||||
|
2026/05/10 20:16:21 WebSocket server starting on http://localhost:8081
|
||||||
17
test.md
17
test.md
@ -1,17 +0,0 @@
|
|||||||
## 测试文件
|
|
||||||
|
|
||||||
这是一个Markdown格式的测试文件。
|
|
||||||
|
|
||||||
- 列表项1
|
|
||||||
- 列表项2
|
|
||||||
- 列表项3
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
fmt.Println("Hello, orca.ai!")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
BIN
test_clean
Executable file
BIN
test_clean
Executable file
Binary file not shown.
BIN
test_memory
Executable file
BIN
test_memory
Executable file
Binary file not shown.
227
test_memory_retrieval.go
Normal file
227
test_memory_retrieval.go
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/orca/orca/pkg/bus"
|
||||||
|
"github.com/orca/orca/pkg/kernel"
|
||||||
|
"github.com/orca/orca/pkg/actor"
|
||||||
|
"github.com/orca/orca/pkg/session"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
_ "modernc.org/sqlite/vec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("=== 记忆系统综合测试 ===")
|
||||||
|
fmt.Println("测试内容:记忆检索、命中率、token节省、日常使用")
|
||||||
|
|
||||||
|
k := kernel.New()
|
||||||
|
if err := k.Start(); err != nil {
|
||||||
|
fmt.Printf("启动失败: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer k.Stop()
|
||||||
|
|
||||||
|
orch := k.Orchestrator()
|
||||||
|
mm := k.MemoryManager()
|
||||||
|
|
||||||
|
// 阶段1: 建立用户画像(触发长期记忆提取)
|
||||||
|
fmt.Println("\n--- 阶段1: 建立用户画像 ---")
|
||||||
|
sendMessage(k, "你好,我叫李四,我在金融科技公司做架构师")
|
||||||
|
sendMessage(k, "我主要用Java和Kotlin,最近在研究微服务拆分")
|
||||||
|
sendMessage(k, "我喜欢详细的技术解释,带架构图最好")
|
||||||
|
sendMessage(k, "现在负责支付系统的重构,从单体迁移到微服务")
|
||||||
|
sendMessage(k, "团队有10个人,前端用React,后端用Spring Boot")
|
||||||
|
|
||||||
|
// 等待长期记忆提取
|
||||||
|
fmt.Println("\n等待长期记忆提取 (15秒)...")
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
|
||||||
|
// 阶段2: 子Agent调用(测试隔离)
|
||||||
|
fmt.Println("\n--- 阶段2: 子Agent调用测试 ---")
|
||||||
|
callSubAgent(orch, "coder", "写一个JWT认证的工具类,Java实现")
|
||||||
|
callSubAgent(orch, "reviewer", "审查代码:public class Auth { public String token; }")
|
||||||
|
|
||||||
|
// 阶段3: 查询记忆(测试检索命中率)
|
||||||
|
fmt.Println("\n--- 阶段3: 记忆检索测试 ---")
|
||||||
|
fmt.Println("\n查询1: 询问技术偏好(应命中长期记忆)")
|
||||||
|
sendMessage(k, "你觉得Java和Go哪个更适合做支付系统?")
|
||||||
|
|
||||||
|
fmt.Println("\n查询2: 询问团队信息(应命中长期记忆)")
|
||||||
|
sendMessage(k, "我们团队前端用什么框架比较好?")
|
||||||
|
|
||||||
|
fmt.Println("\n查询3: 询问个人背景(应命中长期记忆)")
|
||||||
|
sendMessage(k, "你能根据我的背景给些微服务拆分的建议吗?")
|
||||||
|
|
||||||
|
fmt.Println("\n查询4: 无关查询(测试未命中情况)")
|
||||||
|
sendMessage(k, "今天天气怎么样?")
|
||||||
|
|
||||||
|
// 阶段4: 等待并检查统计
|
||||||
|
fmt.Println("\n--- 阶段4: 等待并收集统计 (5秒) ---")
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
|
// 阶段5: 详细统计
|
||||||
|
fmt.Println("\n--- 阶段5: 详细统计 ---")
|
||||||
|
printDetailedStats(mm)
|
||||||
|
checkDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(k *kernel.Kernel, content string) string {
|
||||||
|
resp, err := k.SendMessage("user", "llm", content)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" 发送失败: %v\n", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
fmt.Printf(" 回复: %s\n", truncate(resp, 150))
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func callSubAgent(orch *actor.Orchestrator, agentName, task string) {
|
||||||
|
msg := bus.Message{
|
||||||
|
Type: bus.MsgTypeTaskRequest,
|
||||||
|
From: "user",
|
||||||
|
To: agentName,
|
||||||
|
Content: task,
|
||||||
|
}
|
||||||
|
resp, err := orch.Process(context.Background(), msg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %s 调用失败: %v\n", agentName, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" %s 回复: %s\n", agentName, truncate(fmt.Sprintf("%v", resp.Content), 150))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printDetailedStats(mm *session.MemoryManager) {
|
||||||
|
if mm == nil {
|
||||||
|
fmt.Println("MemoryManager 未初始化")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embedding缓存统计
|
||||||
|
cacheSize, cacheHits, cacheMisses := mm.CacheStats()
|
||||||
|
total := cacheHits + cacheMisses
|
||||||
|
hitRate := float64(0)
|
||||||
|
if total > 0 {
|
||||||
|
hitRate = float64(cacheHits) * 100 / float64(total)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n[Embedding缓存统计]\n")
|
||||||
|
fmt.Printf(" 缓存大小: %d\n", cacheSize)
|
||||||
|
fmt.Printf(" 命中次数: %d\n", cacheHits)
|
||||||
|
fmt.Printf(" 未命中次数: %d\n", cacheMisses)
|
||||||
|
fmt.Printf(" 命中率: %.1f%%\n", hitRate)
|
||||||
|
|
||||||
|
// 记忆上下文统计(模拟查询)
|
||||||
|
queries := []string{
|
||||||
|
"Java技术栈",
|
||||||
|
"团队规模",
|
||||||
|
"微服务拆分",
|
||||||
|
"前端框架",
|
||||||
|
"天气",
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n[记忆检索测试 - %d个查询]\n", len(queries))
|
||||||
|
totalMemories := 0
|
||||||
|
referencedMemories := 0
|
||||||
|
for _, q := range queries {
|
||||||
|
ctx, stats := mm.BuildMemoryContextWithStats("default", q)
|
||||||
|
hasMemory := ctx != ""
|
||||||
|
fmt.Printf(" 查询 '%s': 短期=%d, 长期=%d, tokens=%d, 有记忆=%v\n",
|
||||||
|
q, stats.ShortTermCount, stats.LongTermCount, stats.TotalTokens, hasMemory)
|
||||||
|
if hasMemory {
|
||||||
|
totalMemories++
|
||||||
|
if stats.LongTermCount > 0 {
|
||||||
|
referencedMemories++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf(" 记忆命中率: %d/%d (%.0f%%)\n", referencedMemories, len(queries), float64(referencedMemories)*100/float64(len(queries)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDatabase() {
|
||||||
|
dbPath := os.ExpandEnv("$HOME/.orca/sessions/orcasession.db")
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("打开数据库失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
fmt.Println("\n=== 数据库统计 ===")
|
||||||
|
|
||||||
|
// 表统计
|
||||||
|
fmt.Println("\n--- 各表记录数 ---")
|
||||||
|
tables := []string{"main_messages", "short_term_memories", "long_term_memories", "dialogue_buffer", "subagent_messages", "memory_usage_log"}
|
||||||
|
for _, table := range tables {
|
||||||
|
var count int
|
||||||
|
db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&count)
|
||||||
|
fmt.Printf(" %s: %d\n", table, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 长期记忆详情
|
||||||
|
fmt.Println("\n--- 长期记忆详情 ---")
|
||||||
|
rows, _ := db.Query(`
|
||||||
|
SELECT id, substr(content, 1, 50), memory_type, weight, confidence, access_count, created_at
|
||||||
|
FROM long_term_memories
|
||||||
|
ORDER BY id DESC
|
||||||
|
`)
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var content, mtype string
|
||||||
|
var weight, confidence float64
|
||||||
|
var accessCount int
|
||||||
|
var createdAt string
|
||||||
|
rows.Scan(&id, &content, &mtype, &weight, &confidence, &accessCount, &createdAt)
|
||||||
|
fmt.Printf(" [#%d %s] weight=%.2f conf=%.2f access=%d | %s\n", id, mtype, weight, confidence, accessCount, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// memory_usage_log 详情
|
||||||
|
fmt.Println("\n--- 记忆使用日志 ---")
|
||||||
|
rows2, _ := db.Query(`
|
||||||
|
SELECT memory_id, substr(query, 1, 30), was_referenced, used_at
|
||||||
|
FROM memory_usage_log
|
||||||
|
ORDER BY id DESC
|
||||||
|
`)
|
||||||
|
defer rows2.Close()
|
||||||
|
count := 0
|
||||||
|
for rows2.Next() {
|
||||||
|
var memID int
|
||||||
|
var query string
|
||||||
|
var referenced int
|
||||||
|
var usedAt string
|
||||||
|
rows2.Scan(&memID, &query, &referenced, &usedAt)
|
||||||
|
fmt.Printf(" memory_id=%d query='%s' referenced=%v at=%s\n", memID, query, referenced == 1, usedAt)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
fmt.Println(" (空)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子Agent统计
|
||||||
|
fmt.Println("\n--- 子Agent消息统计 ---")
|
||||||
|
rows3, _ := db.Query(`SELECT agent_name, COUNT(*) FROM subagent_messages GROUP BY agent_name`)
|
||||||
|
defer rows3.Close()
|
||||||
|
for rows3.Next() {
|
||||||
|
var agent string
|
||||||
|
var cnt int
|
||||||
|
rows3.Scan(&agent, &cnt)
|
||||||
|
fmt.Printf(" %s: %d\n", agent, cnt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话缓冲
|
||||||
|
fmt.Println("\n--- 对话缓冲 ---")
|
||||||
|
var bufCount int
|
||||||
|
db.QueryRow("SELECT COUNT(*) FROM dialogue_buffer").Scan(&bufCount)
|
||||||
|
fmt.Printf(" 当前缓冲条数: %d\n", bufCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max] + "..."
|
||||||
|
}
|
||||||
247
test_memory_system.go
Normal file
247
test_memory_system.go
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/orca/orca/pkg/bus"
|
||||||
|
"github.com/orca/orca/pkg/kernel"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
_ "modernc.org/sqlite/vec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("=== 子Agent调用 + 记忆系统测试 ===")
|
||||||
|
|
||||||
|
k := kernel.New()
|
||||||
|
if err := k.Start(); err != nil {
|
||||||
|
fmt.Printf("启动失败: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer k.Stop()
|
||||||
|
|
||||||
|
orch := k.Orchestrator()
|
||||||
|
mm := k.MemoryManager()
|
||||||
|
|
||||||
|
fmt.Println("\n1. 发送普通消息给 LLM...")
|
||||||
|
resp1, err := k.SendMessage("user", "llm", "你好,我叫张三,我喜欢用Go语言写后端服务")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("发送失败: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("回复: %s\n", truncate(resp1, 200))
|
||||||
|
|
||||||
|
fmt.Println("\n2. 发送第二条消息给 LLM...")
|
||||||
|
resp2, err := k.SendMessage("user", "llm", "你觉得Go和Python哪个更适合做Web后端?")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("发送失败: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("回复: %s\n", truncate(resp2, 200))
|
||||||
|
|
||||||
|
fmt.Println("\n3. 直接调用 coder 子Agent...")
|
||||||
|
msg := bus.Message{
|
||||||
|
Type: bus.MsgTypeTaskRequest,
|
||||||
|
From: "user",
|
||||||
|
To: "coder",
|
||||||
|
Content: "请写一个快速排序算法,用Go语言实现",
|
||||||
|
}
|
||||||
|
resp3, err := orch.Process(context.Background(), msg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("coder 调用失败: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("coder 回复: %s\n", truncate(fmt.Sprintf("%v", resp3.Content), 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n4. 直接调用 reviewer 子Agent...")
|
||||||
|
msg2 := bus.Message{
|
||||||
|
Type: bus.MsgTypeTaskRequest,
|
||||||
|
From: "user",
|
||||||
|
To: "reviewer",
|
||||||
|
Content: "请审查以下代码的质量:func main() { fmt.Println(\"hello\") }",
|
||||||
|
}
|
||||||
|
resp4, err := orch.Process(context.Background(), msg2)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("reviewer 调用失败: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("reviewer 回复: %s\n", truncate(fmt.Sprintf("%v", resp4.Content), 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n5. 发送第三条消息(继续对话,积累dialogue_buffer)...")
|
||||||
|
resp5, err := k.SendMessage("user", "llm", "我在一家电商公司做后端开发,平时用Go处理高并发订单系统")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("发送失败: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("回复: %s\n", truncate(resp5, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n6. 发送第四条消息(继续对话,触发长期记忆提取阈值)...")
|
||||||
|
resp6, err := k.SendMessage("user", "llm", "我希望回答简洁一些,直接给代码示例,不要太多解释")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("发送失败: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("回复: %s\n", truncate(resp6, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n7. 发送第五条消息(超过5条阈值,应触发自动提取)...")
|
||||||
|
resp7, err := k.SendMessage("user", "llm", "最近在做一个库存管理模块,用Redis做缓存,MySQL做持久化")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("发送失败: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("回复: %s\n", truncate(resp7, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n8. 手动维护短期记忆...")
|
||||||
|
if mm != nil {
|
||||||
|
mm.AddShortTermMemory("default", "用户张三喜欢Go语言")
|
||||||
|
mm.AddShortTermMemory("default", "用户询问过Web后端技术选型")
|
||||||
|
fmt.Println("短期记忆已添加")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n9. 手动添加长期记忆...")
|
||||||
|
if mm != nil {
|
||||||
|
mm.AddLongTermMemory("用户偏好Go语言", "preference")
|
||||||
|
mm.AddLongTermMemory("用户关注后端开发", "fact")
|
||||||
|
fmt.Println("长期记忆已添加")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n10. 等待异步长期记忆提取 + embedding 处理 (10秒)...")
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
|
||||||
|
fmt.Println("\n11. 查询数据库...")
|
||||||
|
checkDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDatabase() {
|
||||||
|
dbPath := os.ExpandEnv("$HOME/.orca/sessions/orcasession.db")
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("打开数据库失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
fmt.Println("\n--- main_messages 统计 ---")
|
||||||
|
var count int
|
||||||
|
db.QueryRow("SELECT COUNT(*) FROM main_messages").Scan(&count)
|
||||||
|
fmt.Printf("总消息数: %d\n", count)
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
fmt.Println(" 最近5条消息:")
|
||||||
|
rows, _ := db.Query(`
|
||||||
|
SELECT role, substr(content, 1, 60), timestamp
|
||||||
|
FROM main_messages
|
||||||
|
ORDER BY id DESC LIMIT 5
|
||||||
|
`)
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var role, content, ts string
|
||||||
|
rows.Scan(&role, &content, &ts)
|
||||||
|
fmt.Printf(" [%s] %s... (%s)\n", role, content, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n--- subagent_messages 统计 ---")
|
||||||
|
var subCount int
|
||||||
|
db.QueryRow("SELECT COUNT(*) FROM subagent_messages").Scan(&subCount)
|
||||||
|
fmt.Printf("子Agent消息数: %d\n", subCount)
|
||||||
|
|
||||||
|
if subCount > 0 {
|
||||||
|
fmt.Println(" 子Agent消息明细:")
|
||||||
|
rows, _ := db.Query(`
|
||||||
|
SELECT agent_name, role, substr(content, 1, 50), parent_session_id, session_id
|
||||||
|
FROM subagent_messages
|
||||||
|
ORDER BY id DESC LIMIT 10
|
||||||
|
`)
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var agent, role, content, parentSID, sid string
|
||||||
|
rows.Scan(&agent, &role, &content, &parentSID, &sid)
|
||||||
|
fmt.Printf(" [%s/%s] %s... (parent=%s, sid=%s)\n", agent, role, content, parentSID, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n--- 向量表统计 ---")
|
||||||
|
var vecCount int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM vec_main_messages").Scan(&vecCount)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("向量表查询失败: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("向量数量: %d\n", vecCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vecCount > 0 {
|
||||||
|
fmt.Println(" 最近5条向量:")
|
||||||
|
rows, _ := db.Query(`
|
||||||
|
SELECT v.msg_id, m.role, substr(m.content, 1, 40)
|
||||||
|
FROM vec_main_messages v
|
||||||
|
JOIN main_messages m ON v.msg_id = m.id
|
||||||
|
LIMIT 5
|
||||||
|
`)
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var msgID int
|
||||||
|
var role, content string
|
||||||
|
rows.Scan(&msgID, &role, &content)
|
||||||
|
fmt.Printf(" msg_id=%d role=%s content=%s\n", msgID, role, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n--- 短期记忆 ---")
|
||||||
|
rows, _ := db.Query("SELECT substr(content, 1, 60) FROM short_term_memories LIMIT 5")
|
||||||
|
defer rows.Close()
|
||||||
|
stmCount := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var content string
|
||||||
|
rows.Scan(&content)
|
||||||
|
fmt.Printf(" %s\n", content)
|
||||||
|
stmCount++
|
||||||
|
}
|
||||||
|
if stmCount == 0 {
|
||||||
|
fmt.Println(" (空)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n--- 长期记忆 ---")
|
||||||
|
rows2, _ := db.Query("SELECT substr(content, 1, 60), memory_type, weight FROM long_term_memories LIMIT 5")
|
||||||
|
defer rows2.Close()
|
||||||
|
ltmCount := 0
|
||||||
|
for rows2.Next() {
|
||||||
|
var content, mtype string
|
||||||
|
var weight float64
|
||||||
|
rows2.Scan(&content, &mtype, &weight)
|
||||||
|
fmt.Printf(" [%s|weight=%.2f] %s\n", mtype, weight, content)
|
||||||
|
ltmCount++
|
||||||
|
}
|
||||||
|
if ltmCount == 0 {
|
||||||
|
fmt.Println(" (空)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n--- dialogue_buffer ---")
|
||||||
|
var bufCount int
|
||||||
|
db.QueryRow("SELECT COUNT(*) FROM dialogue_buffer").Scan(&bufCount)
|
||||||
|
fmt.Printf("对话缓冲条数: %d\n", bufCount)
|
||||||
|
|
||||||
|
fmt.Println("\n--- 按 agent_name 统计子Agent消息 ---")
|
||||||
|
rows3, _ := db.Query(`
|
||||||
|
SELECT agent_name, COUNT(*) as cnt
|
||||||
|
FROM subagent_messages
|
||||||
|
GROUP BY agent_name
|
||||||
|
`)
|
||||||
|
defer rows3.Close()
|
||||||
|
for rows3.Next() {
|
||||||
|
var agent string
|
||||||
|
var cnt int
|
||||||
|
rows3.Scan(&agent, &cnt)
|
||||||
|
fmt.Printf(" %s: %d 条消息\n", agent, cnt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max] + "..."
|
||||||
|
}
|
||||||
BIN
test_retrieval
Executable file
BIN
test_retrieval
Executable file
Binary file not shown.
491
thoughts/shared/designs/2025-05-11-orca-memory-final-design.md
Normal file
491
thoughts/shared/designs/2025-05-11-orca-memory-final-design.md
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
---
|
||||||
|
date: 2025-05-11
|
||||||
|
topic: "Orca 完整记忆系统 + Agent 进化方案"
|
||||||
|
status: validated
|
||||||
|
---
|
||||||
|
|
||||||
|
# Orca 完整记忆系统 + Agent 进化方案
|
||||||
|
|
||||||
|
## 一、系统架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 用户消息 │
|
||||||
|
└──────────────────────┬──────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 智能注入层(规则驱动,零 LLM 开销) │
|
||||||
|
│ • 首轮对话 → 不带记忆 │
|
||||||
|
│ • 第 2+ 轮 → 自动注入短期记忆(3条) │
|
||||||
|
│ • 技术讨论 → 额外注入长期记忆(2条) │
|
||||||
|
└──────────────────────┬──────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 记忆查询层(按需加载) │
|
||||||
|
│ • 短期记忆:SQLite + 语义检索(缓存命中 130x 加速) │
|
||||||
|
│ • 长期记忆:SQLite + 独立向量索引(vec_long_term_memories) │
|
||||||
|
└──────────────────────┬──────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ LLM Agent 处理消息 │
|
||||||
|
└──────────────────────┬──────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 记忆维护层(后台异步) │
|
||||||
|
│ • 短期记忆:即时摘要保存(现有逻辑) │
|
||||||
|
│ • 长期记忆:MemoryExtractorAgent 批量提取(每 5 轮一次) │
|
||||||
|
└──────────────────────┬──────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 进化层(权重系统) │
|
||||||
|
│ • 记忆被引用 → 权重 +1 │
|
||||||
|
│ • 记忆未引用 → 权重 -0.5 │
|
||||||
|
│ • 权重 < 0.3 → 归档 │
|
||||||
|
│ • 权重 > 5.0 → 核心记忆 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、配置架构(config.toml)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# ========== 主 LLM 配置 ==========
|
||||||
|
provider = "deepseek"
|
||||||
|
|
||||||
|
[deepseek]
|
||||||
|
base_url = "https://api.deepseek.com/v1"
|
||||||
|
model = "deepseek-v4-flash"
|
||||||
|
api_key = "sk-xxx"
|
||||||
|
timeout = "120s"
|
||||||
|
|
||||||
|
# ========== Embedding 配置 ==========
|
||||||
|
[embedding]
|
||||||
|
provider = "siliconflow"
|
||||||
|
model = "Pro/BAAI/bge-m3"
|
||||||
|
dimensions = 1024
|
||||||
|
max_context = 8192
|
||||||
|
|
||||||
|
[siliconflow]
|
||||||
|
api_key = "sk-xxx"
|
||||||
|
base_url = "https://api.siliconflow.cn/v1"
|
||||||
|
|
||||||
|
# ========== 记忆系统配置(新增) ==========
|
||||||
|
[memory]
|
||||||
|
enabled = true # 记忆系统总开关
|
||||||
|
max_history = 100 # 工作记忆最大轮数
|
||||||
|
|
||||||
|
[memory.short_term]
|
||||||
|
max_items = 10 # 最多保留 10 条短期记忆
|
||||||
|
compression_threshold = 200 # 超过 200 字自动压缩
|
||||||
|
|
||||||
|
[memory.long_term]
|
||||||
|
enabled = true # 长期记忆开关
|
||||||
|
vector_index = true # 启用向量索引
|
||||||
|
max_return = 2 # 每次最多返回 2 条
|
||||||
|
archive_threshold = 0.3 # 权重低于此值归档
|
||||||
|
|
||||||
|
[memory.inject]
|
||||||
|
first_round_empty = true # 首轮不带记忆
|
||||||
|
short_term_count = 3 # 默认注入 3 条短期记忆
|
||||||
|
long_term_trigger = "technical" # 技术讨论触发长期记忆
|
||||||
|
min_query_length = 10 # query 最短长度才查长期记忆
|
||||||
|
|
||||||
|
# ========== MemoryExtractorAgent 配置(新增) ==========
|
||||||
|
[memory_agent]
|
||||||
|
enabled = true
|
||||||
|
provider = "deepseek" # 可独立配置,默认继承主 LLM
|
||||||
|
model = "deepseek-v4-flash" # 可用便宜模型,如 deepseek-chat
|
||||||
|
api_key = "" # 留空继承 deepseek.api_key
|
||||||
|
base_url = "" # 留空继承 deepseek.base_url
|
||||||
|
timeout = "60s"
|
||||||
|
|
||||||
|
[memory_agent.extract]
|
||||||
|
batch_size = 5 # 每 5 轮提取一次
|
||||||
|
max_facts = 10 # 每次最多提取 10 个事实
|
||||||
|
min_confidence = 0.6 # 置信度阈值
|
||||||
|
auto_tag = true # 自动打标签
|
||||||
|
|
||||||
|
[memory_agent.summarize]
|
||||||
|
enabled = true # 启用对话总结
|
||||||
|
trigger_tokens = 4000 # 超过此 token 触发总结
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Agent 描述文件
|
||||||
|
|
||||||
|
**文件位置**: `~/.orca/agents/memory_extractor.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Memory Extractor Agent
|
||||||
|
|
||||||
|
你是一个专门从对话中提取用户信息的 Agent。你的工作是将非结构化的对话转化为结构化的长期记忆。
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
|
||||||
|
分析给定的对话记录,提取以下类型的信息:
|
||||||
|
|
||||||
|
1. **事实 (fact)**:客观信息
|
||||||
|
- 工作:公司、职位、技术栈、行业
|
||||||
|
- 技术:擅长语言、框架偏好、架构经验
|
||||||
|
- 个人:教育背景、所在城市(仅用户明确提及)
|
||||||
|
|
||||||
|
2. **偏好 (preference)**:主观倾向
|
||||||
|
- 回答风格:简洁/详细/代码示例/架构图
|
||||||
|
- 技术偏好:语言、数据库、部署方式
|
||||||
|
- 沟通偏好:正式/ casual
|
||||||
|
|
||||||
|
3. **项目 (project)**:当前工作
|
||||||
|
- 项目名称、技术方案、当前阶段、遇到的挑战
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
只输出 JSON,不要任何解释:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"facts": [
|
||||||
|
{
|
||||||
|
"content": "用户在电商公司担任后端工程师",
|
||||||
|
"type": "fact",
|
||||||
|
"tags": ["工作", "后端", "电商"],
|
||||||
|
"confidence": 0.95,
|
||||||
|
"replace": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "用户偏好简洁的技术回答,不要过多解释",
|
||||||
|
"type": "preference",
|
||||||
|
"tags": ["沟通风格", "偏好"],
|
||||||
|
"confidence": 0.85,
|
||||||
|
"replace": "用户喜欢详细的回答"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
|
||||||
|
- confidence < 0.6 的事实不输出
|
||||||
|
- 如果新事实与旧事实冲突:
|
||||||
|
- 在 replace 字段填入被替换的旧事实 content
|
||||||
|
- 只替换同一 type + 同一 tags 的事实
|
||||||
|
- 不猜测、不推断,只提取用户明确表达的信息
|
||||||
|
- 标签从预设列表选择:工作、技术、偏好、项目、沟通风格、行业
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、数据表设计
|
||||||
|
|
||||||
|
### 现有表(已验证)
|
||||||
|
|
||||||
|
- `main_messages` — 工作记忆
|
||||||
|
- `short_term_memories` — 短期记忆
|
||||||
|
- `subagent_messages` — 子 Agent 对话
|
||||||
|
|
||||||
|
### 新增表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 长期记忆向量索引(核心新增)
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS vec_long_term_memories USING vec0(
|
||||||
|
memory_id INTEGER PRIMARY KEY,
|
||||||
|
embedding FLOAT[1024]
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 长期记忆主表(扩展)
|
||||||
|
CREATE TABLE IF NOT EXISTS long_term_memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT NOT NULL UNIQUE,
|
||||||
|
memory_type TEXT NOT NULL DEFAULT 'fact', -- fact/preference/project
|
||||||
|
tags TEXT, -- JSON 数组 ["工作", "技术"]
|
||||||
|
confidence REAL NOT NULL DEFAULT 0.8,
|
||||||
|
weight REAL NOT NULL DEFAULT 1.0, -- 动态权重
|
||||||
|
access_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_accessed DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0 -- 0=活跃, 1=归档
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 记忆使用日志(用于权重计算)
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_usage_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
memory_id INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
query TEXT NOT NULL, -- 用户原始 query
|
||||||
|
was_referenced INTEGER NOT NULL DEFAULT 0, -- 是否被 Agent 引用
|
||||||
|
used_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 对话缓冲(批量提取用)
|
||||||
|
CREATE TABLE IF NOT EXISTS dialogue_buffer (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
user_query TEXT NOT NULL,
|
||||||
|
assistant_response TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、核心流程设计
|
||||||
|
|
||||||
|
### 5.1 消息处理流程(动态注入)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (a *LLMAgent) buildLLMMessages(query string) []llm.Message {
|
||||||
|
messages := make([]llm.Message, 0)
|
||||||
|
|
||||||
|
// 1. System prompt(原有)
|
||||||
|
if a.systemPrompt != "" {
|
||||||
|
messages = append(messages, llm.Message{Role: "system", Content: a.systemPrompt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Tool prompt(原有)
|
||||||
|
if toolPrompt := a.buildToolPrompt(); toolPrompt != "" {
|
||||||
|
messages = append(messages, llm.Message{Role: "system", Content: toolPrompt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 记忆注入(新增:智能判断)
|
||||||
|
if shouldInjectMemory(query, a.sessionMgr.GetMessageCount(a.sessionID)) {
|
||||||
|
ctx, stats := a.memoryManager.BuildMemoryContextWithStats(a.sessionID, query)
|
||||||
|
if ctx != "" {
|
||||||
|
messages = append(messages, llm.Message{Role: "system", Content: ctx})
|
||||||
|
log.Printf("[memory] Injected: short=%d, long=%d, tokens=%d",
|
||||||
|
stats.ShortTermCount, stats.LongTermCount, stats.TotalTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 对话历史(原有)
|
||||||
|
// ...
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldInjectMemory(query string, msgCount int) bool {
|
||||||
|
if msgCount == 0 {
|
||||||
|
return false // 首轮不带记忆
|
||||||
|
}
|
||||||
|
if len(query) < 10 {
|
||||||
|
return false // 太短不查
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 记忆维护流程(批量提取)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (mm *MemoryManager) MaintainSessionMemory(sessionID, userQuery, assistantResponse string) {
|
||||||
|
// 1. 保存短期记忆(即时)
|
||||||
|
summary := fmt.Sprintf("用户问:%s\n回答:%s",
|
||||||
|
userQuery, truncateString(assistantResponse, 100))
|
||||||
|
mm.AddShortTermMemory(sessionID, summary)
|
||||||
|
|
||||||
|
// 2. 缓冲对话(用于批量提取)
|
||||||
|
mm.bufferDialogue(sessionID, userQuery, assistantResponse)
|
||||||
|
|
||||||
|
// 3. 检查是否触发批量提取
|
||||||
|
if mm.shouldExtract() {
|
||||||
|
mm.triggerExtraction(sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mm *MemoryManager) triggerExtraction(sessionID string) {
|
||||||
|
dialogues := mm.flushBuffer(sessionID)
|
||||||
|
|
||||||
|
// 异步调用 MemoryExtractorAgent
|
||||||
|
go func() {
|
||||||
|
facts, err := mm.extractor.ExtractFacts(dialogues)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[memory] Extraction failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fact := range facts {
|
||||||
|
if fact.Confidence >= mm.config.MinConfidence {
|
||||||
|
mm.AddLongTermMemory(fact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 权重反馈流程(自动进化)
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (mm *MemoryManager) recordMemoryUsage(memoryID int64, sessionID, query string, referenced bool) {
|
||||||
|
// 1. 记录使用日志
|
||||||
|
mm.store.Exec(
|
||||||
|
"INSERT INTO memory_usage_log (memory_id, session_id, query, was_referenced) VALUES (?, ?, ?, ?)",
|
||||||
|
memoryID, sessionID, query, referenced,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. 更新权重
|
||||||
|
delta := 0.5
|
||||||
|
if !referenced {
|
||||||
|
delta = -0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
mm.store.Exec(
|
||||||
|
"UPDATE long_term_memories SET weight = weight + ?, access_count = access_count + 1, last_accessed = ? WHERE id = ?",
|
||||||
|
delta, time.Now(), memoryID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 检查归档
|
||||||
|
mm.archiveLowWeightMemories()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、MemoryExtractorAgent 实现
|
||||||
|
|
||||||
|
```go
|
||||||
|
package actor
|
||||||
|
|
||||||
|
type MemoryExtractorAgent struct {
|
||||||
|
*SubAgent
|
||||||
|
config ExtractConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryExtractorAgent(id string, llmBackend llm.LLM, cfg ExtractConfig) *MemoryExtractorAgent {
|
||||||
|
prompt := loadAgentPrompt("memory_extractor") // 读取 ~/.orca/agents/memory_extractor.md
|
||||||
|
|
||||||
|
sa := NewSubAgent(id, llmBackend,
|
||||||
|
WithSubAgentRole("memory_extractor"),
|
||||||
|
WithSubAgentSystemPrompt(prompt),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &MemoryExtractorAgent{SubAgent: sa, config: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mea *MemoryExtractorAgent) ExtractFacts(dialogues []Dialogue) ([]Fact, error) {
|
||||||
|
// 格式化对话为 prompt
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("请分析以下对话记录,提取用户的关键信息:\n\n")
|
||||||
|
for i, d := range dialogues {
|
||||||
|
sb.WriteString(fmt.Sprintf("--- 对话 %d ---\n", i+1))
|
||||||
|
sb.WriteString(fmt.Sprintf("用户:%s\n", d.UserQuery))
|
||||||
|
sb.WriteString(fmt.Sprintf("助手:%s\n\n", d.AssistantResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := bus.Message{Type: bus.MsgTypeTaskRequest, Content: sb.String()}
|
||||||
|
resp, err := mea.Process(context.Background(), msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseFactJSON(resp.Content)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、实施路线图
|
||||||
|
|
||||||
|
### Phase 1: 基础记忆层(1-2 天)
|
||||||
|
- [ ] 扩展 `long_term_memories` 表(添加 weight, tags, archived 字段)
|
||||||
|
- [ ] 创建 `vec_long_term_memories` 向量索引表
|
||||||
|
- [ ] 创建 `memory_usage_log` 使用日志表
|
||||||
|
- [ ] 创建 `dialogue_buffer` 对话缓冲表
|
||||||
|
- [ ] 修改 config.toml 解析,支持 `[memory]` 和 `[memory_agent]` 段
|
||||||
|
|
||||||
|
### Phase 2: 记忆注入层(1-2 天)
|
||||||
|
- [ ] 实现 `shouldInjectMemory()` 智能判断逻辑
|
||||||
|
- [ ] 修改 `buildLLMMessages()` 注入记忆上下文
|
||||||
|
- [ ] 实现短期记忆检索(现有逻辑优化)
|
||||||
|
- [ ] 实现长期记忆向量检索(语义搜索)
|
||||||
|
- [ ] 添加 Embedding 缓存层(130x 加速)
|
||||||
|
|
||||||
|
### Phase 3: MemoryExtractorAgent(2-3 天)
|
||||||
|
- [ ] 创建 `~/.orca/agents/memory_extractor.md` 提示词文件
|
||||||
|
- [ ] 实现 `MemoryExtractorAgent` 结构体
|
||||||
|
- [ ] 实现 `ExtractFacts()` 批量提取逻辑
|
||||||
|
- [ ] 实现对话缓冲和批量触发机制
|
||||||
|
- [ ] 集成到 `MaintainSessionMemory()` 流程
|
||||||
|
|
||||||
|
### Phase 4: 权重进化系统(1-2 天)
|
||||||
|
- [ ] 实现 `recordMemoryUsage()` 权重反馈
|
||||||
|
- [ ] 实现记忆引用检测(判断 Agent 是否使用了某条记忆)
|
||||||
|
- [ ] 实现自动归档逻辑(权重 < 0.3)
|
||||||
|
- [ ] 实现核心记忆标记(权重 > 5.0)
|
||||||
|
- [ ] 添加 `orca memory` CLI 命令(list, stats, clean)
|
||||||
|
|
||||||
|
### Phase 5: 测试与优化(1-2 天)
|
||||||
|
- [ ] 单元测试:记忆检索、权重计算、事实提取
|
||||||
|
- [ ] 集成测试:端到端对话流程
|
||||||
|
- [ ] 性能测试:Embedding 缓存命中率、向量检索延迟
|
||||||
|
- [ ] 调优:权重阈值、提取频率、注入策略
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、预期效果
|
||||||
|
|
||||||
|
| 指标 | 目标值 |
|
||||||
|
|------|--------|
|
||||||
|
| 记忆命中率 | > 80%(相关查询能命中有效记忆) |
|
||||||
|
| Token 消耗 | 增加 < 15%(记忆注入的额外开销) |
|
||||||
|
| 长期记忆检索 | < 100ms(向量搜索 + 缓存) |
|
||||||
|
| 自动学习 | 每 5 轮对话自动提取 2-5 个事实 |
|
||||||
|
| 记忆质量 | 人工抽查 > 90% 准确 |
|
||||||
|
| 成本增加 | Embedding API 调用 ≈ ¥0.01/千次 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、关键决策总结
|
||||||
|
|
||||||
|
| 决策 | 方案 | 理由 |
|
||||||
|
|------|------|------|
|
||||||
|
| 提取频率 | 每 5 轮批量提取 | 平衡实时性与 API 成本 |
|
||||||
|
| 提取模型 | 复用主 LLM(可独立配置) | 降低复杂度,留优化空间 |
|
||||||
|
| 意图分类 | 规则驱动(首轮/长度/技术词) | 零 LLM 开销,可预测 |
|
||||||
|
| 预加载 | 无,按需查询 | 避免无关记忆污染上下文 |
|
||||||
|
| 聚类 | 标签 + 类型,无自动聚类 | 人工可理解,可控 |
|
||||||
|
| 反馈机制 | 引用检测 + 权重调整 | 简单有效,可解释 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 评审意见处理
|
||||||
|
|
||||||
|
| 评审意见 | 处理方式 |
|
||||||
|
|---------|---------|
|
||||||
|
| vec0 兼容性 | ✅ 已确认可用,保留原方案 |
|
||||||
|
| Agent 演化系统 | ✅ 用户明确要求保留权重系统 |
|
||||||
|
| 异步处理 | ✅ 使用 goroutine 异步调用 MemoryExtractorAgent |
|
||||||
|
| 记忆衰减 | ✅ weight 字段 + archive_threshold 实现 |
|
||||||
|
| Token 预算 | ✅ 通过 max_return 和 short_term_count 控制 |
|
||||||
|
| 降级策略 | ✅ 配置项 enabled = true/false 可完全关闭 |
|
||||||
|
| 混合检索 | ⚠️ 当前纯向量检索,后续可添加 FTS5 |
|
||||||
|
| 嵌入模型版本化 | ⚠️ 当前固定 bge-m3,后续可扩展 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### A. 嵌入模型配置
|
||||||
|
|
||||||
|
**固定使用**:硅基流动 Pro/BAAI/bge-m3
|
||||||
|
- 维度:1024
|
||||||
|
- 最大上下文:8192
|
||||||
|
- 优势:中文优化,质量高
|
||||||
|
|
||||||
|
### B. 权重计算示例
|
||||||
|
|
||||||
|
```
|
||||||
|
初始:weight = 1.0
|
||||||
|
被引用 5 次:1.0 + 5*0.5 = 3.5
|
||||||
|
未被引用 3 次:3.5 - 3*0.3 = 2.6
|
||||||
|
超过 5.0 → 标记为核心记忆
|
||||||
|
低于 0.3 → 归档(不删除,可恢复)
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. 降级路径
|
||||||
|
|
||||||
|
```
|
||||||
|
Level 1: 向量检索 + 权重排序(正常)
|
||||||
|
Level 2: 纯 SQL 检索(向量服务故障)
|
||||||
|
Level 3: 仅短期记忆(长期记忆关闭)
|
||||||
|
Level 4: 无记忆(memory.enabled = false)
|
||||||
|
```
|
||||||
317
thoughts/shared/designs/2026-05-10-memory-system-design.md
Normal file
317
thoughts/shared/designs/2026-05-10-memory-system-design.md
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
date: 2026-05-10
|
||||||
|
topic: "三层记忆系统 + 向量检索 + 子Agent隔离"
|
||||||
|
status: validated
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
当前 orca.agent 使用 JSONL 文件存储会话历史,存在以下问题:
|
||||||
|
1. **检索方式原始**:只能按时间窗口取消息,无法语义检索
|
||||||
|
2. **无记忆分层**:所有消息一视同仁,早期重要信息被截断
|
||||||
|
3. **无跨会话知识**:用户偏好、项目背景每次重新说明
|
||||||
|
4. **子 Agent 污染上下文**:子 agent 的详细推理过程进入主 agent 上下文
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
1. **存储**:使用 SQLite + sqlite-vec(用户已安装)
|
||||||
|
2. **Embedding**:使用硅基流动 API(Pro/BAAI/bge-m3,1024维,8K上下文)
|
||||||
|
3. **向后兼容**:保留 JSONL 作为备份/迁移选项
|
||||||
|
4. **API 密钥**:通过 `~/.orca/config.toml` 的 `[siliconflow]` 段配置
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
采用 **三层记忆架构** + **向量检索** + **子 Agent 隔离**:
|
||||||
|
|
||||||
|
**三层记忆**:
|
||||||
|
- **工作记忆**:当前对话窗口(最近 N 条消息)
|
||||||
|
- **短期记忆**:本会话历史摘要(语义检索)
|
||||||
|
- **长期记忆**:跨会话关键知识(语义检索)
|
||||||
|
|
||||||
|
**向量检索**:
|
||||||
|
- 每条消息保存时生成 Embedding
|
||||||
|
- 检索时使用余弦相似度匹配相关记忆
|
||||||
|
- 支持跨会话语义检索
|
||||||
|
|
||||||
|
**子 Agent 隔离**:
|
||||||
|
- 子 agent 推理过程存储在独立表
|
||||||
|
- 主 agent 只接收结果摘要
|
||||||
|
- Web UI 可查看完整子 agent 执行过程
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入
|
||||||
|
│
|
||||||
|
├─→ [工作记忆] SQL 查询最近 N 条
|
||||||
|
│ └─→ 直接注入 Prompt
|
||||||
|
│
|
||||||
|
├─→ [短期记忆] 向量检索当前会话相关历史
|
||||||
|
│ └─→ 语义匹配 → 注入 Prompt
|
||||||
|
│
|
||||||
|
├─→ [长期记忆] 向量检索跨会话知识
|
||||||
|
│ └─→ 语义匹配 → 注入 Prompt
|
||||||
|
│
|
||||||
|
└─→ LLM 生成回复
|
||||||
|
│
|
||||||
|
├─→ 保存到 main_messages(生成 Embedding)
|
||||||
|
│
|
||||||
|
└─→ 如果是工具调用 → 子 Agent 执行
|
||||||
|
│
|
||||||
|
├─→ 子 Agent 输出 → subagent_messages
|
||||||
|
│ └─→ 完成后返回结果
|
||||||
|
│
|
||||||
|
└─→ 结果摘要 → main_messages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. SQLiteStore(替代 JSONLStore)
|
||||||
|
- 文件:`pkg/session/sqlite_store.go`
|
||||||
|
- 实现 `Store` 接口
|
||||||
|
- 使用 SQLite 存储消息
|
||||||
|
- 初始化时创建表结构
|
||||||
|
- **向后兼容**:提供 JSONL → SQLite 迁移脚本
|
||||||
|
|
||||||
|
### 2. VectorStore(向量层)
|
||||||
|
- 文件:`pkg/session/vector_store.go`
|
||||||
|
- 封装 sqlite-vec 操作
|
||||||
|
- 提供 SaveEmbedding、SearchSimilar 方法
|
||||||
|
- 使用硅基流动 Embed API 生成向量(Pro/BAAI/bge-m3,1024维)
|
||||||
|
- **优化策略**:
|
||||||
|
- 只对用户消息和完整 Assistant 回复生成向量(跳过短消息 < 10 字)
|
||||||
|
- 异步生成:消息先存 SQLite,Embedding 后台 goroutine 生成
|
||||||
|
- 批量生成:累积 5 条消息后一次性生成
|
||||||
|
|
||||||
|
### 3. MemoryManager(三层记忆管理)
|
||||||
|
- 文件:`pkg/session/memory_manager.go`
|
||||||
|
- 管理工作记忆、短期记忆、长期记忆
|
||||||
|
- 提供 GetWorkingMemory、GetShortTermMemory、GetLongTermMemory
|
||||||
|
- **Token 预算管理**:
|
||||||
|
- 总预算:模型窗口的 60%(如 8K 模型 = 4800 tokens)
|
||||||
|
- 工作记忆:预算的 50%(约 2400 tokens)
|
||||||
|
- 短期记忆:预算的 30%(约 1440 tokens)
|
||||||
|
- 长期记忆:预算的 20%(约 960 tokens)
|
||||||
|
- 自动维护记忆(生成摘要、压缩)
|
||||||
|
|
||||||
|
### 4. SubAgentStore(子 Agent 隔离)
|
||||||
|
- 文件:`pkg/session/subagent_store.go` 或复用 sqlite_store
|
||||||
|
- 独立表存储子 agent 输出
|
||||||
|
- **存储策略**:只存最终完整回复(一条记录),不存流式 token
|
||||||
|
- 不与主 agent 上下文混合
|
||||||
|
|
||||||
|
### 5. LLM Agent 集成
|
||||||
|
- 修改:`pkg/actor/llm_agent.go`
|
||||||
|
- `buildLLMMessages()` 整合三层记忆
|
||||||
|
- 子 agent 调用时设置隔离存储
|
||||||
|
- **触发时机**:每轮对话结束生成短期摘要,会话结束压缩到长期记忆
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### main_messages(主对话表)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE main_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
msg_type TEXT DEFAULT 'normal' CHECK(msg_type IN ('normal', 'fact', 'todo', 'decision', 'preference', 'error')),
|
||||||
|
token_count INTEGER DEFAULT 0,
|
||||||
|
has_embedding BOOLEAN DEFAULT FALSE,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
metadata JSON
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_main_session_time ON main_messages(session_id, timestamp DESC);
|
||||||
|
CREATE INDEX idx_main_role ON main_messages(role) WHERE role IN ('user', 'assistant');
|
||||||
|
```
|
||||||
|
|
||||||
|
### subagent_messages(子 Agent 隔离表)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE subagent_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
parent_msg_id INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
agent_name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('assistant', 'system')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (parent_msg_id) REFERENCES main_messages(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_subagent_parent ON subagent_messages(parent_msg_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### short_term_memories(短期记忆表)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE short_term_memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
source_count INTEGER DEFAULT 1,
|
||||||
|
confidence REAL DEFAULT 0.8 CHECK(confidence BETWEEN 0 AND 1),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_stm_session ON short_term_memories(session_id, updated_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### long_term_memories(长期记忆表)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE long_term_memories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
memory_type TEXT NOT NULL CHECK(memory_type IN ('preference', 'fact', 'decision', 'project')),
|
||||||
|
source_session TEXT,
|
||||||
|
confidence REAL DEFAULT 0.5 CHECK(confidence BETWEEN 0 AND 1),
|
||||||
|
access_count INTEGER DEFAULT 0,
|
||||||
|
last_accessed DATETIME,
|
||||||
|
expires_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_ltm_type ON long_term_memories(memory_type, confidence DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### vec_main_messages(向量虚拟表)
|
||||||
|
```sql
|
||||||
|
CREATE VIRTUAL TABLE vec_main_messages USING vec0(
|
||||||
|
msg_id INTEGER PRIMARY KEY,
|
||||||
|
embedding FLOAT[1024] -- 硅基流动 Pro/BAAI/bge-m3 维度
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### 消息存储流程
|
||||||
|
```
|
||||||
|
AddMessage(msg)
|
||||||
|
│
|
||||||
|
├─→ SQLiteStore.Save(msg) → main_messages 表
|
||||||
|
│ └─→ 计算 token_count(估算或计数)
|
||||||
|
│
|
||||||
|
├─→ 【异步】如果 msg.role IN ('user', 'assistant') 且 len(content) > 10
|
||||||
|
│ └─→ EmbeddingQueue <- msg
|
||||||
|
│ └─→ 后台 goroutine 批量生成 Embedding
|
||||||
|
│ └─→ VectorStore.Save(msg.id, embedding)
|
||||||
|
│ └─→ UPDATE main_messages SET has_embedding = TRUE
|
||||||
|
│
|
||||||
|
└─→ 返回
|
||||||
|
```
|
||||||
|
|
||||||
|
### 记忆检索流程
|
||||||
|
```
|
||||||
|
buildLLMMessages()
|
||||||
|
│
|
||||||
|
├─→ MemoryManager.GetWorkingMemory(sessionID, tokenBudget=2400)
|
||||||
|
│ └─→ SQL: SELECT * FROM main_messages
|
||||||
|
│ WHERE session_id = ?
|
||||||
|
│ ORDER BY timestamp DESC
|
||||||
|
│ └─→ 累积 messages 直到 token_count ≈ 2400
|
||||||
|
│ └─→ 反转顺序( chronological )
|
||||||
|
│
|
||||||
|
├─→ MemoryManager.GetShortTermMemory(sessionID, query, maxItems=3)
|
||||||
|
│ └─→ 如果 query 存在且 has_embedding:
|
||||||
|
│ └─→ 硅基流动 API.Embed(query)
|
||||||
|
│ └─→ 向量检索 short_term_memories(同 session)
|
||||||
|
│ └─→ 返回 Top-3 相关摘要
|
||||||
|
│ └─→ 否则:SQL 取最近 3 条摘要
|
||||||
|
│
|
||||||
|
└─→ MemoryManager.GetLongTermMemory(query, maxItems=2)
|
||||||
|
└─→ 如果 query 存在且 has_embedding:
|
||||||
|
│ └─→ 硅基流动 API.Embed(query)
|
||||||
|
│ └─→ 向量检索 long_term_memories
|
||||||
|
│ └─→ 返回 Top-2 相关记忆
|
||||||
|
└─→ 否则:SQL 取 access_count 最高的 2 条
|
||||||
|
└─→ UPDATE access_count += 1, last_accessed = NOW()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 子 Agent 执行流程
|
||||||
|
```
|
||||||
|
agent_call.Execute(coder)
|
||||||
|
│
|
||||||
|
├─→ 创建 SubAgent 上下文(隔离存储)
|
||||||
|
│
|
||||||
|
├─→ coder.Process(task)
|
||||||
|
│ ├─→ 流式输出 → subagent_messages
|
||||||
|
│ └─→ 完成后返回结果
|
||||||
|
│
|
||||||
|
└─→ 结果摘要 → main_messages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
1. **Embedding 失败**:降级为纯文本存储,不影响功能
|
||||||
|
- 标记 `has_embedding = FALSE`
|
||||||
|
- 后续检索使用 SQL 时间排序代替向量相似度
|
||||||
|
|
||||||
|
2. **sqlite-vec 不可用**:降级为纯 SQL 查询(无向量检索)
|
||||||
|
- 短期记忆:取最近 N 条摘要(按时间排序)
|
||||||
|
- 长期记忆:取 access_count 最高的记忆(按访问计数排序)
|
||||||
|
|
||||||
|
3. **硅基流动 API 不可用**:跳过向量检索,纯文本模式运行
|
||||||
|
- 启动时检测 API 可用性(读取 config.toml 验证 api_key)
|
||||||
|
- 不可用时禁用向量功能,回退到 SQL 时间排序
|
||||||
|
- 记录警告日志,不影响主功能
|
||||||
|
|
||||||
|
4. **存储失败**:保留 JSONL 作为备份
|
||||||
|
- 初始化时如果 SQLite 失败,回退到 JSONLStore
|
||||||
|
- 提供迁移脚本 `cmd/migrate-jsonl-to-sqlite`
|
||||||
|
|
||||||
|
5. **Token 预算超限**:优先保留工作记忆
|
||||||
|
- 先削减长期记忆(降为 1 条)
|
||||||
|
- 再削减短期记忆(降为 1 条)
|
||||||
|
- 最后削减工作记忆(减少消息数量)
|
||||||
|
|
||||||
|
6. **子 Agent 流式输出过大**:截断保护
|
||||||
|
- 单条子 Agent 回复限制 10000 tokens
|
||||||
|
- 超出部分截断并标记 `[内容已截断]`
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. **单元测试**:
|
||||||
|
- SQLiteStore CRUD 操作
|
||||||
|
- VectorStore 相似度搜索
|
||||||
|
- MemoryManager 三层检索
|
||||||
|
|
||||||
|
2. **集成测试**:
|
||||||
|
- 端到端消息存储和检索
|
||||||
|
- 子 Agent 隔离验证
|
||||||
|
- 向量检索准确性
|
||||||
|
|
||||||
|
3. **迁移测试**:
|
||||||
|
- JSONL → SQLite 数据迁移
|
||||||
|
- 向后兼容性
|
||||||
|
|
||||||
|
## Memory Maintenance Strategy
|
||||||
|
|
||||||
|
### 短期记忆生成
|
||||||
|
- **触发时机**:每轮对话结束(Assistant 回复完成后)
|
||||||
|
- **生成方式**:调用 LLM 生成 1-2 句话摘要
|
||||||
|
- **Prompt 示例**:"请用一句话总结本轮对话的关键信息,不超过 50 字:"
|
||||||
|
- **存储**:插入 `short_term_memories` 表
|
||||||
|
|
||||||
|
### 长期记忆生成
|
||||||
|
- **触发时机**:会话结束或超过 20 轮对话
|
||||||
|
- **生成方式**:
|
||||||
|
1. 合并短期记忆摘要
|
||||||
|
2. 调用 LLM 提取关键事实、偏好、决策
|
||||||
|
3. 分类为 preference/fact/decision/project
|
||||||
|
- **去重**:与新长期记忆做文本相似度比较,相似度 > 0.8 则更新旧记录
|
||||||
|
|
||||||
|
### 记忆清理
|
||||||
|
- **短期记忆**:保留最近 10 条,旧的自动删除
|
||||||
|
- **长期记忆**:过期检查(`expires_at`),过期后降权而非删除
|
||||||
|
- **访问计数**:长期记忆根据 `access_count` 排序,低频记忆逐步淘汰
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Embedding 模型选择**:硅基流动 Pro/BAAI/bge-m3,1024维,8K上下文
|
||||||
|
- **配置**:通过 `SILICONFLOW_API_KEY` 环境变量认证
|
||||||
|
- **模型ID**:`Pro/BAAI/bge-m3`
|
||||||
|
|
||||||
|
2. **向量维度**:1024维,sqlite-vec 完全支持
|
||||||
|
|
||||||
|
3. **性能**:大量消息时向量检索性能如何?是否需要索引优化?
|
||||||
|
- **优化**:sqlite-vec 自动创建 IVF 索引,10 万条消息内性能良好
|
||||||
|
|
||||||
|
4. **Token 计数**:是否需要在 Go 端实现 tiktoken?
|
||||||
|
- **建议**:初期用简单估算(1 中文字 ≈ 1.5 tokens),后续引入 tiktoken
|
||||||
|
|
||||||
|
5. **迁移策略**:是否强制迁移现有 JSONL?
|
||||||
|
- **建议**:不强制迁移,新会话使用 SQLite,旧会话仍从 JSONL 读取
|
||||||
593
thoughts/shared/plans/2025-05-11-orca-memory-plan.md
Normal file
593
thoughts/shared/plans/2025-05-11-orca-memory-plan.md
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
# Orca 记忆系统实施计划
|
||||||
|
|
||||||
|
**基于设计文档**: `thoughts/shared/designs/2025-05-11-orca-memory-final-design.md`
|
||||||
|
**实施方式**: 增量升级(在已有 v1 基础上扩展)
|
||||||
|
**预估工期**: 5-7 天
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: 数据库 Schema 扩展(第 1 天)
|
||||||
|
|
||||||
|
### 1.1 新增 `memory_usage_log` 表
|
||||||
|
|
||||||
|
**文件**: `pkg/session/sqlite_store.go` — `initSchema()` 方法
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 在 initSchema() 中添加
|
||||||
|
schema += `
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_usage_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
memory_id INTEGER NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
was_referenced INTEGER NOT NULL DEFAULT 0,
|
||||||
|
used_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (memory_id) REFERENCES long_term_memories(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_memory ON memory_usage_log(memory_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_session ON memory_usage_log(session_id);
|
||||||
|
`
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: 无
|
||||||
|
**测试**: 验证表创建成功,外键约束生效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 新增 `dialogue_buffer` 表
|
||||||
|
|
||||||
|
**文件**: `pkg/session/sqlite_store.go` — `initSchema()` 方法
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 在 initSchema() 中添加
|
||||||
|
schema += `
|
||||||
|
CREATE TABLE IF NOT EXISTS dialogue_buffer (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
user_query TEXT NOT NULL,
|
||||||
|
assistant_response TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_buffer_session ON dialogue_buffer(session_id, created_at);
|
||||||
|
`
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: 无
|
||||||
|
**测试**: 验证表创建成功
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 扩展 `long_term_memories` 表
|
||||||
|
|
||||||
|
**文件**: `pkg/session/sqlite_store.go`
|
||||||
|
|
||||||
|
当前表缺少 `weight`, `tags`, `archived` 字段。由于是 ALTER TABLE,需要小心处理:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 在 initSchema() 中修改 long_term_memories 定义
|
||||||
|
// 或添加迁移逻辑
|
||||||
|
migrations := []string{
|
||||||
|
`ALTER TABLE long_term_memories ADD COLUMN weight REAL NOT NULL DEFAULT 1.0`,
|
||||||
|
`ALTER TABLE long_term_memories ADD COLUMN tags TEXT`,
|
||||||
|
`ALTER TABLE long_term_memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: SQLite 支持 ADD COLUMN,但有限制(不能加 UNIQUE / PRIMARY KEY)
|
||||||
|
|
||||||
|
**依赖**: 无
|
||||||
|
**测试**: 验证迁移后数据完整性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 配置扩展(第 1-2 天)
|
||||||
|
|
||||||
|
### 2.1 扩展 Config 结构体
|
||||||
|
|
||||||
|
**文件**: `internal/config/config.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
// ... 现有字段 ...
|
||||||
|
|
||||||
|
Memory MemoryConfig // 新增
|
||||||
|
MemoryAgent MemoryAgentConfig // 新增
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
MaxHistory int `toml:"max_history"`
|
||||||
|
|
||||||
|
ShortTerm ShortTermConfig `toml:"short_term"`
|
||||||
|
LongTerm LongTermConfig `toml:"long_term"`
|
||||||
|
Inject InjectConfig `toml:"inject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShortTermConfig struct {
|
||||||
|
MaxItems int `toml:"max_items"`
|
||||||
|
CompressionThreshold int `toml:"compression_threshold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LongTermConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
VectorIndex bool `toml:"vector_index"`
|
||||||
|
MaxReturn int `toml:"max_return"`
|
||||||
|
ArchiveThreshold float64 `toml:"archive_threshold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InjectConfig struct {
|
||||||
|
FirstRoundEmpty bool `toml:"first_round_empty"`
|
||||||
|
ShortTermCount int `toml:"short_term_count"`
|
||||||
|
LongTermTrigger string `toml:"long_term_trigger"`
|
||||||
|
MinQueryLength int `toml:"min_query_length"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryAgentConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Provider string `toml:"provider"`
|
||||||
|
Model string `toml:"model"`
|
||||||
|
APIKey string `toml:"api_key"`
|
||||||
|
BaseURL string `toml:"base_url"`
|
||||||
|
Timeout string `toml:"timeout"`
|
||||||
|
Extract ExtractConfig `toml:"extract"`
|
||||||
|
Summarize SummarizeConfig `toml:"summarize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractConfig struct {
|
||||||
|
BatchSize int `toml:"batch_size"`
|
||||||
|
MaxFacts int `toml:"max_facts"`
|
||||||
|
MinConfidence float64 `toml:"min_confidence"`
|
||||||
|
AutoTag bool `toml:"auto_tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummarizeConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
TriggerTokens int `toml:"trigger_tokens"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 1 完成
|
||||||
|
**测试**: 验证 TOML 解析正确
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 更新 config.toml.example
|
||||||
|
|
||||||
|
**文件**: `config.toml.example`
|
||||||
|
|
||||||
|
添加完整的 `[memory]` 和 `[memory_agent]` 示例配置。
|
||||||
|
|
||||||
|
**依赖**: 2.1 完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: MemoryManager 扩展(第 2-3 天)
|
||||||
|
|
||||||
|
### 3.1 添加权重管理方法
|
||||||
|
|
||||||
|
**文件**: `pkg/session/memory_manager.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// RecordMemoryUsage 记录记忆使用并更新权重
|
||||||
|
func (mm *MemoryManager) RecordMemoryUsage(memoryID int64, sessionID, query string, referenced bool) error
|
||||||
|
|
||||||
|
// UpdateMemoryWeight 更新单条记忆权重
|
||||||
|
func (mm *MemoryManager) UpdateMemoryWeight(memoryID int64, delta float64) error
|
||||||
|
|
||||||
|
// ArchiveLowWeightMemories 归档低权重记忆
|
||||||
|
func (mm *MemoryManager) ArchiveLowWeightMemories(threshold float64) (int, error)
|
||||||
|
|
||||||
|
// GetCoreMemories 获取核心记忆(高权重)
|
||||||
|
func (mm *MemoryManager) GetCoreMemories(minWeight float64) ([]LongTermMemory, error)
|
||||||
|
|
||||||
|
// GetArchivedMemories 获取已归档记忆
|
||||||
|
func (mm *MemoryManager) GetArchivedMemories() ([]LongTermMemory, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 1, Phase 2
|
||||||
|
**测试**: 验证权重计算正确,归档逻辑生效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 添加对话缓冲管理
|
||||||
|
|
||||||
|
**文件**: `pkg/session/memory_manager.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// BufferDialogue 缓冲对话
|
||||||
|
func (mm *MemoryManager) BufferDialogue(sessionID, userQuery, assistantResponse string) error
|
||||||
|
|
||||||
|
// GetBufferedDialogues 获取缓冲的对话
|
||||||
|
func (mm *MemoryManager) GetBufferedDialogues(sessionID string, limit int) ([]Dialogue, error)
|
||||||
|
|
||||||
|
// ClearBuffer 清空缓冲
|
||||||
|
func (mm *MemoryManager) ClearBuffer(sessionID string) error
|
||||||
|
|
||||||
|
// ShouldExtract 检查是否触发提取
|
||||||
|
func (mm *MemoryManager) ShouldExtract(sessionID string) (bool, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 1
|
||||||
|
**测试**: 验证缓冲写入、读取、清空
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 增强记忆注入逻辑
|
||||||
|
|
||||||
|
**文件**: `pkg/session/memory_manager.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ShouldInjectMemory 判断是否注入记忆
|
||||||
|
func (mm *MemoryManager) ShouldInjectMemory(query string, msgCount int) bool
|
||||||
|
|
||||||
|
// BuildMemoryContextWithStats 构建记忆上下文(增强版)
|
||||||
|
// 现有方法需要增强:支持 first_round_empty, min_query_length
|
||||||
|
```
|
||||||
|
|
||||||
|
修改 `BuildMemoryContextWithStats` 支持配置参数:
|
||||||
|
- `first_round_empty`: msgCount == 0 时返回空
|
||||||
|
- `min_query_length`: len(query) < threshold 时返回空
|
||||||
|
- `long_term_trigger`: 检测技术关键词
|
||||||
|
|
||||||
|
**依赖**: Phase 2
|
||||||
|
**测试**: 验证各种注入条件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 添加长期记忆写入
|
||||||
|
|
||||||
|
**文件**: `pkg/session/memory_manager.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// AddLongTermMemoryWithEmbedding 添加长期记忆并生成向量
|
||||||
|
func (mm *MemoryManager) AddLongTermMemoryWithEmbedding(content, memoryType string, tags []string, confidence float64) error
|
||||||
|
|
||||||
|
// UpdateLongTermMemory 更新长期记忆
|
||||||
|
func (mm *MemoryManager) UpdateLongTermMemory(id int64, updates map[string]interface{}) error
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 1
|
||||||
|
**测试**: 验证写入 + 向量生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: MemoryExtractorAgent(第 3-4 天)
|
||||||
|
|
||||||
|
### 4.1 创建 Agent Prompt 文件
|
||||||
|
|
||||||
|
**文件**: `~/.orca/agents/memory_extractor.md`
|
||||||
|
|
||||||
|
内容来自设计文档,需要安装到用户目录。
|
||||||
|
|
||||||
|
**文件**: `pkg/actor/memory_extractor_prompt.go`(内嵌默认 prompt,作为 fallback)
|
||||||
|
|
||||||
|
```go
|
||||||
|
package actor
|
||||||
|
|
||||||
|
const DefaultMemoryExtractorPrompt = `...`
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: 无
|
||||||
|
**测试**: 验证 prompt 加载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 实现 MemoryExtractorAgent
|
||||||
|
|
||||||
|
**文件**: `pkg/actor/memory_extractor_agent.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package actor
|
||||||
|
|
||||||
|
type MemoryExtractorAgent struct {
|
||||||
|
*SubAgent
|
||||||
|
config MemoryAgentConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoryExtractorAgent 创建提取 Agent
|
||||||
|
func NewMemoryExtractorAgent(id string, llmBackend llm.LLM, cfg MemoryAgentConfig) (*MemoryExtractorAgent, error)
|
||||||
|
|
||||||
|
// ExtractFacts 从对话中提取事实
|
||||||
|
func (mea *MemoryExtractorAgent) ExtractFacts(dialogues []Dialogue) ([]Fact, error)
|
||||||
|
|
||||||
|
// parseFactJSON 解析提取结果
|
||||||
|
func parseFactJSON(content string) ([]Fact, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
**类型定义**:
|
||||||
|
```go
|
||||||
|
type Dialogue struct {
|
||||||
|
UserQuery string
|
||||||
|
AssistantResponse string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fact struct {
|
||||||
|
Content string
|
||||||
|
Type string // fact/preference/project
|
||||||
|
Tags []string
|
||||||
|
Confidence float64
|
||||||
|
Replace *string // 被替换的旧事实
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 2, Phase 3
|
||||||
|
**测试**: 验证提取逻辑,JSON 解析
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 集成到 Kernel
|
||||||
|
|
||||||
|
**文件**: `pkg/kernel/kernel.go`
|
||||||
|
|
||||||
|
在 Kernel 初始化时创建 MemoryExtractorAgent:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 在 NewWithConfig() 中
|
||||||
|
if cfg.MemoryAgent.Enabled {
|
||||||
|
kernel.memoryExtractor, err = actor.NewMemoryExtractorAgent(
|
||||||
|
"memory_extractor",
|
||||||
|
kernel.llmBackend,
|
||||||
|
cfg.MemoryAgent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: 4.2
|
||||||
|
**测试**: 验证 Kernel 启动
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: LLMAgent 集成(第 4-5 天)
|
||||||
|
|
||||||
|
### 5.1 修改 buildLLMMessages
|
||||||
|
|
||||||
|
**文件**: `pkg/actor/llm_agent.go`
|
||||||
|
|
||||||
|
增强 `buildLLMMessages()` 方法:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (a *LLMAgent) buildLLMMessages(query string) []llm.Message {
|
||||||
|
messages := make([]llm.Message, 0)
|
||||||
|
|
||||||
|
// 1. System prompt(原有)
|
||||||
|
if a.systemPrompt != "" {
|
||||||
|
messages = append(messages, llm.Message{Role: "system", Content: a.systemPrompt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Tool prompt(原有)
|
||||||
|
if toolPrompt := a.buildToolPrompt(); toolPrompt != "" {
|
||||||
|
messages = append(messages, llm.Message{Role: "system", Content: toolPrompt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 记忆注入(增强)
|
||||||
|
if a.memoryManager != nil {
|
||||||
|
msgCount := a.sessionMgr.GetMessageCount(a.sessionID)
|
||||||
|
if a.memoryManager.ShouldInjectMemory(query, msgCount) {
|
||||||
|
ctx, stats := a.memoryManager.BuildMemoryContextWithStats(a.sessionID, query)
|
||||||
|
if ctx != "" {
|
||||||
|
messages = append(messages, llm.Message{Role: "system", Content: ctx})
|
||||||
|
log.Printf("[memory] Injected: short=%d, long=%d, tokens=%d",
|
||||||
|
stats.ShortTermCount, stats.LongTermCount, stats.TotalTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 对话历史(原有)
|
||||||
|
// ...
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 3
|
||||||
|
**测试**: 验证注入条件,Token 预算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 修改消息处理流程
|
||||||
|
|
||||||
|
**文件**: `pkg/actor/llm_agent.go`
|
||||||
|
|
||||||
|
在消息保存后添加记忆维护:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (a *LLMAgent) handleUserMessage(ctx context.Context, msg Message) error {
|
||||||
|
// ... 原有逻辑 ...
|
||||||
|
|
||||||
|
// 保存消息(原有)
|
||||||
|
a.sessionMgr.SaveMessage(...)
|
||||||
|
|
||||||
|
// 记忆维护(新增)
|
||||||
|
if a.memoryManager != nil {
|
||||||
|
a.memoryManager.MaintainSessionMemory(a.sessionID, query, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 3
|
||||||
|
**测试**: 验证 STM 生成,缓冲写入
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 添加异步提取触发
|
||||||
|
|
||||||
|
**文件**: `pkg/actor/llm_agent.go` 或 `pkg/session/memory_manager.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// triggerExtraction 异步触发事实提取
|
||||||
|
func (mm *MemoryManager) triggerExtraction(sessionID string) {
|
||||||
|
dialogues, _ := mm.GetBufferedDialogues(sessionID, mm.config.Extract.BatchSize)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// 调用 MemoryExtractorAgent
|
||||||
|
facts, err := mm.extractor.ExtractFacts(dialogues)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[memory] Extraction failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fact := range facts {
|
||||||
|
if fact.Confidence >= mm.config.Extract.MinConfidence {
|
||||||
|
mm.AddLongTermMemoryWithEmbedding(...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mm.ClearBuffer(sessionID)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 4
|
||||||
|
**测试**: 验证异步执行,错误处理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: 权重反馈系统(第 5 天)
|
||||||
|
|
||||||
|
### 6.1 记忆引用检测
|
||||||
|
|
||||||
|
**文件**: `pkg/session/memory_manager.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DetectMemoryReference 检测 Agent 回复是否引用了某条记忆
|
||||||
|
func (mm *MemoryManager) DetectMemoryReference(response string, memories []LongTermMemory) []int64 {
|
||||||
|
// 简单实现:检查记忆中关键词是否出现在回复中
|
||||||
|
// 高级实现:使用 Embedding 相似度
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 3
|
||||||
|
**测试**: 验证检测准确率
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 自动归档任务
|
||||||
|
|
||||||
|
**文件**: `pkg/session/memory_manager.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// RunMaintenance 运行记忆维护任务
|
||||||
|
func (mm *MemoryManager) RunMaintenance() error {
|
||||||
|
// 1. 归档低权重记忆
|
||||||
|
mm.ArchiveLowWeightMemories(mm.config.LongTerm.ArchiveThreshold)
|
||||||
|
|
||||||
|
// 2. 清理过期 STM
|
||||||
|
mm.Cleanup()
|
||||||
|
|
||||||
|
// 3. 其他维护...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 3
|
||||||
|
**测试**: 验证归档逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: CLI 命令(第 5-6 天)
|
||||||
|
|
||||||
|
### 7.1 添加 CLI 命令
|
||||||
|
|
||||||
|
**文件**: `cmd/orca/main.go` 或新建 `cmd/orca/memory.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// orca memory list
|
||||||
|
// orca memory search "query"
|
||||||
|
// orca memory delete <id>
|
||||||
|
// orca memory clean
|
||||||
|
// orca memory stats
|
||||||
|
// orca memory export > backup.json
|
||||||
|
// orca memory import < backup.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**依赖**: Phase 3
|
||||||
|
**测试**: 验证命令执行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: 测试与优化(第 6-7 天)
|
||||||
|
|
||||||
|
### 8.1 单元测试
|
||||||
|
|
||||||
|
| 模块 | 测试项 | 文件 |
|
||||||
|
|------|--------|------|
|
||||||
|
| Schema | 表创建、迁移 | `pkg/session/sqlite_store_test.go` |
|
||||||
|
| Config | TOML 解析 | `internal/config/config_test.go` |
|
||||||
|
| Weight | 权重计算、归档 | `pkg/session/memory_manager_test.go` |
|
||||||
|
| Buffer | 缓冲写入、清空 | `pkg/session/memory_manager_test.go` |
|
||||||
|
| Extractor | 事实提取、JSON 解析 | `pkg/actor/memory_extractor_test.go` |
|
||||||
|
| Injection | 注入条件、Token 预算 | `pkg/actor/llm_agent_test.go` |
|
||||||
|
|
||||||
|
### 8.2 集成测试
|
||||||
|
|
||||||
|
```go
|
||||||
|
// test_memory_system_v2.go
|
||||||
|
func TestMemorySystemV2(t *testing.T) {
|
||||||
|
// 1. 创建会话
|
||||||
|
// 2. 发送消息(验证 STM 生成)
|
||||||
|
// 3. 发送 5 条消息(验证 LTM 提取触发)
|
||||||
|
// 4. 查询长期记忆(验证向量检索)
|
||||||
|
// 5. 验证权重变化
|
||||||
|
// 6. 验证归档逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 性能测试
|
||||||
|
|
||||||
|
| 指标 | 目标 | 测试方法 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 向量检索 | < 100ms | BenchmarkVectorSearch |
|
||||||
|
| Embedding 缓存 | > 95% 命中 | BenchmarkEmbeddingCache |
|
||||||
|
| 权重更新 | < 10ms | BenchmarkWeightUpdate |
|
||||||
|
| 整体延迟 | < 200ms | BenchmarkMemoryInjection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖图
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Schema)
|
||||||
|
│
|
||||||
|
├──→ Phase 2 (Config)
|
||||||
|
│ │
|
||||||
|
│ ├──→ Phase 3 (MemoryManager)
|
||||||
|
│ │ │
|
||||||
|
│ │ ├──→ Phase 4 (ExtractorAgent)
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ └──→ Phase 5 (LLMAgent)
|
||||||
|
│ │ │
|
||||||
|
│ │ └──→ Phase 6 (Weight System)
|
||||||
|
│ │
|
||||||
|
│ └──→ Phase 7 (CLI)
|
||||||
|
│
|
||||||
|
└──→ Phase 8 (Test)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 可能性 | 影响 | 缓解 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| Schema 迁移失败 | 中 | 高 | 备份数据库,测试迁移脚本 |
|
||||||
|
| MemoryExtractor 幻觉 | 高 | 中 | 高置信度阈值,人工抽查 |
|
||||||
|
| 权重系统不准确 | 中 | 低 | 提供手动调整 CLI |
|
||||||
|
| 性能退化 | 低 | 中 | 监控,及时优化索引 |
|
||||||
|
| 向后兼容 | 中 | 高 | 保留旧配置解析逻辑 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- [ ] 所有新增表创建成功
|
||||||
|
- [ ] 配置解析正确,默认值合理
|
||||||
|
- [ ] STM 自动生成(每轮对话)
|
||||||
|
- [ ] LTM 批量提取(每 5 轮)
|
||||||
|
- [ ] 向量检索正常(语义相似度)
|
||||||
|
- [ ] 权重自动更新(引用检测)
|
||||||
|
- [ ] 低权重记忆自动归档
|
||||||
|
- [ ] CLI 命令可用
|
||||||
|
- [ ] 单元测试覆盖率 > 80%
|
||||||
|
- [ ] 集成测试通过
|
||||||
|
- [ ] 性能指标达标
|
||||||
71
web-dev.log
Normal file
71
web-dev.log
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
> web@0.0.0 dev
|
||||||
|
> vite
|
||||||
|
|
||||||
|
|
||||||
|
VITE v8.0.11 ready in 215 ms
|
||||||
|
|
||||||
|
➜ Local: http://localhost:5173/
|
||||||
|
➜ Network: use --host to expose
|
||||||
|
19:50:41 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
|
19:50:41 [vite] (client) hmr invalidate /src/contexts/WebSocketContext.tsx Could not Fast Refresh ("useWebSocket" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
|
||||||
|
19:50:41 [vite] (client) hmr update /src/App.tsx, /src/components/Header.tsx, /src/components/ChatPanel.tsx, /src/components/Sidebar.tsx, /src/components/SessionList.tsx
|
||||||
|
19:52:24 [vite] (client) hmr update /src/components/ChatPanel.tsx, /src/index.css
|
||||||
|
19:54:20 [vite] (client) hmr update /src/index.css, /src/components/TaskCard.tsx
|
||||||
|
19:55:32 [vite] (client) hmr update /src/index.css, /src/components/TaskCard.tsx
|
||||||
|
19:56:24 [vite] (client) hmr update /src/index.css, /src/components/TaskCard.tsx
|
||||||
|
19:57:09 [vite] (client) hmr update /src/index.css, /src/components/TaskModal.tsx
|
||||||
|
19:58:18 [vite] (client) hmr update /src/index.css, /src/components/TaskCard.tsx
|
||||||
|
19:58:41 [vite] (client) hmr update /src/components/ChatPanel.tsx, /src/index.css
|
||||||
|
19:59:44 [vite] (client) hmr update /src/index.css, /src/components/TaskCard.tsx
|
||||||
|
20:00:00 [vite] (client) hmr update /src/components/ChatPanel.tsx, /src/index.css
|
||||||
|
20:00:19 [vite] http proxy error: /api/sessions
|
||||||
|
AggregateError [ECONNREFUSED]:
|
||||||
|
at internalConnectMultiple (node:net:1122:18)
|
||||||
|
at afterConnectMultiple (node:net:1689:7)
|
||||||
|
20:00:19 [vite] http proxy error: /api/sessions
|
||||||
|
AggregateError [ECONNREFUSED]:
|
||||||
|
at internalConnectMultiple (node:net:1122:18)
|
||||||
|
at afterConnectMultiple (node:net:1689:7)
|
||||||
|
20:05:13 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
|
20:05:13 [vite] Internal server error: Transform failed with 1 error:
|
||||||
|
|
||||||
|
[31m[PARSE_ERROR] Error:[0m Identifier `currentStreamingContentRef` has already been declared
|
||||||
|
[38;5;246m╭[0m[38;5;246m─[0m[38;5;246m[[0m src/contexts/WebSocketContext.tsx:66:9 [38;5;246m][0m
|
||||||
|
[38;5;246m│[0m
|
||||||
|
[38;5;246m 66 │[0m [38;5;249m [0m[38;5;249m [0m[38;5;249mc[0m[38;5;249mo[0m[38;5;249mn[0m[38;5;249ms[0m[38;5;249mt[0m[38;5;249m [0mcurrentStreamingContentRef[38;5;249m [0m[38;5;249m=[0m[38;5;249m [0m[38;5;249mu[0m[38;5;249ms[0m[38;5;249me[0m[38;5;249mR[0m[38;5;249me[0m[38;5;249mf[0m[38;5;249m([0m[38;5;249m"[0m[38;5;249m"[0m[38;5;249m)[0m[38;5;249m;[0m
|
||||||
|
[38;5;240m │[0m ─────────────┬────────────
|
||||||
|
[38;5;240m │[0m ╰────────────── `currentStreamingContentRef` has already been declared here
|
||||||
|
[38;5;240m │[0m
|
||||||
|
[38;5;246m321 │[0m [38;5;249m [0m[38;5;249m [0m[38;5;249mc[0m[38;5;249mo[0m[38;5;249mn[0m[38;5;249ms[0m[38;5;249mt[0m[38;5;249m [0mcurrentStreamingContentRef[38;5;249m [0m[38;5;249m=[0m[38;5;249m [0m[38;5;249mu[0m[38;5;249ms[0m[38;5;249me[0m[38;5;249mR[0m[38;5;249me[0m[38;5;249mf[0m[38;5;249m([0m[38;5;249mc[0m[38;5;249mu[0m[38;5;249mr[0m[38;5;249mr[0m[38;5;249me[0m[38;5;249mn[0m[38;5;249mt[0m[38;5;249mS[0m[38;5;249mt[0m[38;5;249mr[0m[38;5;249me[0m[38;5;249ma[0m[38;5;249mm[0m[38;5;249mi[0m[38;5;249mn[0m[38;5;249mg[0m[38;5;249mC[0m[38;5;249mo[0m[38;5;249mn[0m[38;5;249mt[0m[38;5;249me[0m[38;5;249mn[0m[38;5;249mt[0m[38;5;249m)[0m[38;5;249m;[0m
|
||||||
|
[38;5;240m │[0m ─────────────┬────────────
|
||||||
|
[38;5;240m │[0m ╰────────────── It can not be redeclared here
|
||||||
|
[38;5;246m─────╯[0m
|
||||||
|
|
||||||
|
Plugin: vite:oxc
|
||||||
|
File: /Users/wang/agent_dev/orca.ai/web/src/contexts/WebSocketContext.tsx
|
||||||
|
at transformWithOxc (file:///Users/wang/agent_dev/orca.ai/web/node_modules/vite/dist/node/chunks/node.js:3340:19)
|
||||||
|
at TransformPluginContext.transform (file:///Users/wang/agent_dev/orca.ai/web/node_modules/vite/dist/node/chunks/node.js:3408:26)
|
||||||
|
at EnvironmentPluginContainer.transform (file:///Users/wang/agent_dev/orca.ai/web/node_modules/vite/dist/node/chunks/node.js:30179:51)
|
||||||
|
at async loadAndTransform (file:///Users/wang/agent_dev/orca.ai/web/node_modules/vite/dist/node/chunks/node.js:24509:26)
|
||||||
|
at async viteTransformMiddleware (file:///Users/wang/agent_dev/orca.ai/web/node_modules/vite/dist/node/chunks/node.js:24303:20)
|
||||||
|
20:05:21 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
|
20:05:21 [vite] (client) hmr invalidate /src/contexts/WebSocketContext.tsx Could not Fast Refresh ("useWebSocket" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
|
||||||
|
20:05:21 [vite] (client) hmr update /src/App.tsx, /src/components/Header.tsx, /src/components/ChatPanel.tsx, /src/components/Sidebar.tsx, /src/components/SessionList.tsx
|
||||||
|
20:05:47 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
|
20:05:47 [vite] (client) hmr invalidate /src/contexts/WebSocketContext.tsx Could not Fast Refresh ("useWebSocket" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
|
||||||
|
20:05:47 [vite] (client) hmr update /src/App.tsx, /src/components/Header.tsx, /src/components/ChatPanel.tsx, /src/components/Sidebar.tsx, /src/components/SessionList.tsx
|
||||||
|
20:05:55 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
|
20:05:55 [vite] (client) hmr invalidate /src/contexts/WebSocketContext.tsx Could not Fast Refresh ("useWebSocket" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
|
||||||
|
20:05:55 [vite] (client) hmr update /src/App.tsx, /src/components/Header.tsx, /src/components/ChatPanel.tsx, /src/components/Sidebar.tsx, /src/components/SessionList.tsx
|
||||||
|
20:06:11 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
|
20:06:11 [vite] (client) hmr invalidate /src/contexts/WebSocketContext.tsx Could not Fast Refresh ("useWebSocket" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
|
||||||
|
20:06:11 [vite] (client) hmr update /src/App.tsx, /src/components/Header.tsx, /src/components/ChatPanel.tsx, /src/components/Sidebar.tsx, /src/components/SessionList.tsx
|
||||||
|
20:06:25 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
|
20:06:25 [vite] (client) hmr invalidate /src/contexts/WebSocketContext.tsx Could not Fast Refresh ("useWebSocket" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
|
||||||
|
20:06:25 [vite] (client) hmr update /src/App.tsx, /src/components/Header.tsx, /src/components/ChatPanel.tsx, /src/components/Sidebar.tsx, /src/components/SessionList.tsx
|
||||||
|
20:06:40 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
|
20:06:41 [vite] (client) hmr invalidate /src/contexts/WebSocketContext.tsx Could not Fast Refresh ("useWebSocket" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
|
||||||
|
20:06:41 [vite] (client) hmr update /src/App.tsx, /src/components/Header.tsx, /src/components/ChatPanel.tsx, /src/components/Sidebar.tsx, /src/components/SessionList.tsx
|
||||||
|
20:10:11 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
|
20:14:41 [vite] (client) hmr update /src/contexts/WebSocketContext.tsx, /src/index.css
|
||||||
12
web-server.log
Normal file
12
web-server.log
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
2026/05/10 18:19:53 kernel: created DeepSeek client (model=deepseek-v4-flash)
|
||||||
|
2026/05/10 18:19:53 kernel: created sub-agent "architect" from /Users/wang/.orca/prompts/architect.md
|
||||||
|
2026/05/10 18:19:53 kernel: created sub-agent "calculator" from /Users/wang/.orca/prompts/calculator.md
|
||||||
|
2026/05/10 18:19:53 kernel: created sub-agent "coder" from /Users/wang/.orca/prompts/coder.md
|
||||||
|
2026/05/10 18:19:53 kernel: created sub-agent "reviewer" from /Users/wang/.orca/prompts/reviewer.md
|
||||||
|
2026/05/10 18:19:53 kernel: created 4 sub-agents
|
||||||
|
2026/05/10 18:19:53 kernel: started (tools=6)
|
||||||
|
2026/05/10 18:19:53 kernel: warning: skill loading had errors: skill: loaded 8 skills with 1 errors: art-design-pro: skill: "/Users/wang/.agents/skills/art-design-pro/SKILL.md" is missing 'name' in frontmatter
|
||||||
|
2026/05/10 18:19:53 kernel: loaded 8 skills
|
||||||
|
Starting web server on http://localhost:8081
|
||||||
|
2026/05/10 18:19:53 WebSocket server starting on http://localhost:8081
|
||||||
|
2026/05/10 18:20:21 WebSocket error: websocket: close 1005 (no status)
|
||||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
web/README.md
Normal file
73
web/README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
22
web/eslint.config.js
Normal file
22
web/eslint.config.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>orca.agent</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4785
web/package-lock.json
generated
Normal file
4785
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
web/package.json
Normal file
44
web/package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"postcss": "^8.5.14",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.2",
|
||||||
|
"vite": "^8.0.10",
|
||||||
|
"ws": "^8.20.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
web/postcss.config.js
Normal file
5
web/postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
web/public/favicon.svg
Normal file
1
web/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
web/public/icons.svg
Normal file
24
web/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
184
web/src/App.css
Normal file
184
web/src/App.css
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
web/src/App.tsx
Normal file
43
web/src/App.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { WebSocketProvider } from "./contexts/WebSocketContext";
|
||||||
|
import { Header } from "./components/Header";
|
||||||
|
import { ChatPanel } from "./components/ChatPanel";
|
||||||
|
import { Sidebar } from "./components/Sidebar";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||||
|
const saved = localStorage.getItem("orca-theme");
|
||||||
|
if (saved) return saved === "dark";
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDarkMode) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
localStorage.setItem("orca-theme", "dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
localStorage.setItem("orca-theme", "light");
|
||||||
|
}
|
||||||
|
}, [isDarkMode]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setIsDarkMode(!isDarkMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebSocketProvider>
|
||||||
|
<div className="h-screen w-screen flex flex-col overflow-hidden bg-background">
|
||||||
|
<Header isDarkMode={isDarkMode} onToggleTheme={toggleTheme} />
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ChatPanel />
|
||||||
|
</div>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WebSocketProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
web/src/assets/hero.png
Normal file
BIN
web/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
web/src/assets/react.svg
Normal file
1
web/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
web/src/assets/vite.svg
Normal file
1
web/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
246
web/src/components/ChatPanel.tsx
Normal file
246
web/src/components/ChatPanel.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
|
import { Send, User, AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { useWebSocket } from "../contexts/WebSocketContext";
|
||||||
|
import type { Task } from "../contexts/WebSocketContext";
|
||||||
|
import { StreamingMarkdown } from "./StreamingMarkdown";
|
||||||
|
import { TaskCard } from "./TaskCard";
|
||||||
|
import { TaskModal } from "./TaskModal";
|
||||||
|
|
||||||
|
interface ContentPart {
|
||||||
|
type: "text" | "agent_call";
|
||||||
|
content: string;
|
||||||
|
agent?: string;
|
||||||
|
task?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMessageContent(text: string): ContentPart[] {
|
||||||
|
const parts: ContentPart[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
const arrayRegex = /(\[\s*\{[\s\S]*?"tool"\s*:\s*"agent_call"[\s\S]*?\}\s*\])/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = arrayRegex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push({ type: "text", content: text.slice(lastIndex, match.index) });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(match[1]);
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
for (const item of arr) {
|
||||||
|
if (item.tool === "agent_call" && item.arguments) {
|
||||||
|
parts.push({
|
||||||
|
type: "agent_call",
|
||||||
|
content: match[1],
|
||||||
|
agent: item.arguments.agent || "unknown",
|
||||||
|
task: item.arguments.task || item.arguments.prompt || "执行任务",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
parts.push({ type: "text", content: match[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push({ type: "text", content: text.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push({ type: "text", content: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTaskForAgentCall(tasks: Task[], agent: string, taskDesc: string): Task | undefined {
|
||||||
|
return tasks.find(
|
||||||
|
(t) =>
|
||||||
|
t.agent === agent &&
|
||||||
|
(t.task === taskDesc || taskDesc.includes(t.task) || t.task.includes(taskDesc))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatPanel: React.FC = () => {
|
||||||
|
const { messages, isLoading, sendMessage, currentStreamingContent, tasks } = useWebSocket();
|
||||||
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages, currentStreamingContent]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const value = inputRef.current?.value || "";
|
||||||
|
if (!value.trim() || isLoading) return;
|
||||||
|
sendMessage(value.trim());
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamingMessage = isLoading
|
||||||
|
? {
|
||||||
|
id: "streaming",
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: currentStreamingContent,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
agent: undefined,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const displayMessages = streamingMessage
|
||||||
|
? [...messages, streamingMessage]
|
||||||
|
: messages;
|
||||||
|
|
||||||
|
const renderMessageContent = (content: string, isStreaming: boolean) => {
|
||||||
|
const parts = parseMessageContent(content);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{parts.map((part, idx) => {
|
||||||
|
if (part.type === "agent_call" && part.agent && part.task) {
|
||||||
|
const task = findTaskForAgentCall(tasks, part.agent, part.task);
|
||||||
|
return (
|
||||||
|
<TaskCard
|
||||||
|
key={idx}
|
||||||
|
task={
|
||||||
|
task || {
|
||||||
|
id: `inline-${idx}`,
|
||||||
|
agent: part.agent,
|
||||||
|
task: part.task,
|
||||||
|
status: "completed",
|
||||||
|
content: "",
|
||||||
|
startTime: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
task
|
||||||
|
? setSelectedTask(task)
|
||||||
|
: setSelectedTask({
|
||||||
|
id: `inline-${idx}`,
|
||||||
|
agent: part.agent || "unknown",
|
||||||
|
task: part.task || "",
|
||||||
|
status: "completed",
|
||||||
|
content: part.content || "",
|
||||||
|
startTime: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<StreamingMarkdown
|
||||||
|
key={idx}
|
||||||
|
content={part.content}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||||
|
{displayMessages.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p>开始与 agent 对话</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto p-4">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{displayMessages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className="px-4 py-4 hover:bg-secondary/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{msg.role === "user" ? (
|
||||||
|
<>
|
||||||
|
<User className="w-4 h-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium text-foreground">You</span>
|
||||||
|
</>
|
||||||
|
) : msg.role === "system" ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||||
|
<span className="text-sm font-medium text-destructive">System</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 rounded-full bg-green-500 flex items-center justify-center">
|
||||||
|
<span className="text-[10px] text-white font-bold">AI</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{msg.agent || "Assistant"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-6">
|
||||||
|
{msg.role === "assistant" && msg.id === "streaming" ? (
|
||||||
|
msg.content.trim().length > 0 ? (
|
||||||
|
renderMessageContent(msg.content, true)
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span className="text-sm">AI 正在思考...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : msg.role === "assistant" ? (
|
||||||
|
renderMessageContent(msg.content, false)
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap text-foreground">{msg.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="border-t border-border p-4">
|
||||||
|
<div className="max-w-3xl mx-auto flex gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="输入消息..."
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 bg-secondary rounded-lg px-4 py-2 resize-none focus:outline-none focus:ring-2 focus:ring-ring min-h-[40px] max-h-[120px]"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-primary text-primary-foreground rounded-lg px-4 py-2 hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<TaskModal task={selectedTask} onClose={() => setSelectedTask(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
web/src/components/Header.tsx
Normal file
44
web/src/components/Header.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useWebSocket } from "../contexts/WebSocketContext";
|
||||||
|
import { Sun, Moon } from "lucide-react";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
isDarkMode: boolean;
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({ isDarkMode, onToggleTheme }) => {
|
||||||
|
const { isConnected } = useWebSocket();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-12 border-b border-border flex items-center justify-between px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-lg font-bold tracking-tight text-foreground">
|
||||||
|
orca.agent
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
isConnected ? "bg-green-500" : "bg-gray-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isConnected ? "已连接" : "未连接"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
className="p-2 rounded-md hover:bg-secondary transition-colors text-foreground"
|
||||||
|
title={isDarkMode ? "切换到亮色模式" : "切换到暗色模式"}
|
||||||
|
>
|
||||||
|
{isDarkMode ? (
|
||||||
|
<Sun className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
web/src/components/SessionList.tsx
Normal file
43
web/src/components/SessionList.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useWebSocket } from "../contexts/WebSocketContext";
|
||||||
|
import { History, MessageSquare } from "lucide-react";
|
||||||
|
|
||||||
|
export const SessionList: React.FC = () => {
|
||||||
|
const { sessions, currentSessionId, loadSession } = useWebSocket();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<History className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">历史对话</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">暂无历史记录</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => loadSession(session.id)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
|
currentSessionId === session.id
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-secondary text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">{session.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1 text-xs text-muted-foreground">
|
||||||
|
<span>{session.message_count} 条消息</span>
|
||||||
|
<span>{new Date(session.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
105
web/src/components/Sidebar.tsx
Normal file
105
web/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cpu, Wrench, BookOpen, Activity } from "lucide-react";
|
||||||
|
import { useWebSocket } from "../contexts/WebSocketContext";
|
||||||
|
import { SessionList } from "./SessionList";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
export const Sidebar: React.FC = () => {
|
||||||
|
const { stats, agentList, isConnected, isLoading } = useWebSocket();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 bg-card border-l border-border flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="font-bold text-lg">orca.agent</h1>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-2 h-2 rounded-full",
|
||||||
|
isConnected ? "bg-green-500" : "bg-red-500"
|
||||||
|
)}
|
||||||
|
title={isConnected ? "Connected" : "Disconnected"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">v0.1.0</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="p-4 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
Statistics
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Wrench className="w-4 h-4 text-muted-foreground" />
|
||||||
|
Tools
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-sm">{stats.tools}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<BookOpen className="w-4 h-4 text-muted-foreground" />
|
||||||
|
Skills
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-sm">{stats.skills}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
|
Agents
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-sm">{stats.agents}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agents */}
|
||||||
|
<div className="p-4 flex-1 overflow-y-auto">
|
||||||
|
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Cpu className="w-4 h-4" />
|
||||||
|
Active Agents
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{agentList.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No agents available</p>
|
||||||
|
)}
|
||||||
|
{agentList.map((agent) => (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
className="flex items-center justify-between p-2 rounded bg-secondary"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{agent.id}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full",
|
||||||
|
agent.status === "running"
|
||||||
|
? "bg-green-500/20 text-green-500"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{agent.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session List */}
|
||||||
|
<SessionList />
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="p-4 border-t border-border">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{isLoading && (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-muted-foreground">Processing...</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
165
web/src/components/StreamingMarkdown.tsx
Normal file
165
web/src/components/StreamingMarkdown.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
|
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
|
|
||||||
|
interface Chunk {
|
||||||
|
type: "text" | "code";
|
||||||
|
content: string;
|
||||||
|
language?: string;
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChunks(text: string): Chunk[] {
|
||||||
|
const chunks: Chunk[] = [];
|
||||||
|
let remaining = text;
|
||||||
|
let inCodeBlock = false;
|
||||||
|
let currentCode = "";
|
||||||
|
let currentLang = "";
|
||||||
|
let currentText = "";
|
||||||
|
|
||||||
|
const flushText = () => {
|
||||||
|
if (currentText) {
|
||||||
|
chunks.push({ type: "text", content: currentText, complete: true });
|
||||||
|
currentText = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushCode = (complete: boolean) => {
|
||||||
|
if (currentCode) {
|
||||||
|
chunks.push({
|
||||||
|
type: "code",
|
||||||
|
content: currentCode,
|
||||||
|
language: currentLang,
|
||||||
|
complete,
|
||||||
|
});
|
||||||
|
currentCode = "";
|
||||||
|
currentLang = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = remaining.split("\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const codeBlockMatch = line.match(/^```(\w*)/);
|
||||||
|
|
||||||
|
if (codeBlockMatch && !inCodeBlock) {
|
||||||
|
flushText();
|
||||||
|
inCodeBlock = true;
|
||||||
|
currentLang = codeBlockMatch[1] || "";
|
||||||
|
} else if (line.trim() === "```" && inCodeBlock) {
|
||||||
|
inCodeBlock = false;
|
||||||
|
flushCode(true);
|
||||||
|
} else if (inCodeBlock) {
|
||||||
|
currentCode += (currentCode ? "\n" : "") + line;
|
||||||
|
} else {
|
||||||
|
currentText += (currentText ? "\n" : "") + line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCodeBlock) {
|
||||||
|
flushCode(false);
|
||||||
|
} else {
|
||||||
|
flushText();
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamingMarkdownProps {
|
||||||
|
content: string;
|
||||||
|
isStreaming: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StreamingMarkdown: React.FC<StreamingMarkdownProps> = ({
|
||||||
|
content,
|
||||||
|
isStreaming,
|
||||||
|
}) => {
|
||||||
|
const chunks = useMemo(() => parseChunks(content), [content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{chunks.map((chunk, index) => {
|
||||||
|
if (chunk.type === "code" && chunk.complete) {
|
||||||
|
return (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
key={index}
|
||||||
|
language={chunk.language || "text"}
|
||||||
|
style={vscDarkPlus}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chunk.content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
);
|
||||||
|
} else if (chunk.type === "code" && !chunk.complete) {
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
key={index}
|
||||||
|
className="bg-muted rounded-lg p-4 overflow-x-auto font-mono text-sm"
|
||||||
|
>
|
||||||
|
<code>{chunk.content}
|
||||||
|
{isStreaming && (
|
||||||
|
<span className="animate-pulse">▋</span>
|
||||||
|
)}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
key={index}
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p className="leading-relaxed">{children}</p>
|
||||||
|
),
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="text-2xl font-bold mt-4 mb-2">{children}</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="text-xl font-bold mt-3 mb-2">{children}</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="text-lg font-bold mt-2 mb-1">{children}</h3>
|
||||||
|
),
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => (
|
||||||
|
<li className="leading-relaxed">{children}</li>
|
||||||
|
),
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="border-l-4 border-muted-foreground pl-4 italic">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
table: ({ children }) => (
|
||||||
|
<table className="w-full border-collapse">{children}</table>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="border border-muted px-3 py-2 text-left font-semibold">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="border border-muted px-3 py-2">{children}</td>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chunk.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
73
web/src/components/TaskCard.tsx
Normal file
73
web/src/components/TaskCard.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Task, TaskStatus } from "../contexts/WebSocketContext";
|
||||||
|
import { Bot, Clock, CheckCircle, XCircle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: Task;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<TaskStatus, {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
}> = {
|
||||||
|
pending: {
|
||||||
|
icon: <Clock className="w-3 h-3" />,
|
||||||
|
color: "text-amber-600 dark:text-amber-400",
|
||||||
|
label: "等待中",
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||||
|
color: "text-blue-600 dark:text-blue-400",
|
||||||
|
label: "执行中",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: <CheckCircle className="w-3 h-3" />,
|
||||||
|
color: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
label: "已完成",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
icon: <XCircle className="w-3 h-3" />,
|
||||||
|
color: "text-red-600 dark:text-red-400",
|
||||||
|
label: "失败",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TaskCard: React.FC<TaskCardProps> = ({ task, onClick }) => {
|
||||||
|
const config = statusConfig[task.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className="
|
||||||
|
cursor-pointer flex items-center justify-between gap-3
|
||||||
|
px-4 py-3 rounded-xl
|
||||||
|
bg-card border border-border
|
||||||
|
transition-all duration-200
|
||||||
|
hover:shadow-md hover:border-border/80
|
||||||
|
active:scale-[0.98]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="p-1.5 rounded-lg bg-muted shrink-0">
|
||||||
|
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">
|
||||||
|
{task.agent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full bg-muted ${config.color} shrink-0`}>
|
||||||
|
{config.icon}
|
||||||
|
<span className="text-xs font-medium">{config.label}</span>
|
||||||
|
{task.status === "running" && (
|
||||||
|
<span className="relative flex h-1.5 w-1.5 ml-0.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-blue-500"></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
89
web/src/components/TaskModal.tsx
Normal file
89
web/src/components/TaskModal.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { Task, TaskStatus } from "../contexts/WebSocketContext";
|
||||||
|
import { Bot, Clock, CheckCircle, XCircle, Loader2, X } from "lucide-react";
|
||||||
|
import { StreamingMarkdown } from "./StreamingMarkdown";
|
||||||
|
|
||||||
|
interface TaskModalProps {
|
||||||
|
task: Task | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<TaskStatus, { icon: React.ReactNode; color: string; label: string }> = {
|
||||||
|
pending: {
|
||||||
|
icon: <Clock className="w-4 h-4" />,
|
||||||
|
color: "text-amber-600 dark:text-amber-400",
|
||||||
|
label: "等待中",
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||||
|
color: "text-blue-600 dark:text-blue-400",
|
||||||
|
label: "执行中",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: <CheckCircle className="w-4 h-4" />,
|
||||||
|
color: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
label: "已完成",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
icon: <XCircle className="w-4 h-4" />,
|
||||||
|
color: "text-red-600 dark:text-red-400",
|
||||||
|
label: "失败",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TaskModal: React.FC<TaskModalProps> = ({ task, onClose }) => {
|
||||||
|
if (!task) return null;
|
||||||
|
|
||||||
|
const config = statusConfig[task.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4 animate-in fade-in duration-200"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl max-h-[80vh] bg-background rounded-xl border border-border shadow-2xl flex flex-col animate-in zoom-in-95 duration-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg bg-secondary ${config.color}`}>
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{task.agent}</h3>
|
||||||
|
<div className={`flex items-center gap-1.5 ${config.color}`}>
|
||||||
|
{config.icon}
|
||||||
|
<span className="text-sm">{config.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg hover:bg-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4 border border-border/50">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
|
任务描述
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-foreground leading-relaxed">{task.task}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
|
执行结果
|
||||||
|
</h4>
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4 border border-border/50">
|
||||||
|
<StreamingMarkdown content={task.content} isStreaming={task.status === "running"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
25
web/src/components/ui/badge.tsx
Normal file
25
web/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: "default" | "secondary" | "destructive" | "outline";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Badge: React.FC<BadgeProps> = ({ children, variant = "default", className }) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||||||
|
variant === "default" && "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
variant === "secondary" && "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
variant === "destructive" && "bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
variant === "outline" && "border border-border text-foreground hover:bg-secondary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
492
web/src/contexts/WebSocketContext.tsx
Normal file
492
web/src/contexts/WebSocketContext.tsx
Normal file
@ -0,0 +1,492 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string;
|
||||||
|
agent?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentInfo {
|
||||||
|
id: string;
|
||||||
|
status: "idle" | "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
tools: number;
|
||||||
|
skills: number;
|
||||||
|
agents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskStatus = "pending" | "running" | "completed" | "failed";
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
agent: string;
|
||||||
|
task: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
content: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string;
|
||||||
|
message_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebSocketContextType {
|
||||||
|
messages: Message[];
|
||||||
|
stats: Stats;
|
||||||
|
agentList: AgentInfo[];
|
||||||
|
tasks: Task[];
|
||||||
|
sessions: SessionInfo[];
|
||||||
|
currentSessionId: string | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
sendMessage: (content: string) => void;
|
||||||
|
loadSession: (sessionId: string) => Promise<void>;
|
||||||
|
currentStreamingContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||||
|
|
||||||
|
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [stats, setStats] = useState<Stats>({ tools: 0, skills: 0, agents: 0 });
|
||||||
|
const [agentList, setAgentList] = useState<AgentInfo[]>([]);
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [currentStreamingContent, setCurrentStreamingContent] = useState("");
|
||||||
|
const currentStreamingContentRef = useRef("");
|
||||||
|
|
||||||
|
const updateStreamingContent = useCallback((content: string) => {
|
||||||
|
currentStreamingContentRef.current = content;
|
||||||
|
setCurrentStreamingContent(content);
|
||||||
|
}, []);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const currentAssistantIdRef = useRef<string | null>(null);
|
||||||
|
const currentTaskRef = useRef<Task | null>(null);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
const ws = new WebSocket("ws://localhost:8081/ws");
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleMessage(data);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseAgentCalls = useCallback((text: string): Array<{ agent: string; task: string }> => {
|
||||||
|
const results: Array<{ agent: string; task: string }> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const addResult = (agent: string, task: string) => {
|
||||||
|
const key = `${agent}:${task}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
results.push({ agent, task });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let searchText = text;
|
||||||
|
if (text.includes('```')) {
|
||||||
|
const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
|
||||||
|
if (codeBlockMatch) {
|
||||||
|
searchText = codeBlockMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayMatch = searchText.match(/\[[\s\S]*\]/);
|
||||||
|
if (arrayMatch) {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(arrayMatch[0]);
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
for (const item of arr) {
|
||||||
|
if (item.tool === "agent_call" && item.arguments) {
|
||||||
|
addResult(
|
||||||
|
item.arguments.agent || "unknown",
|
||||||
|
item.arguments.task || item.arguments.prompt || "执行任务"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.length > 0) return results;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
while (true) {
|
||||||
|
const idx = searchText.indexOf('"tool"', pos);
|
||||||
|
if (idx === -1) break;
|
||||||
|
|
||||||
|
let start = searchText.lastIndexOf('{', idx);
|
||||||
|
if (start === -1 || start < pos) {
|
||||||
|
pos = idx + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let braceCount = 0;
|
||||||
|
let end = start;
|
||||||
|
for (let i = start; i < searchText.length; i++) {
|
||||||
|
if (searchText[i] === '{') braceCount++;
|
||||||
|
if (searchText[i] === '}') braceCount--;
|
||||||
|
if (braceCount === 0) {
|
||||||
|
end = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braceCount !== 0) {
|
||||||
|
pos = idx + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonStr = searchText.slice(start, end);
|
||||||
|
const json = JSON.parse(jsonStr);
|
||||||
|
if (json.tool === "agent_call" && json.arguments) {
|
||||||
|
let taskText = json.arguments.task || json.arguments.prompt || "执行任务";
|
||||||
|
if (typeof taskText === 'string' && taskText.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const nestedJson = JSON.parse(taskText);
|
||||||
|
if (nestedJson.arguments && nestedJson.arguments.task) {
|
||||||
|
taskText = nestedJson.arguments.task;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addResult(json.arguments.agent || "unknown", taskText);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMessage = useCallback((data: any) => {
|
||||||
|
switch (data.type) {
|
||||||
|
case "token": {
|
||||||
|
const text = data.text || "";
|
||||||
|
const newContent = currentStreamingContentRef.current + text;
|
||||||
|
updateStreamingContent(newContent);
|
||||||
|
|
||||||
|
if (!currentTaskRef.current) {
|
||||||
|
const agentCalls = parseAgentCalls(newContent);
|
||||||
|
if (agentCalls.length > 0) {
|
||||||
|
const agentCall = agentCalls[0];
|
||||||
|
const task: Task = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
agent: agentCall.agent,
|
||||||
|
task: agentCall.task,
|
||||||
|
status: "pending",
|
||||||
|
content: "",
|
||||||
|
startTime: Date.now(),
|
||||||
|
};
|
||||||
|
currentTaskRef.current = task;
|
||||||
|
setTasks((prev) => [...prev, task]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTaskRef.current) {
|
||||||
|
currentTaskRef.current.content += text;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "agent_start": {
|
||||||
|
setAgentList((prev) =>
|
||||||
|
prev.map((a) => (a.id === data.agent ? { ...a, status: "running" as const } : a))
|
||||||
|
);
|
||||||
|
if (currentTaskRef.current) {
|
||||||
|
const taskId = currentTaskRef.current.id;
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === taskId ? { ...t, status: "running" as const } : t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "agent_end": {
|
||||||
|
setAgentList((prev) =>
|
||||||
|
prev.map((a) => (a.id === data.agent ? { ...a, status: "idle" as const } : a))
|
||||||
|
);
|
||||||
|
if (currentTaskRef.current) {
|
||||||
|
const taskId = currentTaskRef.current.id;
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === taskId
|
||||||
|
? { ...t, status: "completed" as const, endTime: Date.now() }
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
currentTaskRef.current = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "complete": {
|
||||||
|
setIsLoading(false);
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: currentAssistantIdRef.current || Date.now().toString(),
|
||||||
|
role: "assistant",
|
||||||
|
content: data.content || currentStreamingContentRef.current,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
currentAssistantIdRef.current = null;
|
||||||
|
updateStreamingContent("");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "error": {
|
||||||
|
setIsLoading(false);
|
||||||
|
if (currentTaskRef.current) {
|
||||||
|
const taskId = currentTaskRef.current.id;
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === taskId
|
||||||
|
? { ...t, status: "failed" as const, endTime: Date.now() }
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
currentTaskRef.current = null;
|
||||||
|
}
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: "system",
|
||||||
|
content: `Error: ${data.message}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
updateStreamingContent("");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "stats": {
|
||||||
|
setStats(data.stats);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "agents": {
|
||||||
|
setAgentList(data.agents);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "start": {
|
||||||
|
const task: Task = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
agent: data.agent || "unknown",
|
||||||
|
task: data.message || "执行任务",
|
||||||
|
status: "running",
|
||||||
|
content: "",
|
||||||
|
startTime: Date.now(),
|
||||||
|
};
|
||||||
|
setTasks((prev) => [...prev, task]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "end": {
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.agent === data.agent && t.status === "running"
|
||||||
|
? { ...t, status: data.status === "failed" ? "failed" : "completed", content: data.content || t.content, endTime: Date.now() }
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "agent_token": {
|
||||||
|
const text = data.content || "";
|
||||||
|
const agentName = data.agent || "";
|
||||||
|
if (!text || !agentName) break;
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.agent === agentName && t.status === "running"
|
||||||
|
? { ...t, content: t.content + text }
|
||||||
|
: t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/sessions");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data: SessionInfo[] = await res.json();
|
||||||
|
setSessions(data);
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to load sessions");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSession = useCallback(async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sessions/${sessionId}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data: { role: string; content: string; timestamp: string }[] = await res.json();
|
||||||
|
const loadedMessages: Message[] = data.map((msg, idx) => ({
|
||||||
|
id: `${sessionId}-${idx}`,
|
||||||
|
role: msg.role as "user" | "assistant" | "system",
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: new Date(msg.timestamp).getTime(),
|
||||||
|
}));
|
||||||
|
const historicalTasks: Task[] = [];
|
||||||
|
loadedMessages.forEach((msg) => {
|
||||||
|
if (msg.role === "assistant") {
|
||||||
|
const agentCalls = parseAgentCalls(msg.content);
|
||||||
|
agentCalls.forEach((agentCall) => {
|
||||||
|
historicalTasks.push({
|
||||||
|
id: `hist-${msg.id}-${agentCall.agent}`,
|
||||||
|
agent: agentCall.agent,
|
||||||
|
task: agentCall.task,
|
||||||
|
status: "completed",
|
||||||
|
content: msg.content,
|
||||||
|
startTime: msg.timestamp,
|
||||||
|
endTime: msg.timestamp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setMessages(loadedMessages);
|
||||||
|
setCurrentSessionId(sessionId);
|
||||||
|
setTasks(historicalTasks);
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to load session messages");
|
||||||
|
}
|
||||||
|
}, [parseAgentCalls]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback((content: string) => {
|
||||||
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
const userMsg: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
currentAssistantIdRef.current = (Date.now() + 1).toString();
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
|
setIsLoading(true);
|
||||||
|
updateStreamingContent("");
|
||||||
|
|
||||||
|
wsRef.current.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "chat",
|
||||||
|
message: content,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
loadSessions();
|
||||||
|
|
||||||
|
const mockData = () => {
|
||||||
|
const mockMessages: Message[] = [
|
||||||
|
{
|
||||||
|
id: "mock-1",
|
||||||
|
role: "user",
|
||||||
|
content: "帮我计算 123 × 456 并写个Python程序",
|
||||||
|
timestamp: Date.now() - 60000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mock-2",
|
||||||
|
role: "assistant",
|
||||||
|
content: "好的,我来帮您完成这两个任务。我将调用专门的Agent来处理。\n\n[\n {\"tool\": \"agent_call\", \"arguments\": {\"agent\": \"calculator\", \"task\": \"计算 123 × 456\"}},\n {\"tool\": \"agent_call\", \"arguments\": {\"agent\": \"coder\", \"task\": \"写一个Python程序,输出 '子Agent测试成功!'\"}}\n]\n\n计算结果:123 × 456 = 56088\n\nPython程序:\n\n```python\n# 子Agent测试程序\nprint('子Agent测试成功!')\n```",
|
||||||
|
timestamp: Date.now() - 30000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockTasks: Task[] = [
|
||||||
|
{
|
||||||
|
id: "mock-task-1",
|
||||||
|
agent: "calculator",
|
||||||
|
task: "计算 123 × 456",
|
||||||
|
status: "completed",
|
||||||
|
content: "",
|
||||||
|
startTime: Date.now() - 50000,
|
||||||
|
endTime: Date.now() - 45000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mock-task-2",
|
||||||
|
agent: "coder",
|
||||||
|
task: "写一个Python程序,输出 '子Agent测试成功!'",
|
||||||
|
status: "completed",
|
||||||
|
content: "",
|
||||||
|
startTime: Date.now() - 40000,
|
||||||
|
endTime: Date.now() - 35000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
setMessages(mockMessages);
|
||||||
|
setTasks(mockTasks);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(mockData, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connect, loadSessions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebSocketContext.Provider
|
||||||
|
value={{
|
||||||
|
messages,
|
||||||
|
stats,
|
||||||
|
agentList,
|
||||||
|
tasks,
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
isConnected,
|
||||||
|
isLoading,
|
||||||
|
sendMessage,
|
||||||
|
loadSession,
|
||||||
|
currentStreamingContent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</WebSocketContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocket() {
|
||||||
|
const context = useContext(WebSocketContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useWebSocket must be used within WebSocketProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
84
web/src/index.css
Normal file
84
web/src/index.css
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-foreground: #0a0a0f;
|
||||||
|
--color-card: #fafafa;
|
||||||
|
--color-primary: #0a0a0f;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #f0f0f5;
|
||||||
|
--color-secondary-foreground: #0a0a0f;
|
||||||
|
--color-muted: #f0f0f5;
|
||||||
|
--color-muted-foreground: #71717a;
|
||||||
|
--color-accent: #f0f0f5;
|
||||||
|
--color-accent-foreground: #0a0a0f;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-border: #e4e4e7;
|
||||||
|
--color-input: #e4e4e7;
|
||||||
|
--color-ring: #a1a1aa;
|
||||||
|
--radius-sm: 0.375rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
--radius-lg: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--color-background: #0a0a0f;
|
||||||
|
--color-foreground: #f8f8f8;
|
||||||
|
--color-card: #12121a;
|
||||||
|
--color-primary: #f8f8f8;
|
||||||
|
--color-primary-foreground: #0a0a0f;
|
||||||
|
--color-secondary: #1e1e2e;
|
||||||
|
--color-secondary-foreground: #f8f8f8;
|
||||||
|
--color-muted: #1e1e2e;
|
||||||
|
--color-muted-foreground: #7c7c8a;
|
||||||
|
--color-accent: #1e1e2e;
|
||||||
|
--color-accent-foreground: #f8f8f8;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #f8f8f8;
|
||||||
|
--color-border: #1e1e2e;
|
||||||
|
--color-input: #1e1e2e;
|
||||||
|
--color-ring: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-muted);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
6
web/src/lib/utils.ts
Normal file
6
web/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
41
web/tailwind.config.js
Normal file
41
web/tailwind.config.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{ts,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
||||||
25
web/tsconfig.app.json
Normal file
25
web/tsconfig.app.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
web/tsconfig.node.json
Normal file
24
web/tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
18
web/vite.config.ts
Normal file
18
web/vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8081',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8081',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user