Compare commits

..

10 Commits

65 changed files with 12285 additions and 215 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,6 +1,7 @@
package main
import (
"flag"
"fmt"
"log"
"os"
@ -8,15 +9,26 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/orca/orca/internal/config"
"github.com/orca/orca/internal/tui"
"github.com/orca/orca/internal/websocket"
"github.com/orca/orca/pkg/kernel"
"github.com/orca/orca/pkg/session"
)
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()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if *memoryCmd {
handleMemoryCommand(flag.Args())
return
}
k := kernel.NewWithConfig(cfg)
if err := k.Start(); err != nil {
log.Fatalf("Failed to start kernel: %v", err)
@ -26,6 +38,13 @@ func main() {
log.Printf("Warning: failed to load skills: %v", err)
}
if *webMode {
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())
@ -33,8 +52,58 @@ func main() {
fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err)
os.Exit(1)
}
}
if err := k.Stop(); err != nil {
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
View File

@ -3,41 +3,41 @@ module github.com/orca/orca
go 1.26.1
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/BurntSushi/toml v1.6.0
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/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/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/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/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // 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/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/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // 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/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // 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/sys v0.42.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
View File

@ -1,37 +1,25 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
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/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
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/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
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/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
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/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/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/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/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/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
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/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
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/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
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/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
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/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/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/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/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/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
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/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/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
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/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/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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/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=

View File

@ -29,6 +29,10 @@ type Config struct {
Sandbox SandboxConfig `toml:"sandbox"`
Session SessionConfig `toml:"session"`
Agent AgentConfig `toml:"agent"`
Embedding EmbeddingConfig `toml:"embedding"`
Memory MemoryConfig `toml:"memory"`
MemoryAgent MemoryAgentConfig `toml:"memory_agent"`
SiliconFlow SiliconConfig `toml:"siliconflow"`
}
// OllamaConfig 保存 Ollama LLM 后端的设置。
@ -59,6 +63,68 @@ type SessionConfig struct {
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 身份和行为的配置。
type AgentConfig struct {
// Role 是 Agent 的角色标识,如 "assistant", "coder", "reviewer" 等。
@ -105,9 +171,71 @@ func DefaultConfig() *Config {
SystemPrompt: "",
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 配置文件。
// 如果文件不存在,返回默认配置。
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)
}
cfg.expandPaths()
return cfg, nil
}

View File

@ -212,17 +212,18 @@ func (m *Model) updateLayout() {
rightWidth := 42
leftWidth := m.width - rightWidth - 3
headerHeight := 3
mainHeight := m.height - headerHeight - 1
inputHeight := 5
chatHeight := mainHeight - inputHeight
if chatHeight < 10 {
chatHeight = 10
headerHeight := 2
mainHeight := m.height - headerHeight
if mainHeight < 10 {
mainHeight = 10
}
chatHeight := mainHeight - 9
if chatHeight < 5 {
chatHeight = 5
}
m.textarea.SetWidth(leftWidth)
m.viewport.Width = leftWidth
m.viewport.Height = chatHeight
if !m.ready {
m.viewport = viewport.New(leftWidth, chatHeight)
@ -265,33 +266,18 @@ func (m Model) View() string {
return "Initializing..."
}
rightWidth := 42
leftWidth := m.width - rightWidth - 3
rightWidth := 40
leftWidth := m.width - rightWidth - 2
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())
leftPanel := lipgloss.JoinVertical(lipgloss.Left, chatBox, inputBox)
stats := m.renderStats()
agents := m.renderAgents()
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("")
empty := boxStyle.Width(rightWidth - 2).Height(m.height - lipgloss.Height(stats) - lipgloss.Height(agents) - 5).Render("")
rightPanel := lipgloss.JoinVertical(lipgloss.Left, stats, agents, empty)
if m.loading {
@ -299,19 +285,21 @@ func (m Model) View() string {
}
body := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
return lipgloss.JoinVertical(lipgloss.Left, header, body)
return header + "\n" + body
}
func (m Model) renderHeader() string {
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().
Width(m.width).
Padding(0, 1).
BorderBottom(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(colors.border)).
Render(title)
Bold(true).
Foreground(lipgloss.Color(colors.primary)).
Render(title) + "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color(colors.border)).
Render(line)
}
func (m Model) renderStats() string {
@ -319,17 +307,23 @@ func (m Model) renderStats() string {
b.WriteString(titleStyle.Render("Statistics") + "\n\n")
tools := 0
if m.kernel != nil {
if tm := m.kernel.ToolManager(); tm != nil {
tools = tm.Count()
}
}
skills := 0
if m.kernel != nil {
if sm := m.kernel.SkillManager(); sm != nil {
skills = len(sm.ListSkills())
}
}
agents := 0
if m.kernel != nil {
if as := m.kernel.ActorSystem(); as != nil {
agents = as.AgentCount()
}
}
b.WriteString(statLabelStyle.Render("Tools: "))
b.WriteString(statValueStyle.Render(fmt.Sprintf("%d", tools)) + "\n")
@ -345,6 +339,7 @@ func (m Model) renderAgents() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Active Agents") + "\n\n")
if m.kernel != nil {
if as := m.kernel.ActorSystem(); as != nil {
for _, info := range as.AgentInfos() {
status := "idle"
@ -356,6 +351,7 @@ func (m Model) renderAgents() string {
b.WriteString(fmt.Sprintf("• %s: %s\n", info.ID, style.Render(status)))
}
}
}
return boxStyle.Width(38).Render(b.String())
}

View File

@ -1,8 +1,11 @@
package tui
import (
"strings"
"testing"
"time"
"github.com/charmbracelet/bubbles/textarea"
)
func TestEventWriter(t *testing.T) {
@ -74,3 +77,26 @@ func TestModelFormatMessage(t *testing.T) {
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))])
}
}

View 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
}

View File

@ -7,6 +7,7 @@ import (
"io"
"strings"
"sync"
"time"
"github.com/orca/orca/pkg/bus"
"github.com/orca/orca/pkg/llm"
@ -33,6 +34,7 @@ type LLMAgent struct {
streamWriter io.Writer
systemPrompt string
subAgents map[string]string
memoryManager *session.MemoryManager
}
// 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.
// The agent is started automatically upon creation.
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)
}
// 处理特殊命令
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
if a.sessionMgr != nil && a.sessionID != "" {
// 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)
}
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 {
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)
}
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{
ID: msg.ID + "-response",
Type: bus.MsgTypeTaskResponse,
@ -196,8 +233,25 @@ func (a *LLMAgent) handleUserMessage(ctx context.Context, msg bus.Message) (bus.
}, 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)
if stats == nil {
stats = &contextStats{}
}
// 1. 用户自定义 system prompt配置式身份描述
if a.systemPrompt != "" {
@ -205,6 +259,7 @@ func (a *LLMAgent) buildLLMMessages() []llm.Message {
Role: "system",
Content: a.systemPrompt,
})
stats.systemPromptTokens = estimateTokens(a.systemPrompt)
}
// 2. 运行时工具说明(动态生成)
@ -214,17 +269,28 @@ func (a *LLMAgent) buildLLMMessages() []llm.Message {
Role: "system",
Content: toolPrompt,
})
stats.toolPromptTokens = estimateTokens(toolPrompt)
}
if a.sessionMgr == nil || a.sessionID == "" {
return messages
if a.memoryManager != nil && a.sessionID != "" {
if a.memoryManager.ShouldInjectMemory(a.sessionID, query) {
memoryCtx, memStats := a.memoryManager.BuildMemoryContextWithStats(a.sessionID, query)
if memoryCtx != "" {
messages = append(messages, llm.Message{
Role: "system",
Content: memoryCtx,
})
stats.memoryTokens = memStats.TotalTokens
stats.memoryShortTerm = memStats.ShortTermCount
stats.memoryLongTerm = memStats.LongTermCount
}
}
}
if a.sessionMgr != nil && a.sessionID != "" {
sessionMsgs, err := a.sessionMgr.GetContext(a.sessionID, a.windowSize)
if err != nil {
return messages
}
if err == nil {
stats.historyCount = len(sessionMsgs)
for _, sm := range sessionMsgs {
msg := llm.Message{
Role: string(sm.Role),
@ -234,21 +300,91 @@ func (a *LLMAgent) buildLLMMessages() []llm.Message {
msg.ToolCallID = sm.Metadata["tool_call_id"]
}
messages = append(messages, msg)
stats.historyTokens += estimateTokens(sm.Content)
}
}
}
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 生成工具说明提示词(不包含身份描述)。
// 将可用工具和调用规则注入给 LLM支持基于提示词的工具调用。
func (a *LLMAgent) buildToolPrompt() string {
var b strings.Builder
if a.toolManager != nil {
tools := a.toolManager.List()
b.WriteString("你可以使用以下工具来完成用户的请求。\n\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("描述: %s\n", t.Description()))
paramsJSON, _ := json.Marshal(t.Parameters())
@ -261,7 +397,8 @@ func (a *LLMAgent) buildToolPrompt() string {
b.WriteString("2. 如果需要同时调用多个工具(并行执行),请输出 JSON 数组格式:\n")
b.WriteString(` [{"tool": "工具名1", "arguments": {...}}, {"tool": "工具名2", "arguments": {...}}]` + "\n")
b.WriteString("3. 如果你已经看到了工具返回的结果,请直接根据结果回答用户,不要再次调用工具。\n")
b.WriteString("4. 如果你不需要调用工具,请直接回复用户。\n")
b.WriteString("4. 当你不需要调用工具时,请直接回复用户。\n")
b.WriteString("5. 当用户的请求涉及代码、架构、数学计算等专业领域时你必须调用相应的子Agent不要自己直接回答。\n")
}
if len(a.subAgents) > 0 {
@ -272,7 +409,8 @@ func (a *LLMAgent) buildToolPrompt() string {
b.WriteString("\n调用方式使用 agent_call 工具,指定 agent 名称和任务描述。\n")
b.WriteString("示例:{\"tool\": \"agent_call\", \"arguments\": {\"agent\": \"coder\", \"task\": \"写个快速排序\"}}\n")
b.WriteString("如果用户有多个独立任务,请同时调用多个 agent_callJSON数组格式让它们并行执行。\n")
b.WriteString("\n重要当用户的请求涉及上述专业领域时你必须调用相应的子Agent不要自己直接回答。\n")
b.WriteString("\n【强制规则】当用户的请求涉及代码编程、系统架构、数学计算、代码审查等专业领域时你必须调用相应的子Agent。\n")
b.WriteString("你绝对不能自己直接回答编程或架构问题,必须通过 agent_call 工具委托给专业Agent处理。\n")
}
if a.skillManager != nil {
@ -305,6 +443,47 @@ func (a *LLMAgent) buildToolPrompt() 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) {
maxRounds := 10
@ -332,6 +511,7 @@ func (a *LLMAgent) chatWithToolLoop(ctx context.Context, messages []llm.Message)
})
results := a.executeToolCallsParallel(ctx, toolCalls)
for _, result := range results {
messages = append(messages, llm.Message{
Role: "user",
@ -452,20 +632,35 @@ func (a *LLMAgent) parseToolCallsFromContent(content string) []llm.ToolCall {
}
func (a *LLMAgent) extractJSONFromMarkdown(content string) string {
start := strings.Index(content, "```")
start := strings.Index(content, "`"+"``json")
if start == -1 {
start = strings.Index(content, "`"+"``")
if start == -1 {
return content
}
start = strings.Index(content[start:], "\n")
if start == -1 {
return content
} else {
start += 7
}
start++
end := strings.LastIndex(content[start:], "```")
if !strings.HasPrefix(content[start:], "`"+"``") {
newline := strings.Index(content[start:], "\n")
if newline != -1 {
start = start + newline + 1
}
} else {
start += 3
newline := strings.Index(content[start:], "\n")
if newline != -1 {
start = start + newline + 1
}
}
end := strings.Index(content[start:], "\n`"+"``")
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])

View 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)
}

View File

@ -5,17 +5,27 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/google/uuid"
"github.com/orca/orca/pkg/bus"
"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 {
*BaseAgent
llmBackend llm.LLM
systemPrompt string
role string
streamWriter io.Writer
store SubAgentStore
parentSessionID string
}
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 {
sa := &SubAgent{
BaseAgent: NewBaseAgent(id, "subagent"),
@ -71,6 +93,10 @@ func (sa *SubAgent) SetStreamWriter(w io.Writer) {
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) {
switch msg.Type {
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) {
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{
{
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)
}
if sa.store != nil {
sa.store.SaveSubAgentMessage(parentSessionID, sessionID, sa.ID(), session.SessionMessage{
Role: session.RoleAssistant,
Content: content,
Timestamp: time.Now(),
})
}
return bus.Message{
ID: msg.ID + "-response",
Type: bus.MsgTypeTaskResponse,
@ -108,6 +161,8 @@ func (sa *SubAgent) handleTask(ctx context.Context, msg bus.Message) (bus.Messag
Metadata: map[string]string{
"processed_by": sa.ID(),
"agent_role": sa.role,
"session_id": sessionID,
"parent_session_id": parentSessionID,
},
}, nil
}

124
pkg/embedding/client.go Normal file
View 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
}

View File

@ -13,10 +13,12 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/orca/orca/internal/config"
"github.com/orca/orca/pkg/actor"
"github.com/orca/orca/pkg/bus"
"github.com/orca/orca/pkg/embedding"
"github.com/orca/orca/pkg/llm"
"github.com/orca/orca/pkg/plugin"
"github.com/orca/orca/pkg/session"
@ -24,16 +26,6 @@ import (
"github.com/orca/orca/pkg/tool"
)
// Kernel 是 Orca 框架的微内核核心。
//
// 它编排插件生命周期、消息路由和组件间通信。
// 内核初始化并管理以下组件:
// - 消息总线,用于组件间通信
// - 插件注册表,支持扩展
// - 会话管理器,用于对话持久化
// - 工具管理器,包含内置工具
// - 技能管理器,用于基于技能的自动化
// - Actor 系统,包含编排器、工作者和 LLM 代理
type Kernel struct {
mu sync.RWMutex
mb bus.MessageBus
@ -41,9 +33,10 @@ type Kernel struct {
plugins []plugin.Plugin
started bool
// Integration components
config *config.Config
sessionMgr *session.Manager
sessionStore *session.SQLiteStore
memoryManager *session.MemoryManager
toolMgr *tool.Manager
skillMgr *skill.Manager
actorSystem *actor.System
@ -78,12 +71,34 @@ func NewWithConfig(cfg *config.Config) *Kernel {
subAgents: make(map[string]actor.Agent),
}
// Initialize session manager
store, err := session.NewJSONLStore(cfg.Session.StorageDir)
storageDir := expandHomeDir(cfg.Session.StorageDir)
dbPath := filepath.Join(storageDir, "orcasession.db")
store, err := session.NewSQLiteStore(dbPath)
if err != nil {
log.Printf("kernel: warning: failed to create session store: %v", err)
} else {
k.sessionStore = store
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
@ -137,6 +152,9 @@ func (k *Kernel) initializeActorSystem() {
k.createSubAgents(ollama)
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 {
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),
}
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))
}
@ -170,6 +191,10 @@ func (k *Kernel) initializeActorSystem() {
)
}
if k.memoryManager != nil {
llmOpts = append(llmOpts, actor.WithMemoryManager(k.memoryManager))
}
if len(k.subAgents) > 0 {
agentDescs := make(map[string]string)
for name, agent := range k.subAgents {
@ -183,16 +208,20 @@ func (k *Kernel) initializeActorSystem() {
llmAgent := actor.NewLLMAgent(llmAgentID, ollama, llmOpts...)
k.llmAgent = llmAgent
if k.memoryManager != nil {
k.memoryManager.SetLLM(ollama)
}
k.orch.AddWorker(llmAgent)
k.orch.AddWorker(tw)
k.orch.SetDefaultWorker(llmAgent)
}
func (k *Kernel) createSubAgents(llmBackend llm.LLM) {
promptDir := expandHomeDir("~/.orca/prompts")
entries, err := os.ReadDir(promptDir)
agentsDir := expandHomeDir("~/.orca/agents")
entries, err := os.ReadDir(agentsDir)
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
}
@ -204,31 +233,30 @@ func (k *Kernel) createSubAgents(llmBackend llm.LLM) {
if !strings.HasSuffix(name, ".md") {
continue
}
if name == "assistant.md" {
continue
}
agentName := strings.TrimSuffix(name, ".md")
promptPath := filepath.Join(promptDir, name)
content, err := os.ReadFile(promptPath)
agentPath := filepath.Join(agentsDir, name)
content, err := os.ReadFile(agentPath)
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
}
prompt := string(content)
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
}
agent := actor.NewSubAgent(agentName, llmBackend,
actor.WithSubAgentRole(agentName),
actor.WithSubAgentSystemPrompt(prompt),
actor.WithSubAgentStore(k.sessionStore),
actor.WithSubAgentParentSessionID("default"),
)
k.subAgents[agentName] = 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))
@ -330,6 +358,11 @@ func (k *Kernel) LLMAgent() *actor.LLMAgent {
return k.llmAgent
}
// MemoryManager 返回记忆管理器。
func (k *Kernel) MemoryManager() *session.MemoryManager {
return k.memoryManager
}
// SetStreamWriter 设置用于流式 LLM 输出的写入器。
func (k *Kernel) SetStreamWriter(w io.Writer) {
if k.llmAgent != nil {

View File

@ -160,6 +160,52 @@ func (c *DeepSeekClient) Stream(ctx context.Context, messages []Message, handler
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 {
return deepSeekChatRequest{
Model: c.model,
@ -172,6 +218,7 @@ type deepSeekChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
Tools []ToolDef `json:"tools,omitempty"`
}
type deepSeekChatResponse struct {

View File

@ -17,6 +17,10 @@ type LLM interface {
// If the model decides to call tools, the response contains ToolCalls.
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.
// The handler is called for each chunk. The final response is not
// collected; use Chat for complete responses.

View File

@ -79,6 +79,10 @@ func NewOllamaClient(opts ...OllamaOption) *OllamaClient {
// Chat sends a chat request to Ollama and returns the complete response.
// If the Ollama model returns tool calls, they are parsed and included
// 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) {
req := OllamaChatRequest{
Model: c.model,

View File

@ -14,6 +14,7 @@ type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// ToolCall represents a function calling request from the LLM.

View 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, &timestampStr, &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
View 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 APIRepresentational 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
View 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, &timestampStr, &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, &timestampStr); 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
View 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, ",") + "]"
}

View File

@ -3,22 +3,47 @@ package tool
import (
"context"
"fmt"
"io"
"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 {
Process(ctx context.Context, msg bus.Message) (bus.Message, error)
}
type agentCallTool struct {
agentRegistry func(string) (Agent, bool)
eventBus bus.MessageBus
}
func NewAgentCallTool(registry func(string) (Agent, bool)) Tool {
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) Description() string {
@ -63,7 +88,39 @@ func (t *agentCallTool) Execute(ctx context.Context, args map[string]interface{}
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)
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 {
return ErrorResult(fmt.Sprintf("agent '%s' execution failed: %v", agentName, err)), nil
}

11
server.log Normal file
View 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
View File

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

Binary file not shown.

BIN
test_memory Executable file

Binary file not shown.

227
test_memory_retrieval.go Normal file
View 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
View 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

Binary file not shown.

View 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: MemoryExtractorAgent2-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
```

View 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**:使用硅基流动 APIPro/BAAI/bge-m31024维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-m31024维
- **优化策略**
- 只对用户消息和完整 Assistant 回复生成向量(跳过短消息 < 10
- 异步生成:消息先存 SQLiteEmbedding 后台 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-m31024维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 读取

View 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
View 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:
[PARSE_ERROR] Error: Identifier `currentStreamingContentRef` has already been declared
╭─[ src/contexts/WebSocketContext.tsx:66:9 ]
│
 66 │   const currentStreamingContentRef = useRef("");
 │ ─────────────┬────────────
 │ ╰────────────── `currentStreamingContentRef` has already been declared here
 │
321 │   const currentStreamingContentRef = useRef(currentStreamingContent);
 │ ─────────────┬────────────
 │ ╰────────────── It can not be redeclared here
─────╯
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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

44
web/package.json Normal file
View 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
View File

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
web/src/assets/react.svg Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
web/tsconfig.node.json Normal file
View 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
View 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,
},
},
},
})