mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
fix: correct context pruning order and vector alignment
- Prune context BEFORE processing (LSTM forget gate pattern) so LLM only sees relevant context, instead of pruning after the fact - Fix ensureTrained() to recompute all event vectors after retraining vectorizer, fixing feature-space mismatch between stored vectors and query vector that made relevance scoring effectively random - Add read lock to knowledge BuildTree() (data race fix) - Log writeIndex() errors instead of discarding them - Fix TOCTOU race in document ContextToDoc() dedup - Fix healthcheck timing (measure elapsed before cleanup) - Refactor waiter CLI into separate files (state, conn, config, editor, history, builtin) for maintainability - Add CLI plugin API key authentication
This commit is contained in:
70
cmd/waiter/builtin.go
Normal file
70
cmd/waiter/builtin.go
Normal file
@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func()) bool {
|
||||
switch {
|
||||
case cmd == "/help":
|
||||
fmt.Println(`Built-in commands:
|
||||
/help show this help
|
||||
/exit, /quit exit waiter
|
||||
/clear clear screen
|
||||
/reconnect force reconnection
|
||||
/connect <path> switch to a different unix socket
|
||||
/remote <url> switch to remote HTTP mode
|
||||
/local switch back to local socket mode
|
||||
|
||||
Server commands (sent to agent):
|
||||
/status system status
|
||||
/kernel kernel status
|
||||
/settings [prefix] list settings
|
||||
/settings set <k> <v> set a setting
|
||||
/plugin list list installed plugins
|
||||
/plugin install <url> install plugin
|
||||
/plugin remove <name> remove plugin
|
||||
/plugin info <name> plugin details
|
||||
/memory query <text> query graph memory
|
||||
/knowledge list knowledge base
|
||||
/agents list agents
|
||||
/chat <text> send to agent
|
||||
|
||||
Any other text is sent to the agent directly.`)
|
||||
return true
|
||||
|
||||
case cmd == "/exit" || cmd == "/quit":
|
||||
return true
|
||||
|
||||
case cmd == "/clear":
|
||||
fmt.Print("\033[H\033[2J")
|
||||
return true
|
||||
|
||||
case cmd == "/reconnect":
|
||||
printlnC(colorYellow, "reconnecting...")
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/connect "):
|
||||
cfg.Socket = strings.TrimSpace(cmd[9:])
|
||||
cfg.Remote = ""
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/remote "):
|
||||
cfg.Remote = strings.TrimSpace(cmd[8:])
|
||||
cfg.Socket = ""
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
case cmd == "/local":
|
||||
cfg.Remote = ""
|
||||
cfg.Socket = discoverSocket("")
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
74
cmd/waiter/config.go
Normal file
74
cmd/waiter/config.go
Normal file
@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Socket string `yaml:"socket"`
|
||||
Remote string `yaml:"remote"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
}
|
||||
|
||||
func discoverConfig(configPath string) *Config {
|
||||
if configPath != "" {
|
||||
if cfg := readFile(configPath); cfg != nil {
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
|
||||
candidates := configCandidates()
|
||||
for _, p := range candidates {
|
||||
if cfg := readFile(p); cfg != nil {
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
|
||||
return &Config{}
|
||||
}
|
||||
|
||||
func configCandidates() []string {
|
||||
var cands []string
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
cands = append(cands, filepath.Join(home, ".config", "homeagent", "waiter.yaml"))
|
||||
}
|
||||
|
||||
cands = append(cands, filepath.Join(".", "waiter.yaml"))
|
||||
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
cands = append(cands, filepath.Join(filepath.Dir(exe), "waiter.yaml"))
|
||||
}
|
||||
|
||||
return cands
|
||||
}
|
||||
|
||||
func readFile(path string) *Config {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
return &cfg
|
||||
}
|
||||
|
||||
func (c *Config) MergeCLI(socket, remote, apiKey string) {
|
||||
if socket != "" {
|
||||
c.Socket = socket
|
||||
}
|
||||
if remote != "" {
|
||||
c.Remote = remote
|
||||
}
|
||||
if apiKey != "" {
|
||||
c.APIKey = apiKey
|
||||
}
|
||||
}
|
||||
141
cmd/waiter/conn.go
Normal file
141
cmd/waiter/conn.go
Normal file
@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Conn interface {
|
||||
Send(line string) error
|
||||
ReadLine() (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
func dial(cfg *Config) (Conn, error) {
|
||||
if cfg.Remote != "" {
|
||||
return dialRemote(cfg.Remote, cfg.APIKey)
|
||||
}
|
||||
return dialLocal(cfg.Socket, cfg.APIKey)
|
||||
}
|
||||
|
||||
func dialLocal(socket, apiKey string) (Conn, error) {
|
||||
if socket == "" {
|
||||
socket = discoverSocket("")
|
||||
}
|
||||
c, err := net.DialTimeout("unix", socket, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unix %s: %w", socket, err)
|
||||
}
|
||||
lc := &localConn{conn: c, r: bufio.NewReader(c)}
|
||||
if apiKey != "" {
|
||||
if err := lc.Send("/auth " + apiKey); err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("auth send: %w", err)
|
||||
}
|
||||
line, err := lc.ReadLine()
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("auth read: %w", err)
|
||||
}
|
||||
if strings.Contains(line, "unauthorized") {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("auth rejected: %s", line)
|
||||
}
|
||||
}
|
||||
return lc, nil
|
||||
}
|
||||
|
||||
type localConn struct {
|
||||
conn net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *localConn) Send(line string) error {
|
||||
_, err := fmt.Fprintf(c.conn, "%s\n", line)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *localConn) ReadLine() (string, error) {
|
||||
s, err := c.r.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSuffix(s, "\n"), nil
|
||||
}
|
||||
|
||||
func (c *localConn) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func dialRemote(baseURL, apiKey string) (Conn, error) {
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
return &remoteConn{url: baseURL + "/api/v1/chat", apiKey: apiKey}, nil
|
||||
}
|
||||
|
||||
type remoteConn struct {
|
||||
url string
|
||||
apiKey string
|
||||
mu sync.Mutex
|
||||
buf []string
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *remoteConn) Send(line string) error {
|
||||
req, err := http.NewRequest("POST", c.url, strings.NewReader(
|
||||
fmt.Sprintf(`{"message":%q}`, line),
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("X-API-Key", c.apiKey)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
content, _ := result["response"].(string)
|
||||
|
||||
c.mu.Lock()
|
||||
c.buf = append(c.buf, content)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *remoteConn) ReadLine() (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for len(c.buf) == 0 && !c.closed {
|
||||
c.mu.Unlock()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
c.mu.Lock()
|
||||
}
|
||||
if c.closed && len(c.buf) == 0 {
|
||||
return "", io.EOF
|
||||
}
|
||||
s := c.buf[0]
|
||||
c.buf = c.buf[1:]
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *remoteConn) Close() error {
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
188
cmd/waiter/editor.go
Normal file
188
cmd/waiter/editor.go
Normal file
@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LineEditor struct {
|
||||
buf []rune
|
||||
pos int
|
||||
hist *History
|
||||
histI int
|
||||
pending string
|
||||
}
|
||||
|
||||
func newLineEditor(h *History) *LineEditor {
|
||||
return &LineEditor{hist: h, histI: -1}
|
||||
}
|
||||
|
||||
func (e *LineEditor) clear() {
|
||||
e.buf = e.buf[:0]
|
||||
e.pos = 0
|
||||
e.histI = -1
|
||||
}
|
||||
|
||||
func (e *LineEditor) redrawPending(text string) {
|
||||
e.pending = text
|
||||
}
|
||||
|
||||
func (e *LineEditor) read() (string, error) {
|
||||
if e.pending != "" {
|
||||
t := e.pending
|
||||
e.pending = ""
|
||||
return t, nil
|
||||
}
|
||||
|
||||
e.buf = e.buf[:0]
|
||||
e.pos = 0
|
||||
e.histI = -1
|
||||
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
b := make([]byte, 1)
|
||||
_, err := in.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch b[0] {
|
||||
case '\r', '\n':
|
||||
fmt.Print("\n")
|
||||
return string(e.buf), nil
|
||||
|
||||
case 0x03:
|
||||
fmt.Print("^C\n")
|
||||
os.Exit(130)
|
||||
return "", nil
|
||||
|
||||
case 0x04:
|
||||
if len(e.buf) == 0 {
|
||||
return "", io.EOF
|
||||
}
|
||||
continue
|
||||
|
||||
case 0x08, 0x7f:
|
||||
if e.pos > 0 {
|
||||
e.pos--
|
||||
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
|
||||
e.redraw()
|
||||
}
|
||||
|
||||
case 0x1b:
|
||||
seq := make([]byte, 2)
|
||||
if _, err := io.ReadFull(in, seq); err != nil {
|
||||
continue
|
||||
}
|
||||
if seq[0] != '[' {
|
||||
continue
|
||||
}
|
||||
switch seq[1] {
|
||||
case 'A':
|
||||
e.historyPrev()
|
||||
case 'B':
|
||||
e.historyNext()
|
||||
case 'C':
|
||||
if e.pos < len(e.buf) {
|
||||
e.pos++
|
||||
e.redraw()
|
||||
}
|
||||
case 'D':
|
||||
if e.pos > 0 {
|
||||
e.pos--
|
||||
e.redraw()
|
||||
}
|
||||
case 'H', '1':
|
||||
if seq[1] == '1' {
|
||||
io.ReadFull(in, make([]byte, 1))
|
||||
}
|
||||
e.pos = 0
|
||||
e.redraw()
|
||||
case 'F', '4':
|
||||
if seq[1] == '4' {
|
||||
io.ReadFull(in, make([]byte, 1))
|
||||
}
|
||||
e.pos = len(e.buf)
|
||||
e.redraw()
|
||||
case '3':
|
||||
io.ReadFull(in, make([]byte, 1))
|
||||
if e.pos < len(e.buf) {
|
||||
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
|
||||
e.redraw()
|
||||
}
|
||||
}
|
||||
|
||||
case '\t':
|
||||
e.doCompletion()
|
||||
|
||||
default:
|
||||
if b[0] >= 0x20 {
|
||||
e.buf = append(e.buf, 0)
|
||||
copy(e.buf[e.pos+1:], e.buf[e.pos:])
|
||||
e.buf[e.pos] = rune(b[0])
|
||||
e.pos++
|
||||
e.redraw()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *LineEditor) historyPrev() {
|
||||
all := e.hist.all()
|
||||
if len(all) == 0 {
|
||||
return
|
||||
}
|
||||
if e.histI == -1 {
|
||||
e.histI = len(all) - 1
|
||||
} else if e.histI > 0 {
|
||||
e.histI--
|
||||
}
|
||||
e.buf = []rune(all[e.histI])
|
||||
e.pos = len(e.buf)
|
||||
e.redraw()
|
||||
}
|
||||
|
||||
func (e *LineEditor) historyNext() {
|
||||
if e.histI == -1 {
|
||||
return
|
||||
}
|
||||
all := e.hist.all()
|
||||
e.histI++
|
||||
if e.histI >= len(all) {
|
||||
e.histI = -1
|
||||
e.buf = e.buf[:0]
|
||||
e.pos = 0
|
||||
} else {
|
||||
e.buf = []rune(all[e.histI])
|
||||
e.pos = len(e.buf)
|
||||
}
|
||||
e.redraw()
|
||||
}
|
||||
|
||||
func (e *LineEditor) doCompletion() {
|
||||
cmds := []string{"/help", "/exit", "/quit", "/clear", "/reconnect", "/connect ", "/remote ", "/local",
|
||||
"/status", "/kernel", "/settings ", "/settings set ", "/chat ",
|
||||
"/plugin ", "/plugin list", "/plugin install ", "/plugin remove ", "/plugin info ",
|
||||
"/memory ", "/memory query ", "/knowledge", "/agents"}
|
||||
prefix := string(e.buf)
|
||||
for _, c := range cmds {
|
||||
if strings.HasPrefix(c, prefix) && c != prefix {
|
||||
e.buf = []rune(c)
|
||||
e.pos = len(e.buf)
|
||||
e.redraw()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *LineEditor) redraw() {
|
||||
fmt.Print("\r\033[K")
|
||||
fmt.Print(string(e.buf))
|
||||
if e.pos < len(e.buf) {
|
||||
skip := len(e.buf) - e.pos
|
||||
fmt.Printf("\033[%dD", skip)
|
||||
}
|
||||
}
|
||||
58
cmd/waiter/history.go
Normal file
58
cmd/waiter/history.go
Normal file
@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type History struct {
|
||||
path string
|
||||
lines []string
|
||||
max int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newHistory(path string, max int) History {
|
||||
return History{path: path, max: max}
|
||||
}
|
||||
|
||||
func (h *History) load() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
data, err := os.ReadFile(h.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.lines = strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(h.lines) > h.max {
|
||||
h.lines = h.lines[len(h.lines)-h.max:]
|
||||
}
|
||||
}
|
||||
|
||||
func (h *History) save() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
data := strings.Join(h.lines, "\n") + "\n"
|
||||
os.WriteFile(h.path, []byte(data), 0644)
|
||||
}
|
||||
|
||||
func (h *History) add(line string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if len(h.lines) > 0 && h.lines[len(h.lines)-1] == line {
|
||||
return
|
||||
}
|
||||
h.lines = append(h.lines, line)
|
||||
if len(h.lines) > h.max {
|
||||
h.lines = h.lines[len(h.lines)-h.max:]
|
||||
}
|
||||
}
|
||||
|
||||
func (h *History) all() []string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
r := make([]string, len(h.lines))
|
||||
copy(r, h.lines)
|
||||
return r
|
||||
}
|
||||
@ -1,18 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
@ -27,6 +23,24 @@ const (
|
||||
colorDim = "\033[2m"
|
||||
)
|
||||
|
||||
const clearLine = "\033[2K\r"
|
||||
|
||||
var colors = true
|
||||
|
||||
func init() {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
colors = false
|
||||
}
|
||||
}
|
||||
|
||||
func printlnC(color, msg string) {
|
||||
if !colors {
|
||||
fmt.Println(msg)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s%s%s\n", color, msg, colorReset)
|
||||
}
|
||||
|
||||
func historyPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
xdgData := os.Getenv("XDG_DATA_HOME")
|
||||
@ -61,110 +75,51 @@ func discoverSocket(configured string) string {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
type respLine struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
var colors = true
|
||||
|
||||
func init() {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
colors = false
|
||||
}
|
||||
}
|
||||
|
||||
func printlnC(color, msg string) {
|
||||
if !colors {
|
||||
fmt.Println(msg)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s%s%s\n", color, msg, colorReset)
|
||||
}
|
||||
|
||||
func main() {
|
||||
socket := flag.String("socket", "", "unix socket path")
|
||||
remote := flag.String("remote", "", "remote webui URL (e.g. http://127.0.0.1:8080)")
|
||||
remote := flag.String("remote", "", "remote webui URL")
|
||||
apiKey := flag.String("api-key", "", "API key for remote mode")
|
||||
configPath := flag.String("config", "", "config file path")
|
||||
chat := flag.String("chat", "", "send a message and print final text (one-shot)")
|
||||
say := flag.String("say", "", "deprecated alias of -chat")
|
||||
flag.Parse()
|
||||
|
||||
sockAddr := discoverSocket(*socket)
|
||||
mode := "local"
|
||||
addr := sockAddr
|
||||
if *remote != "" {
|
||||
mode = "remote"
|
||||
addr = *remote
|
||||
} else if *socket != "" {
|
||||
mode = "local"
|
||||
addr = *socket
|
||||
cfg := discoverConfig(*configPath)
|
||||
cfg.MergeCLI(*socket, *remote, *apiKey)
|
||||
|
||||
if cfg.Socket == "" && cfg.Remote == "" {
|
||||
cfg.Socket = discoverSocket("")
|
||||
}
|
||||
|
||||
oneShotMsg := *chat
|
||||
if oneShotMsg == "" {
|
||||
oneShotMsg = *say
|
||||
}
|
||||
if oneShotMsg != "" {
|
||||
oneShot(mode, addr, oneShotMsg)
|
||||
return
|
||||
}
|
||||
runInteractive(mode, addr)
|
||||
}
|
||||
|
||||
func oneShot(mode, addr, message string) {
|
||||
if mode == "remote" {
|
||||
resp, err := doRemoteOnce(addr, message)
|
||||
if err != nil {
|
||||
printlnC(colorRed, fmt.Sprintf("error: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(resp)
|
||||
return
|
||||
}
|
||||
conn, err := net.DialTimeout("unix", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
printlnC(colorRed, fmt.Sprintf("connect to %s: %v", addr, err))
|
||||
state := &State{}
|
||||
if err := state.Connect(cfg); err != nil {
|
||||
printlnC(colorRed, fmt.Sprintf("connect: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
fmt.Fprintf(conn, "%s\n", message)
|
||||
scanner := bufio.NewScanner(conn)
|
||||
if scanner.Scan() {
|
||||
var rl respLine
|
||||
if err := json.Unmarshal(scanner.Bytes(), &rl); err != nil {
|
||||
fmt.Println(scanner.Text())
|
||||
return
|
||||
}
|
||||
switch rl.Type {
|
||||
case "response":
|
||||
fmt.Println(rl.Content)
|
||||
case "error":
|
||||
printlnC(colorRed, fmt.Sprintf("error: %s", rl.Error))
|
||||
os.Exit(1)
|
||||
default:
|
||||
fmt.Println(scanner.Text())
|
||||
}
|
||||
defer state.Disconnect()
|
||||
|
||||
if oneShotMsg != "" {
|
||||
oneshot(state, oneShotMsg)
|
||||
return
|
||||
}
|
||||
runInteractive(state, cfg)
|
||||
}
|
||||
|
||||
func doRemoteOnce(baseURL, message string) (string, error) {
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
body := fmt.Sprintf(`{"message":%q}`, message)
|
||||
resp, err := http.Post(baseURL+"/api/v1/chat", "application/json", strings.NewReader(body))
|
||||
func oneshot(state *State, msg string) {
|
||||
resp, err := state.SendChat(msg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
printlnC(colorRed, fmt.Sprintf("error: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
content, _ := result["response"].(string)
|
||||
return content, nil
|
||||
fmt.Println(resp)
|
||||
}
|
||||
|
||||
const clearLine = "\033[2K\r"
|
||||
|
||||
func runInteractive(mode, addr string) {
|
||||
func runInteractive(state *State, cfg *Config) {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
@ -179,63 +134,47 @@ func runInteractive(mode, addr string) {
|
||||
}
|
||||
defer restore()
|
||||
|
||||
modeLabel := "local"
|
||||
addrLabel := cfg.Socket
|
||||
if cfg.Remote != "" {
|
||||
modeLabel = "remote"
|
||||
addrLabel = cfg.Remote
|
||||
}
|
||||
if colors {
|
||||
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, mode, addr, colorReset)
|
||||
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, modeLabel, addrLabel, colorReset)
|
||||
} else {
|
||||
fmt.Printf("HomeAgent CLI (%s://%s)\n", mode, addr)
|
||||
fmt.Printf("HomeAgent CLI (%s://%s)\n", modeLabel, addrLabel)
|
||||
}
|
||||
fmt.Println("Type /help for commands.")
|
||||
|
||||
var conn io.ReadWriteCloser
|
||||
var readerDone chan struct{}
|
||||
connMu := &sync.Mutex{}
|
||||
|
||||
connect := func() error {
|
||||
connMu.Lock()
|
||||
defer connMu.Unlock()
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
if readerDone != nil {
|
||||
<-readerDone
|
||||
}
|
||||
c, err := dial(mode, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn = c
|
||||
readerDone = make(chan struct{})
|
||||
go readLoop(conn, readerDone)
|
||||
return nil
|
||||
var readerCancel func()
|
||||
startReader := func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
readerCancel = cancel
|
||||
go state.ReadLoop(ctx, printServerOutput)
|
||||
}
|
||||
startReader()
|
||||
|
||||
reconnect := func() {
|
||||
if readerCancel != nil {
|
||||
readerCancel()
|
||||
}
|
||||
state.Disconnect()
|
||||
for i := 0; i < 30; i++ {
|
||||
if err := connect(); err != nil {
|
||||
if err := state.Connect(cfg); err != nil {
|
||||
printlnC(colorYellow, fmt.Sprintf("reconnecting (%d/30): %v", i+1, err))
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
printlnC(colorGreen, "reconnected")
|
||||
startReader()
|
||||
return
|
||||
}
|
||||
printlnC(colorRed, "giving up after 30 attempts")
|
||||
}
|
||||
|
||||
// initial connect
|
||||
loop:
|
||||
for {
|
||||
if err := connect(); err != nil {
|
||||
printlnC(colorYellow, fmt.Sprintf("connect: %v, retrying in 2s...", err))
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
inputMu := &sync.Mutex{}
|
||||
|
||||
for {
|
||||
fmt.Print("waiter> ")
|
||||
text, err := line.read()
|
||||
if err != nil {
|
||||
break
|
||||
@ -248,7 +187,10 @@ func runInteractive(mode, addr string) {
|
||||
}
|
||||
|
||||
if cmd[0] == '/' {
|
||||
if handleBuiltin(cmd, &mode, &addr, reconnect) {
|
||||
if handleBuiltin(cmd, cfg, state, reconnect) {
|
||||
if cmd == "/exit" || cmd == "/quit" {
|
||||
break loop
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -256,424 +198,45 @@ func runInteractive(mode, addr string) {
|
||||
history.add(cmd)
|
||||
history.save()
|
||||
|
||||
connMu.Lock()
|
||||
c := conn
|
||||
connMu.Unlock()
|
||||
if c == nil {
|
||||
printlnC(colorYellow, "not connected, reconnecting...")
|
||||
reconnect()
|
||||
connMu.Lock()
|
||||
c = conn
|
||||
connMu.Unlock()
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(c, "%s\n", cmd)
|
||||
if err != nil {
|
||||
if err := state.Send(cmd); err != nil {
|
||||
printlnC(colorYellow, "connection lost, reconnecting...")
|
||||
line.redrawPending(cmd)
|
||||
reconnect()
|
||||
connMu.Lock()
|
||||
c = conn
|
||||
connMu.Unlock()
|
||||
if c != nil {
|
||||
fmt.Fprintf(c, "%s\n", cmd)
|
||||
}
|
||||
state.Send(cmd)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-sigCh:
|
||||
goto exit
|
||||
break loop
|
||||
default:
|
||||
}
|
||||
_ = inputMu
|
||||
}
|
||||
|
||||
exit:
|
||||
connMu.Lock()
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
connMu.Unlock()
|
||||
if readerDone != nil {
|
||||
<-readerDone
|
||||
if readerCancel != nil {
|
||||
readerCancel()
|
||||
}
|
||||
}
|
||||
|
||||
func dial(mode, addr string) (io.ReadWriteCloser, error) {
|
||||
if mode == "remote" {
|
||||
return newHTTPConn(strings.TrimRight(addr, "/")), nil
|
||||
func printServerOutput(content string) {
|
||||
if !colors {
|
||||
fmt.Printf("%s%s\n", clearLine, content)
|
||||
return
|
||||
}
|
||||
return net.DialTimeout("unix", addr, 5*time.Second)
|
||||
}
|
||||
|
||||
func readLoop(conn io.ReadWriteCloser, done chan struct{}) {
|
||||
defer close(done)
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
if !colors {
|
||||
fmt.Printf("%s%s\n", clearLine, scanner.Text())
|
||||
continue
|
||||
}
|
||||
var rl respLine
|
||||
if err := json.Unmarshal(scanner.Bytes(), &rl); err != nil {
|
||||
fmt.Printf("%s%s%s\n", clearLine, scanner.Text(), colorReset)
|
||||
continue
|
||||
}
|
||||
switch rl.Type {
|
||||
case "response":
|
||||
fmt.Printf("%s%s%s%s\n", clearLine, colorGreen, rl.Content, colorReset)
|
||||
case "error":
|
||||
fmt.Printf("%s%s%s%s\n", clearLine, colorRed, rl.Error, colorReset)
|
||||
default:
|
||||
fmt.Printf("%s%s%s\n", clearLine, scanner.Text(), colorReset)
|
||||
}
|
||||
var rl respLine
|
||||
if err := json.Unmarshal([]byte(content), &rl); err != nil {
|
||||
fmt.Printf("%s%s%s\n", clearLine, content, colorReset)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// httpConn wraps an HTTP endpoint as a read/write/closer for chat.
|
||||
type httpConn struct {
|
||||
url string
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newHTTPConn(baseURL string) *httpConn {
|
||||
return &httpConn{url: baseURL + "/api/v1/chat"}
|
||||
}
|
||||
|
||||
func (c *httpConn) Read(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for len(c.buf) == 0 && !c.closed {
|
||||
c.mu.Unlock()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
c.mu.Lock()
|
||||
}
|
||||
if c.closed && len(c.buf) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, c.buf)
|
||||
c.buf = c.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *httpConn) Write(p []byte) (int, error) {
|
||||
msg := strings.TrimSpace(string(p))
|
||||
resp, err := doRemoteOnce(c.url, msg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
data, _ := json.Marshal(respLine{Type: "response", Content: resp})
|
||||
data = append(data, '\n')
|
||||
c.mu.Lock()
|
||||
c.buf = append(c.buf, data...)
|
||||
c.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (c *httpConn) Close() error {
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleBuiltin(cmd string, mode, addr *string, reconnect func()) bool {
|
||||
switch {
|
||||
case cmd == "/help":
|
||||
fmt.Println(`Built-in commands:
|
||||
/help show this help
|
||||
/exit, /quit exit waiter
|
||||
/clear clear screen
|
||||
/reconnect force reconnection
|
||||
/connect <path> switch to a different unix socket
|
||||
/remote <url> switch to remote HTTP mode
|
||||
/local switch back to local socket mode
|
||||
|
||||
Structured commands (processed server-side):
|
||||
/status system status
|
||||
/kernel kernel status
|
||||
/settings [prefix] list settings
|
||||
/settings set <k> <v> set a setting
|
||||
/plugin list list installed plugins
|
||||
/plugin install <url> install plugin
|
||||
/plugin remove <name> remove plugin
|
||||
/plugin info <name> plugin details
|
||||
/memory query <text> query graph memory
|
||||
/knowledge list knowledge base
|
||||
/agents list agents
|
||||
/chat <text> send to agent
|
||||
|
||||
Any other text is sent to the agent.`)
|
||||
return true
|
||||
|
||||
case cmd == "/exit" || cmd == "/quit":
|
||||
os.Exit(0)
|
||||
return true
|
||||
|
||||
case cmd == "/clear":
|
||||
fmt.Print("\033[H\033[2J")
|
||||
return true
|
||||
|
||||
case cmd == "/reconnect":
|
||||
printlnC(colorYellow, "reconnecting...")
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/connect "):
|
||||
*mode = "local"
|
||||
*addr = strings.TrimSpace(cmd[9:])
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/remote "):
|
||||
*mode = "remote"
|
||||
*addr = strings.TrimSpace(cmd[8:])
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
case cmd == "/local":
|
||||
*mode = "local"
|
||||
*addr = discoverSocket("")
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
switch rl.Type {
|
||||
case "response":
|
||||
fmt.Printf("%s%s%s%s\n", clearLine, colorGreen, rl.Content, colorReset)
|
||||
case "error":
|
||||
fmt.Printf("%s%s%s%s\n", clearLine, colorRed, rl.Error, colorReset)
|
||||
default:
|
||||
return false
|
||||
fmt.Printf("%s%s%s\n", clearLine, content, colorReset)
|
||||
}
|
||||
}
|
||||
|
||||
type LineEditor struct {
|
||||
buf []rune
|
||||
pos int
|
||||
hist *History
|
||||
histI int
|
||||
pending string
|
||||
}
|
||||
|
||||
func newLineEditor(h *History) *LineEditor {
|
||||
return &LineEditor{hist: h, histI: -1}
|
||||
}
|
||||
|
||||
func (e *LineEditor) clear() {
|
||||
e.buf = e.buf[:0]
|
||||
e.pos = 0
|
||||
e.histI = -1
|
||||
}
|
||||
|
||||
func (e *LineEditor) redrawPending(text string) {
|
||||
e.pending = text
|
||||
}
|
||||
|
||||
func (e *LineEditor) read() (string, error) {
|
||||
if e.pending != "" {
|
||||
t := e.pending
|
||||
e.pending = ""
|
||||
return t, nil
|
||||
}
|
||||
|
||||
e.buf = e.buf[:0]
|
||||
e.pos = 0
|
||||
e.histI = -1
|
||||
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
b := make([]byte, 1)
|
||||
_, err := in.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch b[0] {
|
||||
case '\r', '\n':
|
||||
fmt.Print("\n")
|
||||
return string(e.buf), nil
|
||||
|
||||
case 0x03: // Ctrl+C
|
||||
fmt.Print("^C\n")
|
||||
os.Exit(130)
|
||||
return "", nil
|
||||
|
||||
case 0x04: // Ctrl+D
|
||||
if len(e.buf) == 0 {
|
||||
return "", io.EOF
|
||||
}
|
||||
continue
|
||||
|
||||
case 0x08, 0x7f: // Backspace
|
||||
if e.pos > 0 {
|
||||
e.pos--
|
||||
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
|
||||
e.redraw()
|
||||
}
|
||||
|
||||
case 0x1b: // Escape sequence
|
||||
seq := make([]byte, 2)
|
||||
if _, err := io.ReadFull(in, seq); err != nil {
|
||||
continue
|
||||
}
|
||||
if seq[0] != '[' {
|
||||
continue
|
||||
}
|
||||
switch seq[1] {
|
||||
case 'A':
|
||||
e.historyPrev()
|
||||
case 'B':
|
||||
e.historyNext()
|
||||
case 'C':
|
||||
if e.pos < len(e.buf) {
|
||||
e.pos++
|
||||
e.redraw()
|
||||
}
|
||||
case 'D':
|
||||
if e.pos > 0 {
|
||||
e.pos--
|
||||
e.redraw()
|
||||
}
|
||||
case 'H', '1':
|
||||
if seq[1] == '1' {
|
||||
io.ReadFull(in, make([]byte, 1))
|
||||
}
|
||||
e.pos = 0
|
||||
e.redraw()
|
||||
case 'F', '4':
|
||||
if seq[1] == '4' {
|
||||
io.ReadFull(in, make([]byte, 1))
|
||||
}
|
||||
e.pos = len(e.buf)
|
||||
e.redraw()
|
||||
case '3':
|
||||
io.ReadFull(in, make([]byte, 1))
|
||||
if e.pos < len(e.buf) {
|
||||
e.buf = append(e.buf[:e.pos], e.buf[e.pos+1:]...)
|
||||
e.redraw()
|
||||
}
|
||||
}
|
||||
|
||||
case '\t':
|
||||
e.doCompletion()
|
||||
|
||||
default:
|
||||
if b[0] >= 0x20 {
|
||||
e.buf = append(e.buf, 0)
|
||||
copy(e.buf[e.pos+1:], e.buf[e.pos:])
|
||||
e.buf[e.pos] = rune(b[0])
|
||||
e.pos++
|
||||
e.redraw()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *LineEditor) historyPrev() {
|
||||
all := e.hist.all()
|
||||
if len(all) == 0 {
|
||||
return
|
||||
}
|
||||
if e.histI == -1 {
|
||||
e.histI = len(all) - 1
|
||||
} else if e.histI > 0 {
|
||||
e.histI--
|
||||
}
|
||||
e.buf = []rune(all[e.histI])
|
||||
e.pos = len(e.buf)
|
||||
e.redraw()
|
||||
}
|
||||
|
||||
func (e *LineEditor) historyNext() {
|
||||
if e.histI == -1 {
|
||||
return
|
||||
}
|
||||
all := e.hist.all()
|
||||
e.histI++
|
||||
if e.histI >= len(all) {
|
||||
e.histI = -1
|
||||
e.buf = e.buf[:0]
|
||||
e.pos = 0
|
||||
} else {
|
||||
e.buf = []rune(all[e.histI])
|
||||
e.pos = len(e.buf)
|
||||
}
|
||||
e.redraw()
|
||||
}
|
||||
|
||||
func (e *LineEditor) doCompletion() {
|
||||
cmds := []string{"/help", "/exit", "/quit", "/clear", "/reconnect", "/connect ", "/remote ", "/local",
|
||||
"/status", "/kernel", "/settings ", "/settings set ", "/chat ",
|
||||
"/plugin ", "/plugin list", "/plugin install ", "/plugin remove ", "/plugin info ",
|
||||
"/memory ", "/memory query ", "/knowledge", "/agents"}
|
||||
prefix := string(e.buf)
|
||||
for _, c := range cmds {
|
||||
if strings.HasPrefix(c, prefix) && c != prefix {
|
||||
e.buf = []rune(c)
|
||||
e.pos = len(e.buf)
|
||||
e.redraw()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *LineEditor) redraw() {
|
||||
fmt.Print("\r\033[K")
|
||||
fmt.Print(string(e.buf))
|
||||
if e.pos < len(e.buf) {
|
||||
skip := len(e.buf) - e.pos
|
||||
fmt.Printf("\033[%dD", skip)
|
||||
}
|
||||
}
|
||||
|
||||
type History struct {
|
||||
path string
|
||||
lines []string
|
||||
max int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newHistory(path string, max int) History {
|
||||
return History{path: path, max: max}
|
||||
}
|
||||
|
||||
func (h *History) load() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
data, err := os.ReadFile(h.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.lines = strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(h.lines) > h.max {
|
||||
h.lines = h.lines[len(h.lines)-h.max:]
|
||||
}
|
||||
}
|
||||
|
||||
func (h *History) save() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
data := strings.Join(h.lines, "\n") + "\n"
|
||||
os.WriteFile(h.path, []byte(data), 0644)
|
||||
}
|
||||
|
||||
func (h *History) add(line string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if len(h.lines) > 0 && h.lines[len(h.lines)-1] == line {
|
||||
return
|
||||
}
|
||||
h.lines = append(h.lines, line)
|
||||
if len(h.lines) > h.max {
|
||||
h.lines = h.lines[len(h.lines)-h.max:]
|
||||
}
|
||||
}
|
||||
|
||||
func (h *History) all() []string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
r := make([]string, len(h.lines))
|
||||
copy(r, h.lines)
|
||||
return r
|
||||
}
|
||||
|
||||
func setRawMode(fd int) (func(), error) {
|
||||
if fd == 0 {
|
||||
fd = int(os.Stdin.Fd())
|
||||
|
||||
132
cmd/waiter/state.go
Normal file
132
cmd/waiter/state.go
Normal file
@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
conn Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *State) Connect(cfg *Config) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.conn != nil {
|
||||
s.conn.Close()
|
||||
}
|
||||
c, err := dial(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.conn = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) Disconnect() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.conn != nil {
|
||||
s.conn.Close()
|
||||
s.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) Connected() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.conn != nil
|
||||
}
|
||||
|
||||
func (s *State) Send(line string) error {
|
||||
s.mu.Lock()
|
||||
c := s.conn
|
||||
s.mu.Unlock()
|
||||
if c == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
return c.Send(line)
|
||||
}
|
||||
|
||||
func (s *State) SendChat(msg string) (string, error) {
|
||||
if err := s.Send(msg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseRespLine(line)
|
||||
}
|
||||
|
||||
func (s *State) SendBuiltin(cmd string) (string, error) {
|
||||
if err := s.Send(cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err := s.readLine()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseRespLine(line)
|
||||
}
|
||||
|
||||
func (s *State) readLine() (string, error) {
|
||||
s.mu.Lock()
|
||||
c := s.conn
|
||||
s.mu.Unlock()
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
return c.ReadLine()
|
||||
}
|
||||
|
||||
type respLine struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func parseRespLine(line string) (string, error) {
|
||||
var rl respLine
|
||||
if err := json.Unmarshal([]byte(line), &rl); err != nil {
|
||||
return line, nil
|
||||
}
|
||||
switch rl.Type {
|
||||
case "response":
|
||||
return rl.Content, nil
|
||||
case "error":
|
||||
return "", fmt.Errorf("%s", rl.Error)
|
||||
default:
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) ReadLoop(ctx context.Context, cb func(string)) {
|
||||
for {
|
||||
s.mu.Lock()
|
||||
c := s.conn
|
||||
s.mu.Unlock()
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
done := make(chan struct{})
|
||||
var line string
|
||||
var readErr error
|
||||
go func() {
|
||||
line, readErr = c.ReadLine()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
cb(line)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
@ -251,16 +252,27 @@ func (a *Agent) interceptLoop() {
|
||||
a.llmMu.Unlock()
|
||||
|
||||
if hasActiveLLM {
|
||||
select {
|
||||
case a.interceptCh <- clone:
|
||||
default:
|
||||
log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
|
||||
// 后台整理任务被打断:中断消息重新注入为独立输入(consolidation 的 process 不会路由回复)
|
||||
if a.currentOutputChannel == "_consolidation_" {
|
||||
log.Printf("[agent] consolidation interrupted, re-injecting input for %s/%s", evt.Source, evt.OutputChannel)
|
||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": evt.Source,
|
||||
"interrupt_channel": evt.OutputChannel,
|
||||
})
|
||||
} else {
|
||||
select {
|
||||
case a.interceptCh <- clone:
|
||||
default:
|
||||
log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
|
||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": evt.Source,
|
||||
"interrupt_channel": evt.OutputChannel,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
||||
@ -325,6 +337,12 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
|
||||
|
||||
blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
|
||||
|
||||
// 先遗忘再输入
|
||||
archived := a.context.Prune(fallback, a.maxContextSize-1, a.docStore)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
@ -371,11 +389,6 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
|
||||
ToolsUsed: toolsUsed,
|
||||
})
|
||||
|
||||
archived := a.context.Prune(response, a.maxContextSize, a.docStore)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
}
|
||||
|
||||
@ -466,6 +479,12 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
|
||||
input = stageCtx.RawMessage
|
||||
|
||||
// 先"遗忘"再输入:用当前输入决定淘汰哪些不相关旧事件(LSTM forget gate 模式)
|
||||
archived := a.context.Prune(input, a.maxContextSize-1, a.docStore)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
@ -492,12 +511,6 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
ToolsUsed: toolsUsed,
|
||||
})
|
||||
|
||||
// 基于相关性裁剪上下文:保留与当前输入最相关的 maxContextSize 条
|
||||
archived := a.context.Prune(response, a.maxContextSize, a.docStore)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
if !stageCtx.NoMemory {
|
||||
@ -661,6 +674,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
|
||||
if llmErr != nil {
|
||||
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
|
||||
// 后台整理任务被打断:中断已重新注入为独立输入,直接返回
|
||||
if a.currentOutputChannel == "_consolidation_" {
|
||||
return "", toolsUsed, fmt.Errorf("interrupted by user input")
|
||||
}
|
||||
// 用户对话被打断:继续下一轮 drain 打断消息,注入到当前对话上下文
|
||||
continue
|
||||
}
|
||||
return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
|
||||
len(providers), llmErr)
|
||||
}
|
||||
@ -1159,7 +1180,11 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
|
||||
if i >= topK {
|
||||
break
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s]\n%s", k.Name, truncateStr(k.Content, 200)))
|
||||
label := k.Name
|
||||
if k.Category != "" {
|
||||
label = k.Category + "/" + k.Name
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s]\n%s", label, truncateStr(k.Content, 200)))
|
||||
}
|
||||
return strings.Join(parts, "\n---\n")
|
||||
|
||||
@ -1175,11 +1200,8 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
|
||||
return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符)", name, len(content))
|
||||
|
||||
case "knowledge_list":
|
||||
names := a.knowledge.List()
|
||||
if len(names) == 0 {
|
||||
return "知识库为空"
|
||||
}
|
||||
return "知识分类: " + strings.Join(names, ", ")
|
||||
tree := a.knowledge.BuildTree()
|
||||
return formatTree(tree, 0)
|
||||
|
||||
default:
|
||||
return fmt.Sprintf("未知的知识工具: %s", tc.Name)
|
||||
@ -1802,9 +1824,14 @@ func (a *Agent) distillContext() {
|
||||
if a.docStore == nil {
|
||||
return
|
||||
}
|
||||
// 心跳时执行一次安全裁剪(兜底)
|
||||
// 上下文的主要裁剪在 processTextInput 中基于相关性执行
|
||||
_ = a.context.Len()
|
||||
// 心跳时执行安全裁剪:上下文超过 maxContextSize*2 时强制归档
|
||||
n := a.context.Len()
|
||||
if n > a.maxContextSize*2 {
|
||||
archived := a.context.Prune("", a.maxContextSize, a.docStore)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncGraphToDocs — 将图记忆的实体和关系注入文档记忆层
|
||||
@ -2104,6 +2131,13 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed []
|
||||
func (a *Agent) processConsolidation(input string) {
|
||||
start := time.Now()
|
||||
a.currentOutputChannel = "_consolidation_"
|
||||
|
||||
// 遗忘不相关的旧事件
|
||||
archived := a.context.Prune(input, a.maxContextSize-1, a.docStore)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] consolidation: pruned %d low-relevance events", archived)
|
||||
}
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: "system",
|
||||
@ -2121,7 +2155,6 @@ func (a *Agent) processConsolidation(input string) {
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
})
|
||||
_ = a.context.Prune(response, a.maxContextSize, a.docStore)
|
||||
a.emitMemoryCandidate("system", input, response, toolsUsed)
|
||||
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
|
||||
}
|
||||
@ -2585,6 +2618,45 @@ func truncateStr(s string, max int) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func formatTree(node *knowledge.TreeIndex, depth int) string {
|
||||
var sb strings.Builder
|
||||
indent := strings.Repeat(" ", depth)
|
||||
for _, child := range node.Children {
|
||||
sb.WriteString(fmt.Sprintf("%s%s/\n", indent, child.Name))
|
||||
if len(child.Items) > 0 {
|
||||
for _, item := range child.Items {
|
||||
preview := item.Preview
|
||||
if len([]rune(preview)) > 60 {
|
||||
preview = string([]rune(preview)[:60]) + "..."
|
||||
}
|
||||
tags := ""
|
||||
if len(item.Tags) > 0 {
|
||||
tags = " [" + strings.Join(item.Tags, ", ") + "]"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s · %s%s\n %s\n", indent, item.Name, tags, preview))
|
||||
}
|
||||
}
|
||||
sb.WriteString(formatTree(child, depth+1))
|
||||
}
|
||||
if depth > 0 && len(node.Items) > 0 {
|
||||
for _, item := range node.Items {
|
||||
preview := item.Preview
|
||||
if len([]rune(preview)) > 60 {
|
||||
preview = string([]rune(preview)[:60]) + "..."
|
||||
}
|
||||
tags := ""
|
||||
if len(item.Tags) > 0 {
|
||||
tags = " [" + strings.Join(item.Tags, ", ") + "]"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" · %s%s\n %s\n", item.Name, tags, preview))
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
sb.WriteString("(空)")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (a *Agent) resolveToolPlugin(name string) string {
|
||||
if a.stageHost != nil {
|
||||
if plugin := a.stageHost.ToolPlugin(name); plugin != "" {
|
||||
|
||||
@ -223,6 +223,10 @@ func (c *RelevanceContext) ensureTrained() {
|
||||
texts[i] = evt.Input + " " + evt.Response
|
||||
}
|
||||
c.veczer.Train(texts)
|
||||
// 重算所有事件向量,与新的向量化器特征空间对齐
|
||||
for _, evt := range c.events {
|
||||
evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
|
||||
}
|
||||
c.trained = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@ -13,17 +14,67 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
// Knowledge — 单条知识
|
||||
type Knowledge struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Path string `json:"path"`
|
||||
Category string `json:"category,omitempty"` // 父路径,如 "tech/go"
|
||||
Tags []string `json:"tags"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Store — 知识库,文件系统 + 向量索引
|
||||
// IndexItem — 索引条目,包含向量特征和内容摘要
|
||||
type IndexItem struct {
|
||||
Name string `json:"name"`
|
||||
Preview string `json:"preview"` // 前 200 字摘要
|
||||
Tags []string `json:"tags"`
|
||||
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征)
|
||||
Size int `json:"size"` // 内容总字节数
|
||||
}
|
||||
|
||||
// TreeIndex — 树状索引节点
|
||||
type TreeIndex struct {
|
||||
Name string `json:"name"`
|
||||
Children map[string]*TreeIndex `json:"children,omitempty"`
|
||||
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
|
||||
}
|
||||
|
||||
func newTreeIndex(name string) *TreeIndex {
|
||||
return &TreeIndex{Name: name, Children: make(map[string]*TreeIndex)}
|
||||
}
|
||||
|
||||
// compressVector 压缩向量:保留 topN 个权重最高的特征
|
||||
func compressVector(v vector.Vector, topN int) map[string]float64 {
|
||||
if len(v) <= topN {
|
||||
out := make(map[string]float64, len(v))
|
||||
for k, w := range v {
|
||||
out[k] = w
|
||||
}
|
||||
return out
|
||||
}
|
||||
type kv struct {
|
||||
k string
|
||||
v float64
|
||||
}
|
||||
sorted := make([]kv, 0, len(v))
|
||||
for k, w := range v {
|
||||
sorted = append(sorted, kv{k, w})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].v > sorted[j].v
|
||||
})
|
||||
if topN > len(sorted) {
|
||||
topN = len(sorted)
|
||||
}
|
||||
sorted = sorted[:topN]
|
||||
out := make(map[string]float64, topN)
|
||||
for _, kv := range sorted {
|
||||
out[kv.k] = kv.v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
root string
|
||||
vec *vector.Store
|
||||
@ -31,15 +82,17 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]*Knowledge
|
||||
|
||||
indexPath string
|
||||
summaries []string
|
||||
}
|
||||
|
||||
func NewStore(root string) *Store {
|
||||
return &Store{
|
||||
root: root,
|
||||
vec: vector.NewStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(3),
|
||||
items: make(map[string]*Knowledge),
|
||||
root: root,
|
||||
indexPath: filepath.Join(root, ".index.json"),
|
||||
vec: vector.NewStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(3),
|
||||
items: make(map[string]*Knowledge),
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,13 +103,16 @@ func (s *Store) Start() error {
|
||||
if err := s.scanAll(); err != nil {
|
||||
log.Printf("[knowledge] scan error: %v", err)
|
||||
}
|
||||
// 重建索引文件
|
||||
if err := s.writeIndex(); err != nil {
|
||||
log.Printf("[knowledge] write index error: %v", err)
|
||||
}
|
||||
log.Printf("[knowledge] started with %d items, %d vectors", len(s.items), s.vec.Size())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Stop() {}
|
||||
|
||||
// Search — 向量查询知识
|
||||
func (s *Store) Search(query string, topK int) []*Knowledge {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@ -77,48 +133,56 @@ func (s *Store) Search(query string, topK int) []*Knowledge {
|
||||
return out
|
||||
}
|
||||
|
||||
// Add — 添加或更新知识
|
||||
func (s *Store) Add(name, content string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// 创建知识目录
|
||||
dir := filepath.Join(s.root, sanitize(name))
|
||||
// 解析层级:将 "/" 作为路径分隔符
|
||||
category := ""
|
||||
leaf := name
|
||||
if idx := strings.LastIndex(name, "/"); idx >= 0 {
|
||||
category = name[:idx]
|
||||
leaf = name[idx+1:]
|
||||
}
|
||||
dirName := sanitize(leaf)
|
||||
if category != "" {
|
||||
dirName = sanitize(category) + "/" + dirName
|
||||
}
|
||||
dir := filepath.Join(s.root, dirName)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create knowledge dir: %w", err)
|
||||
}
|
||||
|
||||
// 写入知识文件
|
||||
path := filepath.Join(dir, "content.md")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("write knowledge: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
id := sanitize(name)
|
||||
k := &Knowledge{
|
||||
Name: name,
|
||||
Name: id,
|
||||
Content: content,
|
||||
Path: path,
|
||||
Category: sanitize(category),
|
||||
Tags: extractKeywords(name + " " + content),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// 生成 ID = 目录名
|
||||
id := sanitize(name)
|
||||
s.items[id] = k
|
||||
|
||||
vec := s.veczer.Vectorize(name + " " + content)
|
||||
s.vec.Insert(id, name+": "+content, vec, map[string]string{
|
||||
"name": name, "path": path,
|
||||
})
|
||||
|
||||
s.summaries = append(s.summaries, name+" "+content)
|
||||
|
||||
if err := s.writeIndex(); err != nil {
|
||||
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
|
||||
}
|
||||
log.Printf("[knowledge] added: %s (%d bytes)", name, len(content))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchCategories — 返回所有知识类别
|
||||
func (s *Store) SearchCategories(query string, topK int) []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@ -157,6 +221,9 @@ func (s *Store) Remove(name string) error {
|
||||
}
|
||||
delete(s.items, id)
|
||||
s.vec.Remove(id)
|
||||
if err := s.writeIndex(); err != nil {
|
||||
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -167,6 +234,7 @@ func (s *Store) Stats() map[string]interface{} {
|
||||
"knowledge_count": len(s.items),
|
||||
"vector_count": s.vec.Size(),
|
||||
"root": s.root,
|
||||
"index_file": s.indexPath,
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,6 +249,89 @@ func (s *Store) List() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// BuildTree 从当前知识库构建树状索引(含向量特征)
|
||||
func (s *Store) BuildTree() *TreeIndex {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
root := newTreeIndex("root")
|
||||
for _, k := range s.items {
|
||||
node := root
|
||||
if k.Category != "" {
|
||||
parts := strings.Split(k.Category, "/")
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := node.Children[part]; !ok {
|
||||
node.Children[part] = newTreeIndex(part)
|
||||
}
|
||||
node = node.Children[part]
|
||||
}
|
||||
}
|
||||
// 获取该条目的向量并压缩
|
||||
vec := s.veczer.Vectorize(k.Name + " " + k.Content)
|
||||
preview := []rune(k.Content)
|
||||
previewStr := ""
|
||||
if len(preview) > 200 {
|
||||
previewStr = string(preview[:200]) + "..."
|
||||
} else {
|
||||
previewStr = string(preview)
|
||||
}
|
||||
item := IndexItem{
|
||||
Name: k.Name,
|
||||
Preview: previewStr,
|
||||
Tags: k.Tags,
|
||||
Vector: compressVector(vec, 20),
|
||||
Size: len(k.Content),
|
||||
}
|
||||
node.Items = append(node.Items, item)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// SearchTree 树状搜索:在树节点下搜索,返回按分类聚合的结果
|
||||
func (s *Store) SearchTree(query string, topK int) map[string][]*Knowledge {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if topK <= 0 {
|
||||
topK = 10
|
||||
}
|
||||
|
||||
vec := s.veczer.Vectorize(query)
|
||||
results := s.vec.Search(vec, topK*2)
|
||||
|
||||
categorized := make(map[string][]*Knowledge)
|
||||
for _, r := range results {
|
||||
if k, ok := s.items[r.ID]; ok {
|
||||
cat := k.Category
|
||||
if cat == "" {
|
||||
cat = "未分类"
|
||||
}
|
||||
categorized[cat] = append(categorized[cat], k)
|
||||
}
|
||||
}
|
||||
|
||||
out := make(map[string][]*Knowledge)
|
||||
for cat, items := range categorized {
|
||||
if len(items) > topK {
|
||||
items = items[:topK]
|
||||
}
|
||||
out[cat] = items
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeIndex 写入 .index.json 树状索引文件(含向量和摘要)
|
||||
func (s *Store) writeIndex() error {
|
||||
tree := s.BuildTree()
|
||||
data, err := json.MarshalIndent(tree, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.indexPath, data, 0644)
|
||||
}
|
||||
|
||||
// ——— internal ———
|
||||
|
||||
func (s *Store) scanAll() error {
|
||||
@ -193,34 +344,17 @@ func (s *Store) scanAll() error {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(s.root, entry.Name())
|
||||
contentPath := filepath.Join(dir, "content.md")
|
||||
data, err := os.ReadFile(contentPath)
|
||||
if err != nil {
|
||||
// skip hidden dirs
|
||||
if strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
content := string(data)
|
||||
now := time.Now()
|
||||
|
||||
k := &Knowledge{
|
||||
Name: name,
|
||||
Content: content,
|
||||
Path: contentPath,
|
||||
Tags: extractKeywords(name + " " + content),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.items[name] = k
|
||||
s.summaries = append(s.summaries, name+" "+content)
|
||||
s.scanDir("", entry.Name())
|
||||
}
|
||||
|
||||
// 训练向量化器
|
||||
if len(s.summaries) > 0 {
|
||||
s.veczer.Train(s.summaries)
|
||||
}
|
||||
|
||||
// 构建向量索引
|
||||
for _, k := range s.items {
|
||||
vec := s.veczer.Vectorize(k.Name + " " + k.Content)
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
|
||||
@ -231,11 +365,48 @@ func (s *Store) scanAll() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanDir 递归扫描目录
|
||||
// category: 父级路径(从知识库根目录算起),如 "tech/go"
|
||||
// dirName: 当前目录相对路径(从知识库根目录算起)
|
||||
func (s *Store) scanDir(category, dirName string) {
|
||||
dir := filepath.Join(s.root, dirName)
|
||||
contentPath := filepath.Join(dir, "content.md")
|
||||
data, err := os.ReadFile(contentPath)
|
||||
if err == nil {
|
||||
name := dirName
|
||||
content := string(data)
|
||||
now := time.Now()
|
||||
k := &Knowledge{
|
||||
Name: name,
|
||||
Content: content,
|
||||
Path: contentPath,
|
||||
Category: category,
|
||||
Tags: extractKeywords(dirName + " " + content),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.items[name] = k
|
||||
s.summaries = append(s.summaries, name+" "+content)
|
||||
return
|
||||
}
|
||||
|
||||
// 无 content.md => 是分类目录,递归子目录
|
||||
subEntries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, sub := range subEntries {
|
||||
if !sub.IsDir() || strings.HasPrefix(sub.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
childDir := dirName + "/" + sub.Name()
|
||||
s.scanDir(dirName, childDir)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitize(name string) string {
|
||||
name = strings.ToLower(name)
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, " ", "_")
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
return name
|
||||
}
|
||||
@ -255,10 +426,8 @@ func extractKeywords(text string) []string {
|
||||
|
||||
var keywords []string
|
||||
runes := []rune(text)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// bi-gram
|
||||
for i := 0; i < len(runes)-1; i++ {
|
||||
word := string(runes[i : i+2])
|
||||
if !stopWords[word] && len(strings.TrimSpace(word)) == len(word) && !seen[word] {
|
||||
|
||||
@ -93,7 +93,7 @@ func (s *Store) Insert(doc *Doc) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContextToDoc — 将一段上下文对话历史提炼为文档
|
||||
// ContextToDoc — 将一段上下文对话历史提炼为文档(带内容去重)
|
||||
func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
@ -108,26 +108,46 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error
|
||||
parts = append(parts, line)
|
||||
}
|
||||
content := strings.Join(parts, "\n")
|
||||
contentHash := simpleHash(content)
|
||||
|
||||
// 去重:检查是否已有相同 hash 的文档(在锁内完成创建/更新)
|
||||
summary := summarizeEntries(entries)
|
||||
tags := extractTags(entries)
|
||||
entities := extractEntities(entries)
|
||||
|
||||
s.mu.Lock()
|
||||
for _, d := range s.docs {
|
||||
if d.Meta != nil && d.Meta["content_hash"] == contentHash {
|
||||
d.UpdatedAt = time.Now()
|
||||
d.LastAccess = time.Now()
|
||||
d.Content = content
|
||||
d.Source = source
|
||||
d.Summary = summary
|
||||
d.Tags = tags
|
||||
d.Entities = entities
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("doc_%d", time.Now().UnixNano())
|
||||
doc := &Doc{
|
||||
ID: fmt.Sprintf("doc_%d", time.Now().UnixNano()),
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
Entities: entities,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Source: source,
|
||||
ID: id,
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
Entities: entities,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
LastAccess: time.Now(),
|
||||
AccessCount: 1,
|
||||
Source: source,
|
||||
Meta: map[string]string{"content_hash": contentHash},
|
||||
}
|
||||
|
||||
if err := s.Insert(doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.docs[id] = doc
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@ -422,3 +442,12 @@ func truncate(s string, max int) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func simpleHash(s string) string {
|
||||
// 简单的基于内容的哈希,用于去重
|
||||
h := 0
|
||||
for _, r := range s {
|
||||
h = h*31 + int(r)
|
||||
}
|
||||
return fmt.Sprintf("h%08x", h)
|
||||
}
|
||||
|
||||
@ -108,6 +108,26 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) {
|
||||
defer p.wg.Done()
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
|
||||
apiKey := p.webuiAPIKey()
|
||||
if apiKey != "" {
|
||||
if !scanner.Scan() {
|
||||
return
|
||||
}
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "/auth ") || strings.TrimSpace(line[6:]) != apiKey {
|
||||
writeLine(conn, map[string]interface{}{
|
||||
"type": "error",
|
||||
"error": "unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeLine(conn, map[string]interface{}{
|
||||
"type": "response",
|
||||
"content": "authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
@ -136,6 +156,19 @@ func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) webuiAPIKey() string {
|
||||
if cfgReg == nil {
|
||||
return ""
|
||||
}
|
||||
ps := cfgReg.PluginConfig("webui")
|
||||
if v, _ := ps.Get("api_key"); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *Plugin) handleBuiltin(conn net.Conn, line string, s *sdk.PluginSDK) bool {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) == 0 {
|
||||
|
||||
@ -456,8 +456,12 @@ func (p *Plugin) testKnowledgeRaw() checkResult {
|
||||
}
|
||||
|
||||
results := hcKnowledge.Search("健康检查测试标记", 3)
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 清理测试条目,避免积累
|
||||
hcKnowledge.Remove(marker)
|
||||
|
||||
if len(results) > 0 {
|
||||
return checkResult{
|
||||
Name: "knowledge",
|
||||
|
||||
Reference in New Issue
Block a user