feat: output channel redesign - per-channel output gates, LLM chain events, SDKConfig

- Output channels generate per-channel tools: output_send__{name} (type=output) + output_send__{name}_help
- content is JSON string transparently passed to plugin handler for routing
- EventAgentLLMChain: full LLM response forwarded after each turn for webui/logs
- sdk.New refactored to SDKConfig struct (no more 13 positional args)
- RegisterOutputChannel adds desc param for JSON format documentation
- channelDevice simplified (no Tools method), desc field added
- Child agent permission updated for output_send__ prefix
- System prompt: output gates, multi-call, long messages split
- WebUI: subscribes to EventAgentLLMChain in SSE, no output channel
- Tests updated for new naming convention
This commit is contained in:
root
2026-07-16 12:11:16 +08:00
parent a7cd92d24c
commit 4d619bf89c
45 changed files with 3292 additions and 423 deletions

118
internal/memory/cut.go Normal file
View File

@ -0,0 +1,118 @@
package memory
import (
"log"
"os"
"path/filepath"
"sync"
"github.com/yanyiwu/gojieba"
)
var (
jiebaOnce sync.Once
jiebaInst *gojieba.Jieba
)
func GetJieba() *gojieba.Jieba {
jiebaOnce.Do(func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[jieba] init panic recovered: %v", r)
}
}()
d := jiebaDictDir()
if d == "" {
log.Printf("[jieba] no dictionary directory found, jieba disabled")
return
}
jiebaInst = gojieba.NewJieba(
filepath.Join(d, "jieba.dict.utf8"),
filepath.Join(d, "hmm_model.utf8"),
filepath.Join(d, "user.dict.utf8"),
filepath.Join(d, "idf.utf8"),
filepath.Join(d, "stop_words.utf8"),
)
})
return jiebaInst
}
func jiebaDictDir() string {
candidates := []string{
os.Getenv("GOMODCACHE"),
os.Getenv("GOPATH"),
filepath.Join(os.Getenv("HOME"), "go"),
"/root/go",
"/go",
"/home/program/go",
}
for _, base := range candidates {
if base == "" {
continue
}
d := filepath.Join(base, "pkg", "mod", "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict")
if info, err := os.Stat(d); err == nil && info.IsDir() {
return d
}
// also try without "pkg/mod" (in case GOPATH is already the mod cache)
d2 := filepath.Join(base, "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict")
if info, err := os.Stat(d2); err == nil && info.IsDir() {
return d2
}
}
return ""
}
var stopWords = map[string]bool{
"的": true, "了": true, "是": true, "在": true, "有": true,
"和": true, "就": true, "不": true, "人": true, "都": true,
"一": true, "一个": true, "上": true, "也": true, "很": true,
"到": true, "说": true, "要": true, "去": true, "你": true,
"会": true, "着": true, "没有": true, "看": true, "好": true,
"自己": true, "这": true, "他": true, "她": true, "它": true,
"我": true, "我们": true, "你们": true, "他们": true,
"吗": true, "吧": true, "啊": true,
"嗯": true, "哦": true, "哈": true, "呀": true, "嘛": true,
"然后": true, "因为": true, "所以": true, "如果": true, "但是": true,
"可能": true, "还是": true, "已经": true,
"就是": true, "不是": true, "是的": true,
"非常": true, "比较": true, "应该": true, "需要": true,
"能够": true, "目前": true, "现在": true, "今天": true, "昨天": true,
"明天": true, "知道": true, "觉得": true, "认为": true,
"能": true, "没": true, "对": true,
"the": true, "a": true, "an": true, "is": true, "are": true,
"was": true, "were": true, "be": true, "been": true, "being": true,
"have": true, "has": true, "had": true, "do": true, "does": true,
"did": true, "will": true, "would": true, "could": true, "should": true,
"may": true, "might": true, "can": true, "shall": true, "this": true,
"that": true, "these": true, "those": true, "it": true, "its": true,
"and": true, "or": true, "but": true, "in": true, "on": true,
"at": true, "to": true, "for": true, "of": true, "with": true,
"what": true, "how": true, "why": true, "which": true, "where": true,
"when": true, "who": true, "whom": true,
}
func ExtractKeywords(text string) []string {
x := GetJieba()
if x == nil {
return nil
}
words := x.Cut(text, true)
var keywords []string
seen := make(map[string]bool)
for _, w := range words {
if stopWords[w] || seen[w] {
continue
}
r := []rune(w)
if len(r) < 2 {
continue
}
seen[w] = true
keywords = append(keywords, w)
}
if len(keywords) > 5 {
keywords = keywords[:5]
}
return keywords
}

View File

@ -11,6 +11,7 @@ import (
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
@ -388,7 +389,7 @@ func summarizeEntries(entries []ContextEntry) string {
var topics []string
for _, e := range entries {
sources[e.Source]++
words := extractKeywords(e.Content)
words := memory.ExtractKeywords(e.Content)
topics = append(topics, words...)
}
@ -420,7 +421,7 @@ func summarizeEntries(entries []ContextEntry) string {
func extractTags(entries []ContextEntry) []string {
tagSet := make(map[string]bool)
for _, e := range entries {
for _, kw := range extractKeywords(e.Content) {
for _, kw := range memory.ExtractKeywords(e.Content) {
tagSet[kw] = true
}
}
@ -439,7 +440,7 @@ func extractEntities(entries []ContextEntry) []string {
var entities []string
seen := make(map[string]bool)
for _, e := range entries {
for _, kw := range extractKeywords(e.Content) {
for _, kw := range memory.ExtractKeywords(e.Content) {
if len(kw) >= 2 && !seen[kw] {
seen[kw] = true
entities = append(entities, kw)
@ -452,32 +453,6 @@ func extractEntities(entries []ContextEntry) []string {
return entities
}
func extractKeywords(text string) []string {
stopWords := map[string]bool{
"的": true, "了": true, "是": true, "在": true, "有": true,
"和": true, "就": true, "不": true, "人": true, "都": true,
"一": true, "一个": true, "上": true, "也": true, "很": true,
"到": true, "说": true, "要": true, "去": true, "你": true,
"会": true, "着": true, "没有": true, "看": true, "好": true,
"自己": true, "这": true, "他": true, "她": true, "它": true,
"什么": true, "怎么": true, "为什么": true, "如何": true,
"我": true, "我们": true, "你们": true, "他们": true, "这个": true,
"那个": true, "可以": true, "吗": true, "吧": true, "啊": true,
}
var keywords []string
runes := []rune(text)
// 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) {
keywords = append(keywords, word)
}
}
return keywords
}
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) > max {

View File

@ -4,6 +4,8 @@ import (
"os"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
func TestInsertAndQuery(t *testing.T) {
@ -186,7 +188,7 @@ func TestSummarizeEntries(t *testing.T) {
}
func TestExtractKeywords(t *testing.T) {
kws := extractKeywords("今天天气很好")
kws := memory.ExtractKeywords("今天天气很好")
if len(kws) == 0 {
t.Error("should extract keywords from Chinese text")
}

221
internal/memory/embedder.go Normal file
View File

@ -0,0 +1,221 @@
package memory
import (
"math"
"sort"
"strings"
"sync"
"github.com/yanyiwu/gojieba"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
type LocalWordEmbedder struct {
mu sync.RWMutex
jieba *gojieba.Jieba
stopWords map[string]bool
docFreq map[string]float64
totalDocs int
coOccur map[string]map[string]float64
vocab map[string]bool
trained bool
}
func NewLocalWordEmbedder() *LocalWordEmbedder {
sw := make(map[string]bool)
for k, v := range stopWords {
sw[k] = v
}
return &LocalWordEmbedder{
jieba: GetJieba(),
stopWords: sw,
docFreq: make(map[string]float64),
coOccur: make(map[string]map[string]float64),
vocab: make(map[string]bool),
}
}
func (e *LocalWordEmbedder) tokenize(text string) []string {
if e.jieba == nil {
return nil
}
words := e.jieba.Cut(text, true)
var result []string
seen := make(map[string]bool)
for _, w := range words {
w = strings.TrimSpace(w)
if w == "" || e.stopWords[w] || seen[w] {
continue
}
runes := []rune(w)
if len(runes) < 2 {
continue
}
seen[w] = true
result = append(result, w)
}
return result
}
func (e *LocalWordEmbedder) Train(docs []string) {
if e.jieba == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
e.docFreq = make(map[string]float64)
e.coOccur = make(map[string]map[string]float64)
e.vocab = make(map[string]bool)
tokenized := make([][]string, len(docs))
for i, doc := range docs {
tokens := e.tokenize(doc)
tokenized[i] = tokens
seen := make(map[string]bool)
for _, t := range tokens {
e.vocab[t] = true
if !seen[t] {
e.docFreq[t]++
seen[t] = true
}
}
}
e.totalDocs = len(docs)
windowSize := 5
for _, tokens := range tokenized {
for i, word := range tokens {
start := i - windowSize
if start < 0 {
start = 0
}
end := i + windowSize + 1
if end > len(tokens) {
end = len(tokens)
}
for j := start; j < end; j++ {
if i == j {
continue
}
ctx := tokens[j]
if e.coOccur[word] == nil {
e.coOccur[word] = make(map[string]float64)
}
e.coOccur[word][ctx]++
}
}
}
for word, ctxs := range e.coOccur {
totalPairs := 0.0
for _, count := range ctxs {
totalPairs += count
}
pWord := e.docFreq[word] / float64(e.totalDocs)
for ctx, count := range ctxs {
pCtx := e.docFreq[ctx] / float64(e.totalDocs)
pJoint := count / totalPairs
pmi := math.Log2(pJoint / (pWord * pCtx))
if pmi <= 0 {
delete(ctxs, ctx)
} else {
ctxs[ctx] = pmi
}
}
e.coOccur[word] = pruneTopK(ctxs, 50)
}
e.trained = true
}
func pruneTopK(m map[string]float64, k int) map[string]float64 {
if len(m) <= k {
return m
}
type kv struct {
k string
v float64
}
var sorted []kv
for key, val := range m {
sorted = append(sorted, kv{key, val})
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].v > sorted[j].v
})
result := make(map[string]float64, k)
for i := 0; i < k; i++ {
result[sorted[i].k] = sorted[i].v
}
return result
}
func (e *LocalWordEmbedder) Vectorize(text string) vector.Vector {
e.mu.RLock()
useEmbedding := e.trained
e.mu.RUnlock()
tokens := e.tokenize(text)
if len(tokens) == 0 {
return vector.Vector{}
}
tf := make(map[string]float64)
for _, t := range tokens {
tf[t]++
}
maxTF := 0.0
for _, count := range tf {
if count > maxTF {
maxTF = count
}
}
vec := make(vector.Vector)
if useEmbedding {
e.mu.RLock()
for word, count := range tf {
tfidf := (count / maxTF) * idf(e.docFreq[word], e.totalDocs)
if ctxs, ok := e.coOccur[word]; ok {
for ctx, pmi := range ctxs {
vec[ctx] += tfidf * pmi
}
}
vec["__w__"+word] += tfidf
}
e.mu.RUnlock()
} else {
for word, count := range tf {
tfNorm := count / maxTF
var df float64
e.mu.RLock()
df = e.docFreq[word]
e.mu.RUnlock()
vec[word] = tfNorm * idf(df, e.totalDocs)
}
}
return vec
}
func idf(df float64, total int) float64 {
if df <= 0 || total <= 0 {
return 1.0
}
return math.Log(float64(total+1)/(df+1)+1) + 1
}
func (e *LocalWordEmbedder) Trained() bool {
e.mu.RLock()
defer e.mu.RUnlock()
return e.trained
}

View File

@ -132,6 +132,9 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
if t.Subject == "" || t.Relation == "" || t.Object == "" {
continue
}
if !validEntityName(t.Subject) || !validEntityName(t.Object) {
continue
}
subjType := t.SubjectType
if subjType == "" {
@ -161,11 +164,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
var sourceID, targetID int64
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
if err != nil {
return 0, 0, err
return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
}
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
if err != nil {
return 0, 0, err
return 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
}
_, err = tx.Exec(
@ -186,7 +189,27 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
return entitiesCreated, relationsCreated, nil
}
func validEntityName(name string) bool {
if name == "" {
return false
}
r := []rune(name)
if len(r) < 2 || len(r) > 50 {
return false
}
hasLetter := false
for _, ch := range r {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '\u4e00' && ch <= '\u9fff') || ch == '-' || ch == '_' {
hasLetter = true
}
}
return hasLetter
}
func (g *GraphDB) upsertEntity(tx *sql.Tx, name string, entityType string) (int, error) {
if !validEntityName(name) {
return 0, nil
}
result, err := tx.Exec(
`INSERT INTO entities (name, type) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET

View File

@ -94,7 +94,7 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
vectorEntities := idx.vectorSearchEntities(userInput)
// 2. 关键词搜索:已有逻辑
keywords := extractKeywords(userInput)
keywords := ExtractKeywords(userInput)
if len(keywords) == 0 && len(vectorEntities) == 0 {
keywords = []string{userInput}
}
@ -282,46 +282,6 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
}
}
func extractKeywords(input string) []string {
stopWords := map[string]bool{
"的": true, "了": true, "是": true, "在": true, "有": true,
"和": true, "就": true, "不": true, "人": true, "都": true,
"一": true, "一个": true, "上": true, "也": true, "很": true,
"到": true, "说": true, "要": true, "去": true, "你": true,
"会": true, "着": true, "没有": true, "看": true, "好": true,
"自己": true, "这": true, "他": true, "她": true, "它": true,
"什么": true, "怎么": true, "为什么": true, "如何": true,
}
var keywords []string
seen := make(map[string]bool)
runes := []rune(input)
bigram := []rune{}
for _, r := range runes {
bigram = append(bigram, r)
if len(bigram) >= 2 {
word := string(bigram)
if !stopWords[word] && !seen[word] {
seen[word] = true
keywords = append(keywords, word)
}
bigram = bigram[1:]
}
}
if len(keywords) == 0 && len(runes) > 0 {
keywords = []string{string(runes)}
}
if len(keywords) > 5 {
keywords = keywords[:5]
}
return keywords
}
func buildIndexSummary(entities []Entity) string {
if len(entities) == 0 {
return ""

View File

@ -186,9 +186,9 @@ func TestExtractKeywords(t *testing.T) {
{"的了的", 0}, // all stop words
}
for _, tt := range tests {
kw := extractKeywords(tt.input)
kw := ExtractKeywords(tt.input)
if len(kw) < tt.min {
t.Errorf("extractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min)
t.Errorf("ExtractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min)
}
}
}