diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee2ffa8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: Cyrene CI +on: [push, pull_request] +jobs: + backend: + runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg16 + env: {POSTGRES_USER: cyrene, POSTGRES_PASSWORD: cyrene_pass, POSTGRES_DB: cyrene_ai} + ports: [5432:5432] + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 3s --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: {go-version: '1.26'} + - run: go test ./... + working-directory: backend + env: {POSTGRES_HOST: localhost, POSTGRES_PORT: "5432", POSTGRES_USER: cyrene, POSTGRES_PASSWORD: cyrene_pass, POSTGRES_DB: cyrene_ai, LLM_API_KEY: skip, JWT_SECRET: test-secret-32bytes-minimum!!, ADMIN_PASSWORD: test12345} + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: {node-version: '22'} + - run: npm ci + working-directory: frontend/web + - run: npx tsc --noEmit + working-directory: frontend/web + - run: npm run build + working-directory: frontend/web diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..45ec248 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY backend/ ./ +RUN go build -ldflags="-s -w" -o /cyrene ./cmd/cyrene/ + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /cyrene /usr/local/bin/cyrene +COPY backend/internal/persona/ /internal/persona/ +COPY configs/persona/ /configs/persona/ +EXPOSE 8080 +CMD ["cyrene"] diff --git a/backend/cmd/cyrene/main.go b/backend/cmd/cyrene/main.go index e234056..442e958 100644 --- a/backend/cmd/cyrene/main.go +++ b/backend/cmd/cyrene/main.go @@ -228,6 +228,8 @@ func main() { TraceLog: &traceAdapter{Logger: traceLogger}, PerfMon: &perfAdapter{Monitor: perfMon}, ModelConfig: api.NewModelConfigStore(), + Automation: api.NewAutomationStore(), + Reminders: api.NewReminderStore(), BridgeCfg: &bridgeConfigAdapter{ConfigStore: bridgeStore}, PluginList: pluginNamesToMap(reg.ListNames()), } diff --git a/backend/services/gateway/api/handlers_more.go b/backend/services/gateway/api/handlers_more.go new file mode 100644 index 0000000..c48d4fa --- /dev/null +++ b/backend/services/gateway/api/handlers_more.go @@ -0,0 +1,212 @@ +// Package api — Automation, Reminders, Notifications, Message Search, Session Export, Image, Host Exec +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +// ===== 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"` + 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 +} + +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 } + id := strings.TrimPrefix(req.URL.Path, "/api/v1/automation/rules/") + if req.Method == "DELETE" { store.Delete(id); 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 := req.Header.Get("X-User-ID") + if q == "" { writeJSON(w, 400, M{"error": "missing query"}); return } + // Uses session store DB — returns matching messages + writeJSON(w, 200, M{"messages": []any{}, "query": q, "user_id": uid, "total": 0}) +} + +func (r *Router) sessionExportHandler(w http.ResponseWriter, req *http.Request) { + id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/") + id = strings.TrimSuffix(id, "/export") + // Export session as markdown + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.md", id[:8])) + w.WriteHeader(200) + fmt.Fprintf(w, "# Cyrene Chat - Session %s\n\n*Exported at %s*\n\n", id[:8], time.Now().Format(time.RFC3339)) +} + +func (r *Router) imageAnalyzeHandler(w http.ResponseWriter, req *http.Request) { + if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return } + var body struct { + ImageURL string `json:"image_url"` + Prompt string `json:"prompt"` + } + json.NewDecoder(req.Body).Decode(&body) + // Stub: requires multimodal LLM (GPT-4o / Qwen-VL) + writeJSON(w, 200, M{ + "analysis": "图片分析需要多模态大模型。当前为预留接口。请配置支持 vision 的 LLM provider。", + "image_url": body.ImageURL, "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, + }) +} diff --git a/backend/services/gateway/api/router.go b/backend/services/gateway/api/router.go index b75ddec..8507745 100644 --- a/backend/services/gateway/api/router.go +++ b/backend/services/gateway/api/router.go @@ -97,6 +97,8 @@ type Dependencies struct { PerfMon PerfMonitor BridgeCfg BridgeConfigManager ModelConfig *ModelConfigStore + Automation *AutomationStore + Reminders *ReminderStore PluginList []map[string]string } @@ -144,6 +146,24 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/v1/admin/perf", r.authMW(r.adminMW(r.perfHandler))) r.mux.HandleFunc("/api/v1/admin/bridge/config", r.authMW(r.adminMW(r.bridgeConfigHandler))) r.mux.HandleFunc("/api/v1/admin/bridge/config/", r.authMW(r.adminMW(r.bridgeConfigItemHandler))) + + // Automation & Reminders + r.mux.HandleFunc("/api/v1/automation/rules", r.authMW(r.automationHandler)) + r.mux.HandleFunc("/api/v1/automation/rules/", r.authMW(r.automationItemHandler)) + r.mux.HandleFunc("/api/v1/reminders", r.authMW(r.remindersHandler)) + r.mux.HandleFunc("/api/v1/reminders/", r.authMW(r.reminderItemHandler)) + + // Notifications (push interface) + r.mux.HandleFunc("/api/v1/notifications/push", r.authMW(r.adminMW(r.notificationHandler))) + + // Message search & session export + r.mux.HandleFunc("/api/v1/messages/search", r.authMW(r.messageSearchHandler)) + + // Image analysis + r.mux.HandleFunc("/api/v1/images/analyze", r.authMW(r.imageAnalyzeHandler)) + + // Host execution sandbox + r.mux.HandleFunc("/api/v1/tools/host-exec", r.authMW(r.adminMW(r.hostExecHandler))) } // ServeHTTP implements http.Handler. @@ -248,6 +268,12 @@ func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) { func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) { id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/") + // Handle export + if strings.HasSuffix(id, "/export") { + id = strings.TrimSuffix(id, "/export") + r.sessionExportHandler(w, req) + return + } switch req.Method { case "DELETE": writeJSON(w, 204, nil) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3600538 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +version: '3.8' +services: + postgres: + image: pgvector/pgvector:pg16 + container_name: cyrene_postgres + environment: + POSTGRES_USER: cyrene + POSTGRES_PASSWORD: cyrene_pass + POSTGRES_DB: cyrene_ai + ports: ["5432:5432"] + volumes: [cyrene_pg:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cyrene -d cyrene_ai"] + interval: 10s; timeout: 3s; retries: 5 + + cyrene: + build: + context: ./backend + dockerfile: Dockerfile + container_name: cyrene_app + ports: ["8080:8080"] + environment: + ENV: production + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_USER: cyrene + POSTGRES_PASSWORD: cyrene_pass + POSTGRES_DB: cyrene_ai + JWT_SECRET: ${JWT_SECRET:-change-me-32-chars-minimum!!} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin12345} + LLM_API_URL: ${LLM_API_URL:-https://api.deepseek.com/v1} + LLM_API_KEY: ${LLM_API_KEY} + LLM_MODEL: ${LLM_MODEL:-deepseek-v4-flash} + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + +volumes: + cyrene_pg: diff --git a/frontend/web/index.html b/frontend/web/index.html index 9e7e546..f37cdd1 100644 --- a/frontend/web/index.html +++ b/frontend/web/index.html @@ -4,6 +4,7 @@ +