Files
Cyrene_v2/backend/services/gateway/api/handlers_more.go
T
Aska 9c8d5a9e8d
Cyrene CI / backend (push) Has been cancelled
Cyrene CI / frontend (push) Has been cancelled
feat(rag): pgvector knowledge base with API and LLM tools
- services/rag: knowledge_docs store — vector search via the shared
  embedder (HNSW index, graceful ILIKE fallback), list/delete scoped to
  the user, search intentionally global for the single-user deployment
- gateway: GET/POST /knowledge/docs, DELETE /knowledge/docs/:id,
  POST /knowledge/search
- plugins/knowledge: knowledge_search and knowledge_ingest tools so the
  persona can save and recall information mid-conversation
- main: migrations, store wiring, plugin registration
2026-08-21 18:40:01 +08:00

362 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package api — Automation, Reminders, Notifications, Message Search, Session Export, Image, Host Exec
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/engine"
)
// ===== Automation Rules =====
type AutomationRule struct {
ID string `json:"id"`
Name string `json:"name"`
Trigger string `json:"trigger"` // "time" | "event" | "webhook"
Condition map[string]string `json:"condition"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
type AutomationStore struct {
mu sync.RWMutex
rules map[string]*AutomationRule
}
func NewAutomationStore() *AutomationStore {
return &AutomationStore{rules: make(map[string]*AutomationRule)}
}
func (s *AutomationStore) List() []*AutomationRule {
s.mu.RLock(); defer s.mu.RUnlock()
result := make([]*AutomationRule, 0, len(s.rules))
for _, r := range s.rules { result = append(result, r) }
return result
}
func (s *AutomationStore) Save(r *AutomationRule) {
s.mu.Lock(); defer s.mu.Unlock()
if r.ID == "" { r.ID = fmt.Sprintf("rule-%d", time.Now().UnixNano()) }
if r.CreatedAt == "" { r.CreatedAt = time.Now().Format(time.RFC3339) }
s.rules[r.ID] = r
}
func (s *AutomationStore) Delete(id string) {
s.mu.Lock(); defer s.mu.Unlock()
delete(s.rules, id)
}
// ===== Reminders =====
type Reminder struct {
ID string `json:"id"`
Title string `json:"title"`
DueAt string `json:"due_at"` // RFC3339
UserID string `json:"user_id"`
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
}
type ReminderStore struct {
mu sync.RWMutex
reminders map[string]*Reminder
}
func NewReminderStore() *ReminderStore { return &ReminderStore{reminders: make(map[string]*Reminder)} }
func (s *ReminderStore) List(userID string) []*Reminder {
s.mu.RLock(); defer s.mu.RUnlock()
result := make([]*Reminder, 0)
for _, r := range s.reminders {
if r.UserID == userID { result = append(result, r) }
}
return result
}
// ListDue returns undelivered reminders whose time has come.
func (s *ReminderStore) ListDue(now time.Time) []engine.Reminder {
s.mu.RLock(); defer s.mu.RUnlock()
var due []engine.Reminder
for _, r := range s.reminders {
if r.Done { continue }
t, err := time.Parse(time.RFC3339, r.DueAt)
if err != nil || t.After(now) { continue }
due = append(due, engine.Reminder{ID: r.ID, Title: r.Title, DueAt: t, UserID: r.UserID})
}
return due
}
// MarkDone marks a reminder as delivered.
func (s *ReminderStore) MarkDone(id string) {
s.mu.Lock(); defer s.mu.Unlock()
if r, ok := s.reminders[id]; ok { r.Done = true }
}
func (s *ReminderStore) Save(r *Reminder) {
s.mu.Lock(); defer s.mu.Unlock()
if r.ID == "" { r.ID = fmt.Sprintf("rem-%d", time.Now().UnixNano()) }
if r.CreatedAt == "" { r.CreatedAt = time.Now().Format(time.RFC3339) }
s.reminders[r.ID] = r
}
func (s *ReminderStore) Delete(id string) {
s.mu.Lock(); defer s.mu.Unlock()
delete(s.reminders, id)
}
// ===== Notification (API interface for future push server) =====
type Notification struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"` // "info" | "warning" | "error"
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
// NotifyFunc is called when a notification is pushed (injected by main.go).
var NotifyFunc func(n Notification)
// ===== Handlers on Router =====
func (r *Router) automationHandler(w http.ResponseWriter, req *http.Request) {
store := r.deps.Automation
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
switch req.Method {
case "GET":
writeJSON(w, 200, M{"rules": store.List()})
case "POST":
var rule AutomationRule
json.NewDecoder(req.Body).Decode(&rule)
store.Save(&rule)
writeJSON(w, 201, M{"rule": rule})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) automationItemHandler(w http.ResponseWriter, req *http.Request) {
store := r.deps.Automation
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
rest := strings.TrimPrefix(req.URL.Path, "/api/v1/automation/rules/")
// POST /api/v1/automation/rules/{id}/trigger — fire immediately
if strings.HasSuffix(rest, "/trigger") {
if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return }
if r.deps.AutomationEng == nil { writeJSON(w, 503, M{"error": "automation engine not running"}); return }
id := strings.TrimSuffix(rest, "/trigger")
if err := r.deps.AutomationEng.Trigger(req.Context(), id); err != nil {
writeJSON(w, 404, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"ok": true})
return
}
if req.Method == "DELETE" { store.Delete(rest); writeJSON(w, 204, nil); return }
writeJSON(w, 405, M{"error": "method not allowed"})
}
func (r *Router) remindersHandler(w http.ResponseWriter, req *http.Request) {
store := r.deps.Reminders
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
uid := req.Header.Get("X-User-ID")
switch req.Method {
case "GET":
writeJSON(w, 200, M{"reminders": store.List(uid)})
case "POST":
var rem Reminder
json.NewDecoder(req.Body).Decode(&rem)
rem.UserID = uid
store.Save(&rem)
writeJSON(w, 201, M{"reminder": rem})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) reminderItemHandler(w http.ResponseWriter, req *http.Request) {
store := r.deps.Reminders
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
id := strings.TrimPrefix(req.URL.Path, "/api/v1/reminders/")
if req.Method == "DELETE" { store.Delete(id); writeJSON(w, 204, nil); return }
writeJSON(w, 405, M{"error": "method not allowed"})
}
func (r *Router) notificationHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return }
var n Notification
json.NewDecoder(req.Body).Decode(&n)
n.ID = fmt.Sprintf("notif-%d", time.Now().UnixNano())
n.CreatedAt = time.Now().Format(time.RFC3339)
if NotifyFunc != nil { go NotifyFunc(n) }
writeJSON(w, 200, M{"ok": true, "id": n.ID})
}
func (r *Router) messageSearchHandler(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query().Get("q")
uid := r.userID(req)
if q == "" { writeJSON(w, 400, M{"error": "missing query"}); return }
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
messages, err := r.deps.ChatStore.SearchMessages(req.Context(), uid, q, 20)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"messages": messages, "query": q, "total": len(messages)})
}
func (r *Router) sessionExportHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/"), "/export")
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
messages, err := r.deps.ChatStore.ListMessages(req.Context(), id, 200)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
short := id
if len(short) > 8 { short = short[:8] }
format := req.URL.Query().Get("format")
exportedAt := time.Now().Format(time.RFC3339)
switch format {
case "json":
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.json", short))
writeJSON(w, 200, M{"session_id": id, "exported_at": exportedAt, "messages": messages})
case "txt":
var sb strings.Builder
fmt.Fprintf(&sb, "Cyrene Chat - Session %s (exported %s)\n\n", short, exportedAt)
for _, m := range messages {
fmt.Fprintf(&sb, "[%s] %s: %s\n\n", m.CreatedAt.Format("2006-01-02 15:04"), m.Role, m.Content)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.txt", short))
w.WriteHeader(200)
w.Write([]byte(sb.String()))
default: // markdown
var sb strings.Builder
fmt.Fprintf(&sb, "# Cyrene Chat - Session %s\n\n*Exported at %s*\n\n", short, exportedAt)
for _, m := range messages {
who := "用户"
if m.Role == "assistant" { who = "昔涟" }
fmt.Fprintf(&sb, "**%s**(%s):\n\n%s\n\n---\n\n", who, m.CreatedAt.Format("2006-01-02 15:04"), m.Content)
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.md", short))
w.WriteHeader(200)
w.Write([]byte(sb.String()))
}
}
func (r *Router) imageAnalyzeHandler(w http.ResponseWriter, req *http.Request) { if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return }
if r.deps.Vision == nil { writeJSON(w, 503, M{"error": "vision not available"}); return }
var body struct {
ImageURL string `json:"image_url"`
ImageBase64 string `json:"image_base64"` // raw base64 or data URL
Prompt string `json:"prompt"`
}
json.NewDecoder(req.Body).Decode(&body)
imageRef := body.ImageURL
if imageRef == "" && body.ImageBase64 != "" {
imageRef = body.ImageBase64
if !strings.HasPrefix(imageRef, "data:") {
imageRef = "data:image/jpeg;base64," + imageRef
}
}
if imageRef == "" {
writeJSON(w, 400, M{"error": "missing image_url or image_base64"})
return
}
if body.Prompt == "" {
body.Prompt = "描述这张图片的内容。"
}
analysis, err := r.deps.Vision.AnalyzeImage(req.Context(), body.Prompt, imageRef)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"analysis": analysis, "prompt": body.Prompt})
}
func (r *Router) hostExecHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return }
var body struct {
Command string `json:"command"`
Args []string `json:"args"`
Timeout int `json:"timeout_sec"`
}
json.NewDecoder(req.Body).Decode(&body)
// Stub: safe execution requires Docker sandbox
writeJSON(w, 200, M{
"result": "主机执行需要沙箱环境。当前为预留接口。配置 HOST_EXEC_BACKEND=docker 后可用。",
"command": body.Command,
})
}
// ===== Knowledge Base =====
func (r *Router) knowledgeDocsHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.Knowledge == nil { writeJSON(w, 503, M{"error": "knowledge base unavailable"}); return }
uid := r.userID(req)
switch req.Method {
case "GET":
docs, err := r.deps.Knowledge.List(req.Context(), uid, 50)
if err != nil { writeJSON(w, 500, M{"error": err.Error()}); return }
writeJSON(w, 200, M{"docs": docs})
case "POST":
var body struct {
Title string `json:"title"`
Content string `json:"content"`
}
json.NewDecoder(req.Body).Decode(&body)
if body.Content == "" { writeJSON(w, 400, M{"error": "missing content"}); return }
doc, err := r.deps.Knowledge.Ingest(req.Context(), uid, body.Title, body.Content)
if err != nil { writeJSON(w, 500, M{"error": err.Error()}); return }
writeJSON(w, 201, M{"doc": doc})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) knowledgeDocItemHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.Knowledge == nil { writeJSON(w, 503, M{"error": "knowledge base unavailable"}); return }
if req.Method != "DELETE" { writeJSON(w, 405, M{"error": "method not allowed"}); return }
idStr := strings.TrimPrefix(req.URL.Path, "/api/v1/knowledge/docs/")
var id int64
if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil || id <= 0 {
writeJSON(w, 400, M{"error": "invalid id"})
return
}
if err := r.deps.Knowledge.Delete(req.Context(), r.userID(req), id); err != nil {
writeJSON(w, 404, M{"error": err.Error()})
return
}
writeJSON(w, 204, nil)
}
func (r *Router) knowledgeSearchHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.Knowledge == nil { writeJSON(w, 503, M{"error": "knowledge base unavailable"}); return }
if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return }
var body struct {
Query string `json:"query"`
TopK int `json:"top_k"`
}
json.NewDecoder(req.Body).Decode(&body)
if body.Query == "" { writeJSON(w, 400, M{"error": "missing query"}); return }
docs, err := r.deps.Knowledge.Search(req.Context(), r.userID(req), body.Query, body.TopK)
if err != nil { writeJSON(w, 500, M{"error": err.Error()}); return }
writeJSON(w, 200, M{"docs": docs, "total": len(docs)})
}