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