Files
Cyrene_v2/backend/services/gateway/api/handlers_more.go
T
Aska ac0c2b2c73 feat(ai-core): real vision image analysis
- llm: Adapter.Vision sends multimodal chat requests (text + image_url,
  data URLs supported) and logs the call as purpose 'vision'
- gateway /images/analyze now accepts image_url or image_base64 and
  returns real analysis instead of a placeholder message
- main wires the default LLM adapter as the vision analyzer
- httptest coverage for the vision request/response shape
2026-08-21 18:33:44 +08:00

308 lines
10 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,
})
}