feat: 第五轮开发 - 14项未来路线图功能完整实现
W1-W14 全部完成: - W1: 消息搜索 (ILIKE全文检索 + SearchModal) - W2: 对话导出 (JSON/Markdown/TXT三格式) - W3: 记忆时间线 DevTools 可视化 - W4: 通知推送系统 (WebSocket + Browser Notification API) - W5: 定时提醒 (30s轮询 + 重复提醒 + WebSocket推送) - W6: 每日简报 (08:00自动生成: 天气+新闻+提醒+AI摘要) - W7: IoT场景自动化 (规则引擎 10s轮询 + 条件评估 + 场景执行) - W8: 语音输入 (浏览器 Speech Recognition API) - W9: STT服务 (voice-service + whisper.cpp) - W10: TTS服务 (浏览器 Speech Synthesis + edge-tts三档回退) - W11: 文件管理 (上传/下载/缩略图/纯Go bilinear缩放) - W12: 知识库RAG (PostgreSQL tsvector + 文档分块 + 检索) - W13: 多模态 (图片上传+分析: Vision API + 本地Go分析回退) - W14: PWA (Service Worker + 离线页 + install prompt) 总计: 6个Go微服务 + 10+前端组件 + 10+ PostgreSQL表 + 4个后台调度器
This commit is contained in:
+18
-3
@@ -17,15 +17,30 @@ devtools/node_modules/
|
||||
devtools/logs/
|
||||
devtools/package-lock.json
|
||||
|
||||
# Go 编译二进制
|
||||
# Go 编译二进制 (模块根 + cmd/ 内产出)
|
||||
backend/ai-core/main
|
||||
backend/ai-core/cmd/main
|
||||
backend/ai-core/ai-core
|
||||
backend/gateway/main
|
||||
backend/gateway/cmd/main
|
||||
backend/gateway/gateway
|
||||
backend/iot-debug-service/main
|
||||
backend/iot-debug-service/cmd/main
|
||||
backend/iot-debug-service/iot-debug-service
|
||||
backend/memory-service/main
|
||||
backend/memory-service/cmd/main
|
||||
backend/memory-service/memory-service
|
||||
backend/tool-engine/cmd/tool-engine
|
||||
backend/tool-engine/tool-engine
|
||||
backend/voice-service/voice-service
|
||||
backend/voice-service/cmd/voice-service
|
||||
|
||||
# Stale binary (old build artifact)
|
||||
backend/cmd
|
||||
# Stale build artifact (legacy)
|
||||
backend/cmd
|
||||
|
||||
# 用户上传文件
|
||||
backend/gateway/uploads/
|
||||
|
||||
# Voice Service 外部依赖 (C++编译产物 / 模型文件)
|
||||
backend/voice-service/whisper.cpp/
|
||||
backend/voice-service/models/
|
||||
+83
-10
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/engine"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/handler"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/router"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
@@ -27,15 +29,78 @@ func main() {
|
||||
// 加载配置
|
||||
cfg := config.Load()
|
||||
|
||||
// 确保上传目录存在
|
||||
if err := os.MkdirAll("./uploads", 0755); err != nil {
|
||||
log.Printf("⚠ 创建上传目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化数据库持久化存储 (降级:连接失败不崩溃)
|
||||
var sessionStore *store.SessionStore
|
||||
var reminderStore *store.ReminderStore
|
||||
var briefingStore *store.BriefingStore
|
||||
var automationStore *store.AutomationStore
|
||||
var fileStore *store.FileStore
|
||||
var knowledgeStore *store.KnowledgeStore
|
||||
var ruleEngine *engine.RuleEngine
|
||||
databaseURL := cfg.DatabaseURL()
|
||||
if s, err := store.NewSessionStore(databaseURL); err != nil {
|
||||
log.Printf("⚠ 会话持久化存储初始化失败 (数据库不可用): %v", err)
|
||||
log.Println("⚠ Gateway 将以仅内存模式运行 — 会话数据在重启后丢失")
|
||||
log.Printf("⚠ 会话持久化存储初始化失败 (数据库不可用): %v", err)
|
||||
log.Println("⚠ Gateway 将以仅内存模式运行 — 会话数据在重启后丢失")
|
||||
} else {
|
||||
sessionStore = s
|
||||
log.Println("✅ 会话持久化存储已启用 (PostgreSQL)")
|
||||
sessionStore = s
|
||||
log.Println("✅ 会话持久化存储已启用 (PostgreSQL)")
|
||||
|
||||
// 初始化提醒存储(复用同一数据库连接)
|
||||
if rs, err := store.NewReminderStore(s.DB()); err != nil {
|
||||
log.Printf("⚠ 提醒存储初始化失败: %v", err)
|
||||
} else {
|
||||
reminderStore = rs
|
||||
log.Println("✅ 提醒持久化存储已启用 (PostgreSQL)")
|
||||
}
|
||||
|
||||
// 初始化简报存储(复用同一数据库连接)
|
||||
if bs, err := store.NewBriefingStore(s.DB()); err != nil {
|
||||
log.Printf("⚠ 简报存储初始化失败: %v", err)
|
||||
} else {
|
||||
briefingStore = bs
|
||||
log.Println("✅ 简报持久化存储已启用 (PostgreSQL)")
|
||||
}
|
||||
|
||||
// 初始化自动化存储(复用同一数据库连接)
|
||||
if as, err := store.NewAutomationStore(s.DB()); err != nil {
|
||||
log.Printf("⚠ 自动化存储初始化失败: %v", err)
|
||||
} else {
|
||||
automationStore = as
|
||||
log.Println("✅ 自动化持久化存储已启用 (PostgreSQL)")
|
||||
}
|
||||
|
||||
// 初始化文件存储(复用同一数据库连接)
|
||||
if fs, err := store.NewFileStore(s.DB()); err != nil {
|
||||
log.Printf("⚠ 文件存储初始化失败: %v", err)
|
||||
} else {
|
||||
fileStore = fs
|
||||
log.Println("✅ 文件持久化存储已启用 (PostgreSQL)")
|
||||
}
|
||||
|
||||
// 初始化知识库存储(复用同一数据库连接)
|
||||
if ks, err := store.NewKnowledgeStore(s.DB()); err != nil {
|
||||
log.Printf("⚠ 知识库存储初始化失败: %v", err)
|
||||
} else {
|
||||
knowledgeStore = ks
|
||||
log.Println("✅ 知识库持久化存储已启用 (PostgreSQL)")
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 WebSocket Hub
|
||||
hub := ws.NewHub()
|
||||
hub.SetStore(sessionStore)
|
||||
hub.SetIdleTimeout(cfg.SessionIdleTimeoutMin)
|
||||
|
||||
// 初始化规则引擎 (需要 Hub)
|
||||
if automationStore != nil {
|
||||
ruleEngine = engine.NewRuleEngine(automationStore, hub)
|
||||
ruleEngine.Start()
|
||||
log.Println("✅ 规则引擎已启动")
|
||||
}
|
||||
|
||||
// 初始化Gin
|
||||
@@ -49,10 +114,7 @@ func main() {
|
||||
r.Use(middleware.RequestLogging())
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
// 初始化WebSocket Hub
|
||||
hub := ws.NewHub()
|
||||
hub.SetStore(sessionStore)
|
||||
hub.SetIdleTimeout(cfg.SessionIdleTimeoutMin)
|
||||
// 启动 WebSocket Hub
|
||||
go hub.Run()
|
||||
|
||||
// 启动闲置会话清理 (标记超时会话为 idle,不删除)
|
||||
@@ -62,8 +124,19 @@ func main() {
|
||||
hub.StartIoTBroadcast(cfg.IoTDebugServiceURL)
|
||||
|
||||
// 注册路由
|
||||
router.Setup(r, hub, cfg, sessionStore)
|
||||
|
||||
router.Setup(r, hub, cfg, sessionStore, reminderStore, briefingStore, automationStore, fileStore, ruleEngine, knowledgeStore, nil)
|
||||
|
||||
// 启动提醒调度器
|
||||
if reminderStore != nil {
|
||||
handler.StartReminderScheduler(reminderStore, hub)
|
||||
}
|
||||
|
||||
// 启动简报调度器
|
||||
if briefingStore != nil && reminderStore != nil {
|
||||
briefingHandler := handler.NewBriefingHandler(cfg, hub, briefingStore, reminderStore)
|
||||
handler.StartBriefingScheduler(briefingHandler, briefingStore, cfg.BriefingTime)
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
|
||||
@@ -45,6 +45,9 @@ type Config struct {
|
||||
// IoT 调试服务
|
||||
IoTDebugServiceURL string
|
||||
|
||||
// Voice 语音识别服务
|
||||
VoiceServiceURL string
|
||||
|
||||
// Tool-Engine 工具引擎服务
|
||||
ToolEngineURL string
|
||||
|
||||
@@ -61,6 +64,12 @@ type Config struct {
|
||||
|
||||
// Webhook (第三方平台接入)
|
||||
WebhookAPIKey string
|
||||
|
||||
// Internal Service Token (内部服务间认证)
|
||||
InternalServiceToken string
|
||||
|
||||
// 每日简报时间 (HH:MM 格式)
|
||||
BriefingTime string
|
||||
}
|
||||
|
||||
// Load 从环境变量加载配置
|
||||
@@ -95,6 +104,8 @@ func Load() *Config {
|
||||
|
||||
IoTDebugServiceURL: getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083"),
|
||||
|
||||
VoiceServiceURL: getEnv("VOICE_SERVICE_URL", "http://localhost:8093"),
|
||||
|
||||
ToolEngineURL: getEnv("TOOL_ENGINE_URL", "http://localhost:8092"),
|
||||
|
||||
LLMAPIURL: getEnv("LLM_API_URL", "https://api.openai.com/v1"),
|
||||
@@ -104,7 +115,10 @@ func Load() *Config {
|
||||
WSMaxConnections: getEnvInt("WS_MAX_CONNECTIONS", 1000),
|
||||
SessionIdleTimeoutMin: getEnvInt("SESSION_IDLE_TIMEOUT_MIN", 30),
|
||||
|
||||
WebhookAPIKey: getEnv("WEBHOOK_API_KEY", ""),
|
||||
WebhookAPIKey: getEnv("WEBHOOK_API_KEY", ""),
|
||||
InternalServiceToken: getEnv("INTERNAL_SERVICE_TOKEN", "cyrene-internal-token-change-me"),
|
||||
|
||||
BriefingTime: getEnv("BRIEFING_TIME", "08:00"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||
)
|
||||
|
||||
// TriggerConfig 触发器配置
|
||||
type TriggerConfig struct {
|
||||
Cron string `json:"cron,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
Days []string `json:"days,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
Property string `json:"property,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// Condition 条件定义
|
||||
type Condition struct {
|
||||
Type string `json:"type"`
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
Property string `json:"property,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// Action 动作定义
|
||||
type Action struct {
|
||||
Type string `json:"type"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
Property string `json:"property,omitempty"`
|
||||
Value interface{} `json:"value,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// RuleEngine 规则引擎
|
||||
type RuleEngine struct {
|
||||
store *store.AutomationStore
|
||||
hub *ws.Hub
|
||||
iotServiceURL string
|
||||
httpClient *http.Client
|
||||
lastTriggered map[string]time.Time
|
||||
mu sync.RWMutex
|
||||
stopCh chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewRuleEngine 创建规则引擎
|
||||
func NewRuleEngine(as *store.AutomationStore, hub *ws.Hub) *RuleEngine {
|
||||
iotServiceURL := os.Getenv("IOT_SERVICE_URL")
|
||||
if iotServiceURL == "" {
|
||||
iotServiceURL = "http://localhost:8083"
|
||||
}
|
||||
|
||||
return &RuleEngine{
|
||||
store: as,
|
||||
hub: hub,
|
||||
iotServiceURL: iotServiceURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
lastTriggered: make(map[string]time.Time),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动后台规则评估 goroutine
|
||||
func (e *RuleEngine) Start() {
|
||||
e.mu.Lock()
|
||||
if e.running {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
e.running = true
|
||||
e.mu.Unlock()
|
||||
|
||||
go e.loop()
|
||||
log.Printf("[RuleEngine] 规则引擎已启动 (IoT服务地址: %s)", e.iotServiceURL)
|
||||
}
|
||||
|
||||
// Stop 停止规则引擎
|
||||
func (e *RuleEngine) Stop() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if !e.running {
|
||||
return
|
||||
}
|
||||
close(e.stopCh)
|
||||
e.running = false
|
||||
log.Println("[RuleEngine] 规则引擎已停止")
|
||||
}
|
||||
|
||||
// loop 规则引擎主循环
|
||||
func (e *RuleEngine) loop() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 首次立即评估
|
||||
e.evaluateAllRules()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-e.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
e.evaluateAllRules()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateAllRules 评估所有启用的规则
|
||||
func (e *RuleEngine) evaluateAllRules() {
|
||||
rules, err := e.store.GetEnabledRules()
|
||||
if err != nil {
|
||||
log.Printf("[RuleEngine] 获取启用的规则失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(rules) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if e.evaluateRule(&rule) {
|
||||
e.ExecuteRuleActions(&rule)
|
||||
e.store.MarkRuleTriggered(rule.ID)
|
||||
e.mu.Lock()
|
||||
e.lastTriggered[rule.ID] = time.Now()
|
||||
e.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateRule 评估单条规则是否应触发
|
||||
func (e *RuleEngine) evaluateRule(rule *store.AutomationRule) bool {
|
||||
// 防重复触发:同一规则在 1 分钟内不重复触发
|
||||
e.mu.RLock()
|
||||
lastTime, exists := e.lastTriggered[rule.ID]
|
||||
e.mu.RUnlock()
|
||||
if exists && time.Since(lastTime) < 1*time.Minute {
|
||||
return false
|
||||
}
|
||||
|
||||
// 解析 trigger_config
|
||||
var triggerCfg TriggerConfig
|
||||
if rule.TriggerConfig != nil {
|
||||
if err := json.Unmarshal(*rule.TriggerConfig, &triggerCfg); err != nil {
|
||||
log.Printf("[RuleEngine] 解析触发器配置失败: rule=%s err=%v", rule.ID, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 评估触发器
|
||||
triggered := false
|
||||
switch rule.TriggerType {
|
||||
case "schedule":
|
||||
triggered = e.evaluateScheduleTrigger(triggerCfg)
|
||||
case "device_state":
|
||||
triggered = e.evaluateDeviceStateTrigger(triggerCfg)
|
||||
case "manual":
|
||||
// 不自动触发
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
if !triggered {
|
||||
return false
|
||||
}
|
||||
|
||||
// 评估 conditions
|
||||
var conditions []Condition
|
||||
if rule.Conditions != nil {
|
||||
if err := json.Unmarshal(*rule.Conditions, &conditions); err != nil {
|
||||
log.Printf("[RuleEngine] 解析条件失败: rule=%s err=%v", rule.ID, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for _, cond := range conditions {
|
||||
if !e.evaluateCondition(cond) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// evaluateScheduleTrigger 评估定时触发器
|
||||
func (e *RuleEngine) evaluateScheduleTrigger(cfg TriggerConfig) bool {
|
||||
now := time.Now()
|
||||
|
||||
// 检查 days (星期)
|
||||
if len(cfg.Days) > 0 {
|
||||
weekday := strings.ToLower(now.Weekday().String()[:3])
|
||||
found := false
|
||||
for _, d := range cfg.Days {
|
||||
if strings.ToLower(strings.TrimSpace(d)) == weekday {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 time
|
||||
if cfg.Time != "" {
|
||||
currentTime := now.Format("15:04")
|
||||
return currentTime == cfg.Time
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// evaluateDeviceStateTrigger 评估设备状态触发器
|
||||
func (e *RuleEngine) evaluateDeviceStateTrigger(cfg TriggerConfig) bool {
|
||||
if cfg.DeviceID == "" || cfg.Property == "" || cfg.Operator == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 从 IoT 服务获取设备状态
|
||||
devices, err := e.fetchIoTDevices()
|
||||
if err != nil {
|
||||
log.Printf("[RuleEngine] 获取设备状态失败: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// 查找目标设备
|
||||
for _, d := range devices {
|
||||
if d.ID != cfg.DeviceID {
|
||||
continue
|
||||
}
|
||||
|
||||
var actualValue float64
|
||||
switch cfg.Property {
|
||||
case "temperature":
|
||||
actualValue = d.Temperature
|
||||
case "value":
|
||||
actualValue = d.Value
|
||||
case "brightness":
|
||||
actualValue = float64(d.Brightness)
|
||||
case "position":
|
||||
actualValue = float64(d.Position)
|
||||
case "battery":
|
||||
actualValue = float64(d.Battery)
|
||||
default:
|
||||
// 尝试从 properties 中获取
|
||||
if props, ok := d.Properties[cfg.Property]; ok {
|
||||
if v, ok := props.(float64); ok {
|
||||
actualValue = v
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return compareValues(actualValue, cfg.Operator, cfg.Value)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// evaluateCondition 评估单个条件
|
||||
func (e *RuleEngine) evaluateCondition(cond Condition) bool {
|
||||
switch cond.Type {
|
||||
case "time_range":
|
||||
if cond.Start == "" || cond.End == "" {
|
||||
return true
|
||||
}
|
||||
now := time.Now()
|
||||
currentTime := now.Format("15:04")
|
||||
return currentTime >= cond.Start && currentTime <= cond.End
|
||||
|
||||
case "device_state":
|
||||
if cond.DeviceID == "" || cond.Property == "" || cond.Operator == "" {
|
||||
return true
|
||||
}
|
||||
devices, err := e.fetchIoTDevices()
|
||||
if err != nil {
|
||||
return true // 无法获取设备状态时不阻塞
|
||||
}
|
||||
|
||||
for _, d := range devices {
|
||||
if d.ID != cond.DeviceID {
|
||||
continue
|
||||
}
|
||||
|
||||
var actualValue float64
|
||||
switch cond.Property {
|
||||
case "temperature":
|
||||
actualValue = d.Temperature
|
||||
case "value":
|
||||
actualValue = d.Value
|
||||
case "brightness":
|
||||
actualValue = float64(d.Brightness)
|
||||
case "position":
|
||||
actualValue = float64(d.Position)
|
||||
case "battery":
|
||||
actualValue = float64(d.Battery)
|
||||
default:
|
||||
if props, ok := d.Properties[cond.Property]; ok {
|
||||
if v, ok := props.(float64); ok {
|
||||
actualValue = v
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return compareValues(actualValue, cond.Operator, cond.Value)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ExecuteRuleActions 执行规则的动作
|
||||
func (e *RuleEngine) ExecuteRuleActions(rule *store.AutomationRule) {
|
||||
var actions []Action
|
||||
if rule.Actions != nil {
|
||||
if err := json.Unmarshal(*rule.Actions, &actions); err != nil {
|
||||
log.Printf("[RuleEngine] 解析动作失败: rule=%s err=%v", rule.ID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[RuleEngine] 执行规则 %s (%s) 的 %d 个动作", rule.ID, rule.Name, len(actions))
|
||||
|
||||
for _, action := range actions {
|
||||
switch action.Type {
|
||||
case "set_device":
|
||||
e.executeSetDevice(action)
|
||||
case "notify":
|
||||
e.executeNotify(action, rule.UserID)
|
||||
default:
|
||||
log.Printf("[RuleEngine] 未知动作类型: %s", action.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteScene 手动触发场景
|
||||
func (e *RuleEngine) ExecuteScene(sceneID, userID string) error {
|
||||
rules, err := e.store.GetSceneRules(sceneID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取场景规则失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[RuleEngine] 执行场景 %s,共 %d 条关联规则", sceneID, len(rules))
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.Enabled {
|
||||
e.ExecuteRuleActions(&rule)
|
||||
e.store.MarkRuleTriggered(rule.ID)
|
||||
e.mu.Lock()
|
||||
e.lastTriggered[rule.ID] = time.Now()
|
||||
e.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeSetDevice 执行设备控制动作
|
||||
func (e *RuleEngine) executeSetDevice(action Action) {
|
||||
url := fmt.Sprintf("%s/api/v1/devices/%s/set", e.iotServiceURL, action.DeviceID)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"property": action.Property,
|
||||
"value": action.Value,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
resp, err := e.httpClient.Post(url, "application/json", bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
log.Printf("[RuleEngine] 设备控制请求失败: device=%s property=%s err=%v",
|
||||
action.DeviceID, action.Property, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
log.Printf("[RuleEngine] 设备控制成功: device=%s property=%s value=%v",
|
||||
action.DeviceID, action.Property, action.Value)
|
||||
} else {
|
||||
log.Printf("[RuleEngine] 设备控制失败: device=%s property=%s status=%d",
|
||||
action.DeviceID, action.Property, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// executeNotify 执行通知动作
|
||||
func (e *RuleEngine) executeNotify(action Action, userID string) {
|
||||
notif := ws.NotificationInfo{
|
||||
ID: "notif_" + randomID(),
|
||||
Type: "info",
|
||||
Title: action.Title,
|
||||
Body: action.Body,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
msg := ws.ServerMessage{
|
||||
Type: "notification",
|
||||
MessageID: "notif_" + randomID(),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Notification: ¬if,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[RuleEngine] 序列化通知失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
e.hub.SendToUser(userID, data)
|
||||
log.Printf("[RuleEngine] 通知已发送: user=%s title=%s", userID, action.Title)
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
// IotDevice 设备信息(从 IoT 服务返回)
|
||||
type IotDevice struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Brightness int `json:"brightness,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Position int `json:"position,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Battery int `json:"battery,omitempty"`
|
||||
Properties map[string]interface{} `json:"properties,omitempty"`
|
||||
}
|
||||
|
||||
// fetchIoTDevices 从 IoT 调试服务获取设备列表
|
||||
func (e *RuleEngine) fetchIoTDevices() ([]IotDevice, error) {
|
||||
resp, err := e.httpClient.Get(e.iotServiceURL + "/api/v1/devices")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求IoT服务失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("IoT服务返回状态码 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Devices []IotDevice `json:"devices"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析IoT设备列表失败: %w", err)
|
||||
}
|
||||
|
||||
return result.Devices, nil
|
||||
}
|
||||
|
||||
// compareValues 比较两个值
|
||||
func compareValues(actual float64, operator string, expected float64) bool {
|
||||
switch operator {
|
||||
case "eq":
|
||||
return actual == expected
|
||||
case "neq":
|
||||
return actual != expected
|
||||
case "gt":
|
||||
return actual > expected
|
||||
case "gte":
|
||||
return actual >= expected
|
||||
case "lt":
|
||||
return actual < expected
|
||||
case "lte":
|
||||
return actual <= expected
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// randomID 使用 crypto/rand 生成随机 ID
|
||||
func randomID() string {
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/engine"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
)
|
||||
|
||||
// AutomationHandler 自动化处理器
|
||||
type AutomationHandler struct {
|
||||
store *store.AutomationStore
|
||||
engine *engine.RuleEngine
|
||||
}
|
||||
|
||||
// NewAutomationHandler 创建自动化处理器
|
||||
func NewAutomationHandler(s *store.AutomationStore, e *engine.RuleEngine) *AutomationHandler {
|
||||
return &AutomationHandler{store: s, engine: e}
|
||||
}
|
||||
|
||||
// ========== 请求/响应体 ==========
|
||||
|
||||
// CreateRuleRequest 创建规则请求
|
||||
type CreateRuleRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
TriggerType string `json:"trigger_type" binding:"required"`
|
||||
TriggerConfig json.RawMessage `json:"trigger_config"`
|
||||
Conditions json.RawMessage `json:"conditions"`
|
||||
Actions json.RawMessage `json:"actions" binding:"required"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpdateRuleRequest 更新规则请求
|
||||
type UpdateRuleRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
TriggerType *string `json:"trigger_type"`
|
||||
TriggerConfig *json.RawMessage `json:"trigger_config"`
|
||||
Conditions *json.RawMessage `json:"conditions"`
|
||||
Actions *json.RawMessage `json:"actions"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// CreateSceneRequest 创建场景请求
|
||||
type CreateSceneRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Icon string `json:"icon"`
|
||||
RuleIDs json.RawMessage `json:"rule_ids"`
|
||||
}
|
||||
|
||||
// UpdateSceneRequest 更新场景请求
|
||||
type UpdateSceneRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
RuleIDs *json.RawMessage `json:"rule_ids"`
|
||||
}
|
||||
|
||||
// ========== Rule Handlers ==========
|
||||
|
||||
// ListRules 获取用户的所有规则
|
||||
// GET /api/v1/automation/rules
|
||||
func (h *AutomationHandler) ListRules(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := h.store.GetRulesByUser(userID)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取规则列表失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"rules": rules,
|
||||
"count": len(rules),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRule 创建规则
|
||||
// POST /api/v1/automation/rules
|
||||
func (h *AutomationHandler) CreateRule(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
triggerConfig := ensureRawMessage(req.TriggerConfig)
|
||||
conditions := ensureRawMessage(req.Conditions)
|
||||
actions := ensureRawMessage(req.Actions)
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
rule := &store.AutomationRule{
|
||||
ID: randomHexID(),
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
TriggerType: req.TriggerType,
|
||||
TriggerConfig: triggerConfig,
|
||||
Conditions: conditions,
|
||||
Actions: actions,
|
||||
Enabled: enabled,
|
||||
}
|
||||
|
||||
if err := h.store.CreateRule(rule); err != nil {
|
||||
log.Printf("[automation] 创建规则失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建规则失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"rule": rule,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRule 获取单条规则
|
||||
func (h *AutomationHandler) GetRule(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
rule, err := h.store.GetRule(id)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取规则失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则失败"})
|
||||
return
|
||||
}
|
||||
if rule == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "规则不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"rule": rule})
|
||||
}
|
||||
|
||||
// UpdateRule 更新规则
|
||||
// PUT /api/v1/automation/rules/:id
|
||||
func (h *AutomationHandler) UpdateRule(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
// 先获取规则验证所有权
|
||||
existing, err := h.store.GetRule(id)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取规则失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则失败"})
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "规则不存在"})
|
||||
return
|
||||
}
|
||||
if existing.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权修改此规则"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 只更新提供的字段
|
||||
if req.Name != nil {
|
||||
existing.Name = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
existing.Description = *req.Description
|
||||
}
|
||||
if req.TriggerType != nil {
|
||||
existing.TriggerType = *req.TriggerType
|
||||
}
|
||||
if req.TriggerConfig != nil {
|
||||
existing.TriggerConfig = req.TriggerConfig
|
||||
}
|
||||
if req.Conditions != nil {
|
||||
existing.Conditions = req.Conditions
|
||||
}
|
||||
if req.Actions != nil {
|
||||
existing.Actions = req.Actions
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
existing.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
if err := h.store.UpdateRule(existing); err != nil {
|
||||
log.Printf("[automation] 更新规则失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新规则失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"rule": existing,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteRule 删除规则
|
||||
// DELETE /api/v1/automation/rules/:id
|
||||
func (h *AutomationHandler) DeleteRule(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.store.GetRule(id)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取规则失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则失败"})
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "规则不存在"})
|
||||
return
|
||||
}
|
||||
if existing.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此规则"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteRule(id); err != nil {
|
||||
log.Printf("[automation] 删除规则失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除规则失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// TriggerRule 手动触发单条规则
|
||||
// POST /api/v1/automation/rules/:id/trigger
|
||||
func (h *AutomationHandler) TriggerRule(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
rule, err := h.store.GetRule(id)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取规则失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取规则失败"})
|
||||
return
|
||||
}
|
||||
if rule == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "规则不存在"})
|
||||
return
|
||||
}
|
||||
if rule.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权触发此规则"})
|
||||
return
|
||||
}
|
||||
|
||||
h.engine.ExecuteRuleActions(rule)
|
||||
h.store.MarkRuleTriggered(id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "规则已触发",
|
||||
})
|
||||
}
|
||||
|
||||
// ========== Scene Handlers ==========
|
||||
|
||||
// ListScenes 获取所有场景
|
||||
// GET /api/v1/automation/scenes
|
||||
func (h *AutomationHandler) ListScenes(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
scenes, err := h.store.GetScenesByUser(userID)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取场景列表失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"scenes": scenes,
|
||||
"count": len(scenes),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateScene 创建场景
|
||||
// POST /api/v1/automation/scenes
|
||||
func (h *AutomationHandler) CreateScene(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateSceneRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ruleIDs := ensureRawMessage(req.RuleIDs)
|
||||
|
||||
scene := &store.AutomationScene{
|
||||
ID: randomHexID(),
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Icon: req.Icon,
|
||||
RuleIDs: ruleIDs,
|
||||
}
|
||||
|
||||
if err := h.store.CreateScene(scene); err != nil {
|
||||
log.Printf("[automation] 创建场景失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建场景失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"scene": scene,
|
||||
})
|
||||
}
|
||||
|
||||
// GetScene 获取单个场景
|
||||
func (h *AutomationHandler) GetScene(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
scene, err := h.store.GetScene(id)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取场景失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景失败"})
|
||||
return
|
||||
}
|
||||
if scene == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "场景不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"scene": scene})
|
||||
}
|
||||
|
||||
// UpdateScene 更新场景
|
||||
// PUT /api/v1/automation/scenes/:id
|
||||
func (h *AutomationHandler) UpdateScene(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.store.GetScene(id)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取场景失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景失败"})
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "场景不存在"})
|
||||
return
|
||||
}
|
||||
if existing.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权修改此场景"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateSceneRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
existing.Name = *req.Name
|
||||
}
|
||||
if req.Icon != nil {
|
||||
existing.Icon = *req.Icon
|
||||
}
|
||||
if req.RuleIDs != nil {
|
||||
existing.RuleIDs = req.RuleIDs
|
||||
}
|
||||
|
||||
if err := h.store.UpdateScene(existing); err != nil {
|
||||
log.Printf("[automation] 更新场景失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新场景失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"scene": existing,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteScene 删除场景
|
||||
// DELETE /api/v1/automation/scenes/:id
|
||||
func (h *AutomationHandler) DeleteScene(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.store.GetScene(id)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取场景失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景失败"})
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "场景不存在"})
|
||||
return
|
||||
}
|
||||
if existing.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此场景"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteScene(id); err != nil {
|
||||
log.Printf("[automation] 删除场景失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除场景失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// ExecuteScene 手动执行场景
|
||||
// POST /api/v1/automation/scenes/:id/execute
|
||||
func (h *AutomationHandler) ExecuteScene(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// 验证场景存在
|
||||
scene, err := h.store.GetScene(id)
|
||||
if err != nil {
|
||||
log.Printf("[automation] 获取场景失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取场景失败"})
|
||||
return
|
||||
}
|
||||
if scene == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "场景不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
if err := h.engine.ExecuteScene(id, userID); err != nil {
|
||||
log.Printf("[automation] 执行场景失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "执行场景失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "场景已执行",
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
// randomHexID 使用 crypto/rand 生成 16 字节 hex ID
|
||||
func randomHexID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ensureRawMessage 确保 json.RawMessage 非空
|
||||
func ensureRawMessage(raw json.RawMessage) *json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(json.RawMessage, len(raw))
|
||||
copy(result, raw)
|
||||
return &result
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||
)
|
||||
|
||||
// BriefingHandler 每日简报处理器
|
||||
type BriefingHandler struct {
|
||||
cfg *config.Config
|
||||
hub *ws.Hub
|
||||
briefingStore *store.BriefingStore
|
||||
reminderStore *store.ReminderStore
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewBriefingHandler 创建简报处理器
|
||||
func NewBriefingHandler(cfg *config.Config, hub *ws.Hub, bs *store.BriefingStore, rs *store.ReminderStore) *BriefingHandler {
|
||||
return &BriefingHandler{
|
||||
cfg: cfg,
|
||||
hub: hub,
|
||||
briefingStore: bs,
|
||||
reminderStore: rs,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateBriefingRequest 手动生成简报请求体
|
||||
type GenerateBriefingRequest struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
}
|
||||
|
||||
// GetBriefing 获取指定日期简报
|
||||
// GET /api/v1/briefings?user_id=xxx&date=2024-01-01
|
||||
func (h *BriefingHandler) GetBriefing(c *gin.Context) {
|
||||
authUserID := middleware.GetUserID(c)
|
||||
userID := c.Query("user_id")
|
||||
date := c.Query("date")
|
||||
|
||||
if !strings.HasPrefix(authUserID, "admin_") || userID == "" {
|
||||
userID = authUserID
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少user_id参数"})
|
||||
return
|
||||
}
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
briefing, err := h.briefingStore.GetBriefingByDate(userID, date)
|
||||
if err != nil {
|
||||
log.Printf("[briefing] 查询简报失败: user=%s date=%s err=%v", userID, date, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询简报失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if briefing == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"briefing": nil,
|
||||
"message": "当日简报尚未生成",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"briefing": briefing})
|
||||
}
|
||||
|
||||
// GetLatestBriefings 获取最近简报列表
|
||||
// GET /api/v1/briefings/latest?user_id=xxx&limit=7
|
||||
func (h *BriefingHandler) GetLatestBriefings(c *gin.Context) {
|
||||
authUserID := middleware.GetUserID(c)
|
||||
userID := c.Query("user_id")
|
||||
|
||||
if !strings.HasPrefix(authUserID, "admin_") || userID == "" {
|
||||
userID = authUserID
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少user_id参数"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 7
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := parseInt(l); err == nil && parsed > 0 && parsed <= 30 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
briefings, err := h.briefingStore.GetLatestBriefings(userID, limit)
|
||||
if err != nil {
|
||||
log.Printf("[briefing] 查询简报列表失败: user=%s err=%v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询简报列表失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"briefings": briefings,
|
||||
"total": len(briefings),
|
||||
})
|
||||
}
|
||||
|
||||
// Generate 手动触发生成今日简报
|
||||
// POST /api/v1/briefings/generate
|
||||
func (h *BriefingHandler) Generate(c *gin.Context) {
|
||||
authUserID := middleware.GetUserID(c)
|
||||
var req GenerateBriefingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 非管理员只能为自己生成
|
||||
if !strings.HasPrefix(authUserID, "admin_") {
|
||||
req.UserID = authUserID
|
||||
}
|
||||
|
||||
if req.UserID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少user_id"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.GenerateDailyBriefing(req.UserID)
|
||||
if err != nil {
|
||||
log.Printf("[briefing] 生成简报失败: user=%s err=%v", req.UserID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "生成简报失败: " + err.Error(),
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成后推送通知
|
||||
h.pushBriefingNotification(req.UserID, result)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"briefing": result,
|
||||
"message": "简报已生成并推送",
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateDailyBriefing 生成每日简报(核心逻辑)
|
||||
func (h *BriefingHandler) GenerateDailyBriefing(userID string) (*store.Briefing, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
briefing := &store.Briefing{
|
||||
ID: "brief_" + generateID(),
|
||||
UserID: userID,
|
||||
Date: today,
|
||||
Status: "pending",
|
||||
Weather: &store.WeatherData{},
|
||||
News: []store.NewsItem{},
|
||||
Reminders: []store.BriefReminder{},
|
||||
}
|
||||
|
||||
// 1. 获取天气数据
|
||||
log.Printf("[briefing] 获取天气数据...")
|
||||
weather, err := h.fetchWeather("Shanghai")
|
||||
if err != nil {
|
||||
log.Printf("[briefing] 天气获取失败 (降级): %v", err)
|
||||
weather = &store.WeatherData{
|
||||
Location: "未知",
|
||||
Temp: 0,
|
||||
Condition: "获取天气失败",
|
||||
Icon: "❓",
|
||||
}
|
||||
}
|
||||
briefing.Weather = weather
|
||||
log.Printf("[briefing] 天气: %s %.1f°C %s", weather.Location, weather.Temp, weather.Condition)
|
||||
|
||||
// 2. 获取今日待办提醒
|
||||
log.Printf("[briefing] 获取待办提醒...")
|
||||
reminders, err := h.reminderStore.GetRemindersByUser(userID, "pending", 10, 0)
|
||||
if err != nil {
|
||||
log.Printf("[briefing] 获取提醒失败: %v", err)
|
||||
} else {
|
||||
now := time.Now()
|
||||
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
|
||||
for _, r := range reminders {
|
||||
if r.RemindAt.Before(endOfDay) || r.RemindAt.Equal(endOfDay) {
|
||||
briefing.Reminders = append(briefing.Reminders, store.BriefReminder{
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
RemindAt: r.RemindAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("[briefing] 今日待办: %d 项", len(briefing.Reminders))
|
||||
|
||||
// 3. 获取新闻摘要(通过 tool-engine web_search)
|
||||
log.Printf("[briefing] 获取新闻摘要...")
|
||||
news, err := h.fetchNews()
|
||||
if err != nil {
|
||||
log.Printf("[briefing] 新闻获取失败 (降级): %v", err)
|
||||
}
|
||||
briefing.News = news
|
||||
log.Printf("[briefing] 新闻: %d 条", len(news))
|
||||
|
||||
// 4. 生成 AI 摘要
|
||||
log.Printf("[briefing] 生成 AI 摘要...")
|
||||
summary, err := h.generateAISummary(briefing)
|
||||
if err != nil {
|
||||
log.Printf("[briefing] AI 摘要生成失败 (降级): %v", err)
|
||||
summary = h.buildFallbackSummary(briefing)
|
||||
}
|
||||
briefing.Summary = summary
|
||||
|
||||
// 5. 标记为已生成
|
||||
now := time.Now()
|
||||
briefing.Status = "generated"
|
||||
briefing.GeneratedAt = &now
|
||||
|
||||
// 6. 持久化
|
||||
if err := h.briefingStore.CreateOrUpdateBriefing(briefing); err != nil {
|
||||
return nil, fmt.Errorf("保存简报失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[briefing] 简报已生成: user=%s date=%s", userID, today)
|
||||
return briefing, nil
|
||||
}
|
||||
|
||||
// fetchWeather 通过 wttr.in API 获取天气数据
|
||||
func (h *BriefingHandler) fetchWeather(location string) (*store.WeatherData, error) {
|
||||
url := fmt.Sprintf("https://wttr.in/%s?format=j1", location)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构建天气请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Cyrene-AI/1.0")
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求天气API失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("天气API返回状态码 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取天气响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析 wttr.in JSON 响应
|
||||
var wttrResp struct {
|
||||
CurrentCondition []struct {
|
||||
TempC string `json:"temp_C"`
|
||||
WeatherDesc []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"weatherDesc"`
|
||||
WeatherIconURL []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"weatherIconUrl"`
|
||||
} `json:"current_condition"`
|
||||
NearestArea []struct {
|
||||
AreaName []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"areaName"`
|
||||
} `json:"nearest_area"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &wttrResp); err != nil {
|
||||
return nil, fmt.Errorf("解析天气数据失败: %w", err)
|
||||
}
|
||||
|
||||
wd := &store.WeatherData{
|
||||
Location: location,
|
||||
}
|
||||
|
||||
if len(wttrResp.NearestArea) > 0 && len(wttrResp.NearestArea[0].AreaName) > 0 {
|
||||
wd.Location = wttrResp.NearestArea[0].AreaName[0].Value
|
||||
}
|
||||
|
||||
if len(wttrResp.CurrentCondition) > 0 {
|
||||
cc := wttrResp.CurrentCondition[0]
|
||||
wd.Temp = parseFloat(cc.TempC)
|
||||
|
||||
if len(cc.WeatherDesc) > 0 {
|
||||
wd.Condition = cc.WeatherDesc[0].Value
|
||||
}
|
||||
|
||||
// 根据天气描述转 emoji
|
||||
wd.Icon = weatherEmoji(wd.Condition)
|
||||
}
|
||||
|
||||
return wd, nil
|
||||
}
|
||||
|
||||
// fetchNews 通过 tool-engine web_search 搜索今日新闻
|
||||
func (h *BriefingHandler) fetchNews() ([]store.NewsItem, error) {
|
||||
if h.cfg.ToolEngineURL == "" {
|
||||
return nil, fmt.Errorf("ToolEngine URL 未配置")
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006年01月02日")
|
||||
query := fmt.Sprintf("%s 今日要闻 热点新闻", today)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"arguments": map[string]interface{}{
|
||||
"query": query,
|
||||
"limit": 5,
|
||||
},
|
||||
})
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/tools/web_search/execute", h.cfg.ToolEngineURL)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构建新闻搜索请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求新闻搜索失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取新闻搜索结果失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("新闻搜索返回状态码 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// 解析 tool-engine 单个工具执行响应: {id, output, error?}
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析新闻搜索结果失败: %w", err)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
log.Printf("[briefing] 新闻搜索失败: %s", result.Error)
|
||||
// 返回降级新闻
|
||||
return []store.NewsItem{
|
||||
{
|
||||
Title: "未能获取今日新闻",
|
||||
URL: "",
|
||||
Source: "系统",
|
||||
Summary: "新闻搜索服务暂时不可用,请稍后再试。",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var news []store.NewsItem
|
||||
|
||||
// 尝试解析搜索结果为结构化数据
|
||||
var searchResults []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(result.Output), &searchResults); err != nil {
|
||||
// 如果不是 JSON 数组,当做纯文本处理
|
||||
news = append(news, store.NewsItem{
|
||||
Title: "今日新闻",
|
||||
URL: "",
|
||||
Source: "搜索引擎",
|
||||
Summary: truncateStr(result.Output, 200),
|
||||
})
|
||||
} else {
|
||||
for _, sr := range searchResults {
|
||||
news = append(news, store.NewsItem{
|
||||
Title: sr.Title,
|
||||
URL: sr.URL,
|
||||
Source: sr.Source,
|
||||
Summary: sr.Snippet,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(news) == 0 {
|
||||
news = []store.NewsItem{
|
||||
{
|
||||
Title: "未能获取今日新闻",
|
||||
URL: "",
|
||||
Source: "系统",
|
||||
Summary: "新闻搜索服务暂时不可用,请稍后再试。",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 限制最多 5 条
|
||||
if len(news) > 5 {
|
||||
news = news[:5]
|
||||
}
|
||||
|
||||
return news, nil
|
||||
}
|
||||
|
||||
// generateAISummary 通过 AI-Core 生成人性化摘要
|
||||
func (h *BriefingHandler) generateAISummary(b *store.Briefing) (string, error) {
|
||||
if h.cfg.AICoreURL == "" {
|
||||
return "", fmt.Errorf("AI-Core URL 未配置")
|
||||
}
|
||||
|
||||
// 构建提示词
|
||||
prompt := h.buildSummaryPrompt(b)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是昔涟,一个温柔贴心的AI助手。请用温暖、亲切的语气回复,像朋友一样关心用户。回复使用中文。",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
"max_tokens": 500,
|
||||
"temperature": 0.7,
|
||||
})
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/chat/completions", h.cfg.AICoreURL)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("构建 AI 请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求 AI-Core 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 AI 响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("AI-Core 返回状态码 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// 解析 OpenAI 兼容响应
|
||||
var aiResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &aiResp); err != nil {
|
||||
return "", fmt.Errorf("解析 AI 响应失败: %w", err)
|
||||
}
|
||||
|
||||
if len(aiResp.Choices) == 0 {
|
||||
return "", fmt.Errorf("AI 返回空响应")
|
||||
}
|
||||
|
||||
return aiResp.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
// buildSummaryPrompt 构建 AI 摘要提示词
|
||||
func (h *BriefingHandler) buildSummaryPrompt(b *store.Briefing) string {
|
||||
var sb strings.Builder
|
||||
today := time.Now().Format("2006年01月02日")
|
||||
|
||||
sb.WriteString(fmt.Sprintf("今天是%s。请根据以下信息,用昔涟温柔的语气为用户生成一份简短的每日简报(控制在200字以内):\n\n", today))
|
||||
|
||||
// 天气
|
||||
if b.Weather != nil && b.Weather.Condition != "" {
|
||||
sb.WriteString(fmt.Sprintf("☁️ 天气:%s,%.0f°C,%s\n", b.Weather.Location, b.Weather.Temp, b.Weather.Condition))
|
||||
}
|
||||
|
||||
// 待办
|
||||
if len(b.Reminders) > 0 {
|
||||
sb.WriteString("📋 今日待办:\n")
|
||||
for _, r := range b.Reminders {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", r.Title))
|
||||
}
|
||||
}
|
||||
|
||||
// 新闻
|
||||
if len(b.News) > 0 {
|
||||
sb.WriteString("📰 今日新闻:\n")
|
||||
for _, n := range b.News {
|
||||
if n.Summary != "" {
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s\n", n.Title, n.Summary))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", n.Title))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n请用昔涟的语气回复,包含:1) 温馨问候 2) 天气提醒 3) 待办提醒 4) 新闻简要 5) 结语祝福。简洁自然即可。")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildFallbackSummary 降级摘要(不依赖 AI)
|
||||
func (h *BriefingHandler) buildFallbackSummary(b *store.Briefing) string {
|
||||
today := time.Now().Format("2006年01月02日")
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("早上好!今天是%s ☀️\n\n", today))
|
||||
|
||||
if b.Weather != nil && b.Weather.Condition != "" {
|
||||
sb.WriteString(fmt.Sprintf("今日%s天气:%s,%.0f°C。", b.Weather.Location, b.Weather.Condition, b.Weather.Temp))
|
||||
if b.Weather.Temp < 10 {
|
||||
sb.WriteString("天气有点凉,记得多穿件衣服哦~")
|
||||
} else if b.Weather.Temp > 30 {
|
||||
sb.WriteString("天气比较热,注意防暑降温哦~")
|
||||
} else {
|
||||
sb.WriteString("天气不错,适合出门走走呢~")
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if len(b.Reminders) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("你今天有 %d 项待办事项,记得按时完成哦!\n", len(b.Reminders)))
|
||||
} else {
|
||||
sb.WriteString("今天没有待办事项,可以轻松一下~\n")
|
||||
}
|
||||
|
||||
if len(b.News) > 0 && b.News[0].Title != "未能获取今日新闻" {
|
||||
sb.WriteString(fmt.Sprintf("\n今日热点:%s。", b.News[0].Title))
|
||||
}
|
||||
|
||||
sb.WriteString("\n\n祝你度过美好的一天!🌸")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// pushBriefingNotification 推送简报通知到用户
|
||||
func (h *BriefingHandler) pushBriefingNotification(userID string, b *store.Briefing) {
|
||||
bodyPreview := truncateStr(b.Summary, 100)
|
||||
|
||||
notif := &ws.NotificationInfo{
|
||||
ID: "briefing_" + b.ID,
|
||||
Type: "info",
|
||||
Title: fmt.Sprintf("📋 今日简报 (%s)", b.Date),
|
||||
Body: bodyPreview,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Data: map[string]interface{}{
|
||||
"briefing_id": b.ID,
|
||||
"date": b.Date,
|
||||
"type": "daily_briefing",
|
||||
},
|
||||
}
|
||||
|
||||
msg := ws.ServerMessage{
|
||||
Type: "notification",
|
||||
MessageID: "briefing_" + b.ID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Notification: notif,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[briefing] 序列化简报通知失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.hub.SendToUser(userID, data)
|
||||
|
||||
// 更新简报状态为已送达
|
||||
now := time.Now()
|
||||
b.Status = "delivered"
|
||||
b.DeliveredAt = &now
|
||||
if err := h.briefingStore.CreateOrUpdateBriefing(b); err != nil {
|
||||
log.Printf("[briefing] 更新简报送达状态失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[briefing] 简报通知已推送: user=%s date=%s", userID, b.Date)
|
||||
}
|
||||
|
||||
// StartBriefingScheduler 启动简报调度器
|
||||
// briefingTime 格式: "HH:MM",默认 "08:00"
|
||||
func StartBriefingScheduler(handler *BriefingHandler, briefingStore *store.BriefingStore, briefingTime string) {
|
||||
if briefingTime == "" {
|
||||
briefingTime = "08:00"
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 每 30 秒检查一次是否到达简报时间
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Printf("[BriefingScheduler] 简报调度器已启动 (简报时间: %s)", briefingTime)
|
||||
|
||||
// 记录今天是否已触发
|
||||
lastTriggeredDate := ""
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
currentTime := now.Format("15:04")
|
||||
currentDate := now.Format("2006-01-02")
|
||||
|
||||
// 检查是否到达简报时间且今天尚未触发
|
||||
if currentTime == briefingTime && currentDate != lastTriggeredDate {
|
||||
log.Printf("[BriefingScheduler] 触发每日简报生成: %s", currentDate)
|
||||
lastTriggeredDate = currentDate
|
||||
|
||||
// 获取所有用户
|
||||
users, err := briefingStore.GetAllUsers()
|
||||
if err != nil {
|
||||
log.Printf("[BriefingScheduler] 获取用户列表失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果没有从 reminders 表获取到用户,也尝试从 briefings 表获取
|
||||
if len(users) == 0 {
|
||||
users, _ = briefingStore.GetUsersWithBriefings()
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
log.Println("[BriefingScheduler] 没有找到用户,跳过简报生成")
|
||||
continue
|
||||
}
|
||||
|
||||
for _, userID := range users {
|
||||
log.Printf("[BriefingScheduler] 为用户 %s 生成简报...", userID)
|
||||
result, err := handler.GenerateDailyBriefing(userID)
|
||||
if err != nil {
|
||||
log.Printf("[BriefingScheduler] 生成简报失败: user=%s err=%v", userID, err)
|
||||
continue
|
||||
}
|
||||
handler.pushBriefingNotification(userID, result)
|
||||
}
|
||||
|
||||
log.Printf("[BriefingScheduler] 每日简报已生成完毕,共 %d 个用户", len(users))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
// weatherEmoji 根据天气描述返回对应 emoji
|
||||
func weatherEmoji(condition string) string {
|
||||
c := strings.ToLower(condition)
|
||||
switch {
|
||||
case strings.Contains(c, "sunny") || strings.Contains(c, "clear") || strings.Contains(c, "晴"):
|
||||
return "☀️"
|
||||
case strings.Contains(c, "partly cloudy") || strings.Contains(c, "多云"):
|
||||
return "⛅"
|
||||
case strings.Contains(c, "cloudy") || strings.Contains(c, "阴"):
|
||||
return "☁️"
|
||||
case strings.Contains(c, "rain") || strings.Contains(c, "drizzle") || strings.Contains(c, "雨"):
|
||||
return "🌧️"
|
||||
case strings.Contains(c, "thunder") || strings.Contains(c, "雷"):
|
||||
return "⛈️"
|
||||
case strings.Contains(c, "snow") || strings.Contains(c, "雪"):
|
||||
return "❄️"
|
||||
case strings.Contains(c, "fog") || strings.Contains(c, "mist") || strings.Contains(c, "雾"):
|
||||
return "🌫️"
|
||||
case strings.Contains(c, "wind") || strings.Contains(c, "风"):
|
||||
return "💨"
|
||||
default:
|
||||
return "🌤️"
|
||||
}
|
||||
}
|
||||
|
||||
// parseFloat 安全解析浮点数
|
||||
func parseFloat(s string) float64 {
|
||||
var f float64
|
||||
fmt.Sscanf(s, "%f", &f)
|
||||
return f
|
||||
}
|
||||
|
||||
// parseInt 安全解析整数
|
||||
func parseInt(s string) (int, error) {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// truncateStr 截断字符串
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
@@ -128,12 +128,15 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
|
||||
h.hub.UpdateSessionState(client.SessionID, "thinking")
|
||||
|
||||
// 构建 AI-Core 请求
|
||||
aiReq := map[string]string{
|
||||
aiReq := map[string]interface{}{
|
||||
"user_id": client.UserID,
|
||||
"session_id": client.SessionID,
|
||||
"message": msg.Content,
|
||||
"mode": mode,
|
||||
}
|
||||
if len(msg.Attachments) > 0 {
|
||||
aiReq["attachments"] = msg.Attachments
|
||||
}
|
||||
reqBody, err := json.Marshal(aiReq)
|
||||
if err != nil {
|
||||
log.Printf("[chat] 序列化请求失败: %v", err)
|
||||
@@ -148,12 +151,16 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
|
||||
}
|
||||
|
||||
// 缓存用户消息(在 goroutine 前完成,避免竞态)
|
||||
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
|
||||
userMsg := ws.Message{
|
||||
ID: "msg_" + generateID(),
|
||||
Role: "user",
|
||||
Content: msg.Content,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
if len(msg.Attachments) > 0 {
|
||||
userMsg.Attachments = msg.Attachments
|
||||
}
|
||||
h.hub.CacheMessage(client.UserID, client.SessionID, userMsg)
|
||||
|
||||
// 在 goroutine 中进行 AI-Core 调用和流式发送,避免阻塞 ReadPump
|
||||
go h.streamResponse(client, mode, reqBody, msg.Content)
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
)
|
||||
|
||||
// FileHandler 文件管理处理器
|
||||
type FileHandler struct {
|
||||
store *store.FileStore
|
||||
uploadDir string
|
||||
}
|
||||
|
||||
// NewFileHandler 创建文件处理器
|
||||
func NewFileHandler(s *store.FileStore) *FileHandler {
|
||||
return &FileHandler{
|
||||
store: s,
|
||||
uploadDir: "./uploads",
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 允许的文件类型 ==========
|
||||
|
||||
var allowedMimeTypes = map[string]bool{
|
||||
// 图片
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/svg+xml": true,
|
||||
// 文档
|
||||
"application/pdf": true,
|
||||
"text/plain": true,
|
||||
"text/markdown": true,
|
||||
"application/msword": true,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||
// 音频
|
||||
"audio/mpeg": true,
|
||||
"audio/wav": true,
|
||||
"audio/ogg": true,
|
||||
"audio/webm": true,
|
||||
// 视频
|
||||
"video/mp4": true,
|
||||
"video/webm": true,
|
||||
}
|
||||
|
||||
var allowedExtensions = map[string]string{
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".mp4": "video/mp4",
|
||||
}
|
||||
|
||||
const maxFileSize = 20 * 1024 * 1024 // 20MB
|
||||
|
||||
// ========== POST /api/v1/files/upload ==========
|
||||
|
||||
// Upload 处理文件上传
|
||||
func (h *FileHandler) Upload(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
// Nil store guard — 数据库不可用时拒绝上传
|
||||
if h.store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用,数据库未连接", "errorType": "service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到上传文件", "errorType": "missing_file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 检查文件大小
|
||||
if header.Size > maxFileSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "文件大小超过限制 (最大 20MB)",
|
||||
"errorType": "file_too_large",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if mimeType == "" || mimeType == "application/octet-stream" {
|
||||
// 尝试从扩展名推断
|
||||
if inferred, ok := allowedExtensions[ext]; ok {
|
||||
mimeType = inferred
|
||||
}
|
||||
}
|
||||
if !allowedMimeTypes[mimeType] {
|
||||
// 再尝试通过扩展名检查
|
||||
if _, ok := allowedExtensions[ext]; !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "不支持的文件类型: " + mimeType,
|
||||
"errorType": "unsupported_type",
|
||||
})
|
||||
return
|
||||
}
|
||||
mimeType = allowedExtensions[ext]
|
||||
}
|
||||
|
||||
// 安全化文件名:移除路径分隔符和特殊字符
|
||||
safeFilename := sanitizeFilename(header.Filename)
|
||||
|
||||
// 生成文件ID (crypto/rand UUID v4)
|
||||
fileID := generateUUID()
|
||||
|
||||
// 创建按日期组织的目录
|
||||
dateDir := time.Now().Format("2006-01-02")
|
||||
storedDir := filepath.Join(h.uploadDir, dateDir)
|
||||
if err := os.MkdirAll(storedDir, 0755); err != nil {
|
||||
log.Printf("[FileHandler] 创建上传目录失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败", "errorType": "server_error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 存储路径:以UUID+原始扩展名保存
|
||||
storedFilename := fileID + ext
|
||||
storedPath := filepath.Join(storedDir, storedFilename)
|
||||
|
||||
// 计算 SHA256 hash
|
||||
hasher := sha256.New()
|
||||
teeReader := io.TeeReader(file, hasher)
|
||||
|
||||
// 保存到磁盘
|
||||
dst, err := os.Create(storedPath)
|
||||
if err != nil {
|
||||
log.Printf("[FileHandler] 创建文件失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败", "errorType": "server_error"})
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
written, err := io.Copy(dst, teeReader)
|
||||
if err != nil {
|
||||
os.Remove(storedPath)
|
||||
log.Printf("[FileHandler] 写入文件失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入文件失败", "errorType": "server_error"})
|
||||
return
|
||||
}
|
||||
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// 去重检查:如果存在相同hash的文件,复用已有记录
|
||||
if existing, err := h.store.GetFileByHash(hash); err == nil && existing != nil {
|
||||
// 删除刚保存的重复文件
|
||||
os.Remove(storedPath)
|
||||
log.Printf("[FileHandler] 文件去重: 复用已有文件 %s (hash=%s)", existing.ID, hash[:16])
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": existing.ID,
|
||||
"filename": existing.Filename,
|
||||
"mime_type": existing.MimeType,
|
||||
"size": existing.Size,
|
||||
"url": fmt.Sprintf("/api/v1/files/%s/download", existing.ID),
|
||||
"dedup": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建数据库记录
|
||||
fileRecord := &store.File{
|
||||
ID: fileID,
|
||||
UserID: userID,
|
||||
Filename: safeFilename,
|
||||
StoredPath: storedPath,
|
||||
MimeType: mimeType,
|
||||
Size: written,
|
||||
Hash: hash,
|
||||
IsPublic: false,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.store.CreateFile(fileRecord); err != nil {
|
||||
os.Remove(storedPath)
|
||||
log.Printf("[FileHandler] 创建文件记录失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文件记录失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[FileHandler] 文件上传成功: %s (%s, %d bytes, hash=%s)", fileID, safeFilename, written, hash[:16])
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": fileID,
|
||||
"filename": safeFilename,
|
||||
"mime_type": mimeType,
|
||||
"size": written,
|
||||
"url": fmt.Sprintf("/api/v1/files/%s/download", fileID),
|
||||
})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/files ==========
|
||||
|
||||
// List 列出用户的所有文件 (支持分页)
|
||||
func (h *FileHandler) List(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
if h.store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
|
||||
files, total, err := h.store.GetUserFiles(userID, page, limit)
|
||||
if err != nil {
|
||||
log.Printf("[FileHandler] 查询文件列表失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件列表失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := make([]gin.H, 0, len(files))
|
||||
for _, f := range files {
|
||||
item := gin.H{
|
||||
"id": f.ID,
|
||||
"user_id": f.UserID,
|
||||
"filename": f.Filename,
|
||||
"mime_type": f.MimeType,
|
||||
"size": f.Size,
|
||||
"hash": f.Hash,
|
||||
"is_public": f.IsPublic,
|
||||
"created_at": f.CreatedAt.UnixMilli(),
|
||||
"url": fmt.Sprintf("/api/v1/files/%s/download", f.ID),
|
||||
}
|
||||
// 图片类型添加缩略图URL
|
||||
if isImageType(f.MimeType) {
|
||||
item["thumbnail_url"] = fmt.Sprintf("/api/v1/files/%s/thumbnail", f.ID)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"files": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/files/:id ==========
|
||||
|
||||
// Get 获取文件元数据
|
||||
func (h *FileHandler) Get(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
fileID := c.Param("id")
|
||||
|
||||
if h.store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := h.store.GetFile(fileID)
|
||||
if err != nil {
|
||||
log.Printf("[FileHandler] 查询文件失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "文件不存在",
|
||||
"errorType": "file_not_found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if f.UserID != userID && !f.IsPublic {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
item := gin.H{
|
||||
"id": f.ID,
|
||||
"user_id": f.UserID,
|
||||
"filename": f.Filename,
|
||||
"mime_type": f.MimeType,
|
||||
"size": f.Size,
|
||||
"hash": f.Hash,
|
||||
"is_public": f.IsPublic,
|
||||
"created_at": f.CreatedAt.UnixMilli(),
|
||||
"url": fmt.Sprintf("/api/v1/files/%s/download", f.ID),
|
||||
}
|
||||
if isImageType(f.MimeType) {
|
||||
item["thumbnail_url"] = fmt.Sprintf("/api/v1/files/%s/thumbnail", f.ID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/files/:id/download ==========
|
||||
|
||||
// Download 下载文件
|
||||
func (h *FileHandler) Download(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
fileID := c.Param("id")
|
||||
|
||||
if h.store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := h.store.GetFile(fileID)
|
||||
if err != nil {
|
||||
log.Printf("[FileHandler] 查询文件失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "文件不存在",
|
||||
"errorType": "file_not_found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if f.UserID != userID && !f.IsPublic {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件在磁盘上是否存在
|
||||
if _, err := os.Stat(f.StoredPath); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "文件实体不存在(可能已被清理)",
|
||||
"errorType": "file_missing",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置 Content-Disposition
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, f.Filename))
|
||||
c.Header("Content-Type", f.MimeType)
|
||||
c.File(f.StoredPath)
|
||||
}
|
||||
|
||||
// ========== DELETE /api/v1/files/:id ==========
|
||||
|
||||
// Delete 删除文件
|
||||
func (h *FileHandler) Delete(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
fileID := c.Param("id")
|
||||
|
||||
if h.store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := h.store.GetFile(fileID)
|
||||
if err != nil {
|
||||
log.Printf("[FileHandler] 查询文件失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "文件不存在",
|
||||
"errorType": "file_not_found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if f.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此文件", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除磁盘上的文件(忽略错误,可能已被删除)
|
||||
if err := os.Remove(f.StoredPath); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[FileHandler] 删除磁盘文件失败 (stored_path=%s): %v", f.StoredPath, err)
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
if err := h.store.DeleteFile(fileID); err != nil {
|
||||
log.Printf("[FileHandler] 删除文件记录失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文件记录失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/files/:id/thumbnail ==========
|
||||
|
||||
// Thumbnail 返回文件缩略图
|
||||
func (h *FileHandler) Thumbnail(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
fileID := c.Param("id")
|
||||
|
||||
if h.store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := h.store.GetFile(fileID)
|
||||
if err != nil {
|
||||
log.Printf("[FileHandler] 查询文件失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "文件不存在",
|
||||
"errorType": "file_not_found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if f.UserID != userID && !f.IsPublic {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是图片,生成缩略图
|
||||
if isImageType(f.MimeType) && f.MimeType != "image/svg+xml" {
|
||||
if thumbData, contentType, err := generateThumbnail(f.StoredPath, f.MimeType); err == nil {
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Data(http.StatusOK, contentType, thumbData)
|
||||
return
|
||||
} else {
|
||||
log.Printf("[FileHandler] 生成缩略图失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 非图片文件或缩略图生成失败,返回占位图标 SVG
|
||||
placeholder := generatePlaceholderSVG(f.MimeType)
|
||||
c.Header("Content-Type", "image/svg+xml")
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Data(http.StatusOK, "image/svg+xml", []byte(placeholder))
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
// isImageType 判断是否图片类型
|
||||
func isImageType(mimeType string) bool {
|
||||
return strings.HasPrefix(mimeType, "image/")
|
||||
}
|
||||
|
||||
// sanitizeFilename 安全化文件名:移除路径分隔符、特殊字符
|
||||
var unsafeChars = regexp.MustCompile(`[\\/:*?"<>|]`)
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
// 移除路径分隔符和Windows特殊字符
|
||||
name = unsafeChars.ReplaceAllString(name, "_")
|
||||
// 限制长度
|
||||
if len(name) > 255 {
|
||||
ext := filepath.Ext(name)
|
||||
base := name[:255-len(ext)]
|
||||
name = base + ext
|
||||
}
|
||||
// 为空时给默认名
|
||||
if name == "" || name == "." || name == ".." {
|
||||
name = "unnamed_file"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// generateThumbnail 使用 Go 标准库生成缩略图 (最大 300x300)
|
||||
func generateThumbnail(filePath, mimeType string) ([]byte, string, error) {
|
||||
// 打开源文件
|
||||
srcFile, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("打开文件失败: %w", err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// 解码图像
|
||||
var srcImg image.Image
|
||||
switch mimeType {
|
||||
case "image/jpeg":
|
||||
srcImg, err = jpeg.Decode(srcFile)
|
||||
case "image/png":
|
||||
srcImg, err = png.Decode(srcFile)
|
||||
default:
|
||||
// 尝试通用解码
|
||||
srcImg, _, err = image.Decode(srcFile)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("解码图像失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算缩略图尺寸
|
||||
bounds := srcImg.Bounds()
|
||||
srcW := bounds.Dx()
|
||||
srcH := bounds.Dy()
|
||||
|
||||
maxDim := 300
|
||||
newW, newH := srcW, srcH
|
||||
if srcW > maxDim || srcH > maxDim {
|
||||
if srcW > srcH {
|
||||
newW = maxDim
|
||||
newH = srcH * maxDim / srcW
|
||||
} else {
|
||||
newH = maxDim
|
||||
newW = srcW * maxDim / srcH
|
||||
}
|
||||
}
|
||||
if newW < 1 {
|
||||
newW = 1
|
||||
}
|
||||
if newH < 1 {
|
||||
newH = 1
|
||||
}
|
||||
|
||||
// 使用标准库双线性缩放
|
||||
thumbImg := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
||||
scaleBilinear(thumbImg, srcImg)
|
||||
|
||||
// 编码为 JPEG 输出
|
||||
var buf strings.Builder
|
||||
errWriter := &stringWriter{&buf}
|
||||
if err := jpeg.Encode(errWriter, thumbImg, &jpeg.Options{Quality: 80}); err != nil {
|
||||
return nil, "", fmt.Errorf("编码缩略图失败: %w", err)
|
||||
}
|
||||
|
||||
return []byte(buf.String()), "image/jpeg", nil
|
||||
}
|
||||
|
||||
// stringWriter 实现 io.Writer 到 strings.Builder
|
||||
type stringWriter struct {
|
||||
b *strings.Builder
|
||||
}
|
||||
|
||||
func (w *stringWriter) Write(p []byte) (int, error) {
|
||||
return w.b.Write(p)
|
||||
}
|
||||
|
||||
// generatePlaceholderSVG 为非图片文件生成占位图标 SVG
|
||||
func generatePlaceholderSVG(mimeType string) string {
|
||||
var icon, color string
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(mimeType, "audio/"):
|
||||
icon = "🎵"
|
||||
color = "#8B5CF6" // purple
|
||||
case strings.HasPrefix(mimeType, "video/"):
|
||||
icon = "🎬"
|
||||
color = "#EF4444" // red
|
||||
case strings.HasPrefix(mimeType, "application/pdf"):
|
||||
icon = "📄"
|
||||
color = "#F59E0B" // amber
|
||||
case strings.HasPrefix(mimeType, "text/"):
|
||||
icon = "📝"
|
||||
color = "#3B82F6" // blue
|
||||
default:
|
||||
icon = "📎"
|
||||
color = "#6B7280" // gray
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300">
|
||||
<rect width="300" height="300" fill="%s" opacity="0.1"/>
|
||||
<text x="150" y="160" text-anchor="middle" font-size="64" fill="%s">%s</text>
|
||||
<text x="150" y="220" text-anchor="middle" font-size="16" fill="%s" opacity="0.7">%s</text>
|
||||
</svg>`, color, color, icon, color, getMimeTypeShort(mimeType))
|
||||
}
|
||||
|
||||
// getMimeTypeShort 获取MIME类型简称
|
||||
func getMimeTypeShort(mimeType string) string {
|
||||
parts := strings.SplitN(mimeType, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return mimeType
|
||||
}
|
||||
return strings.ToUpper(parts[1])
|
||||
}
|
||||
|
||||
// generateUUID 使用 crypto/rand 生成 UUID v4 格式的字符串
|
||||
func generateUUID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// 降级方案:基于时间戳 + 随机数的唯一标识
|
||||
b = make([]byte, 16)
|
||||
ts := time.Now().UnixNano()
|
||||
for i := 0; i < 8; i++ {
|
||||
b[i] = byte(ts >> (i * 8))
|
||||
}
|
||||
// 用简单 PRNG 填充剩余字节
|
||||
for i := 8; i < 16; i++ {
|
||||
b[i] = byte((ts * int64(i+1)) % 256)
|
||||
}
|
||||
}
|
||||
// 设置 UUID v4 版本位 (version = 4)
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
// 设置 UUID variant 位 (variant = 10xx)
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// scaleBilinear 使用双线性插值将 src 图像缩放到 dst 的尺寸 (纯标准库实现)
|
||||
func scaleBilinear(dst *image.RGBA, src image.Image) {
|
||||
dstBounds := dst.Bounds()
|
||||
srcBounds := src.Bounds()
|
||||
dstW := dstBounds.Dx()
|
||||
dstH := dstBounds.Dy()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
|
||||
// 计算缩放比例
|
||||
scaleX := float64(srcW) / float64(dstW)
|
||||
scaleY := float64(srcH) / float64(dstH)
|
||||
|
||||
for dy := 0; dy < dstH; dy++ {
|
||||
for dx := 0; dx < dstW; dx++ {
|
||||
// 源图像中的浮点坐标
|
||||
sx := float64(dx)*scaleX + float64(srcBounds.Min.X)
|
||||
sy := float64(dy)*scaleY + float64(srcBounds.Min.Y)
|
||||
|
||||
// 四个邻近像素的整数坐标
|
||||
x0 := int(sx)
|
||||
y0 := int(sy)
|
||||
x1 := x0 + 1
|
||||
y1 := y0 + 1
|
||||
|
||||
// 边界限制
|
||||
if x0 < srcBounds.Min.X {
|
||||
x0 = srcBounds.Min.X
|
||||
}
|
||||
if x1 >= srcBounds.Max.X {
|
||||
x1 = srcBounds.Max.X - 1
|
||||
}
|
||||
if x0 >= srcBounds.Max.X {
|
||||
x0 = srcBounds.Max.X - 1
|
||||
}
|
||||
if x1 < srcBounds.Min.X {
|
||||
x1 = srcBounds.Min.X
|
||||
}
|
||||
if y0 < srcBounds.Min.Y {
|
||||
y0 = srcBounds.Min.Y
|
||||
}
|
||||
if y1 >= srcBounds.Max.Y {
|
||||
y1 = srcBounds.Max.Y - 1
|
||||
}
|
||||
if y0 >= srcBounds.Max.Y {
|
||||
y0 = srcBounds.Max.Y - 1
|
||||
}
|
||||
if y1 < srcBounds.Min.Y {
|
||||
y1 = srcBounds.Min.Y
|
||||
}
|
||||
|
||||
// 插值权重
|
||||
fracX := sx - float64(x0)
|
||||
fracY := sy - float64(y0)
|
||||
|
||||
// 四个角的 RGBA 值
|
||||
r00, g00, b00, a00 := src.At(x0, y0).RGBA()
|
||||
r10, g10, b10, a10 := src.At(x1, y0).RGBA()
|
||||
r01, g01, b01, a01 := src.At(x0, y1).RGBA()
|
||||
r11, g11, b11, a11 := src.At(x1, y1).RGBA()
|
||||
|
||||
// 双线性插值 (在 0-65535 范围内进行)
|
||||
interp := func(c00, c10, c01, c11 uint32) uint8 {
|
||||
top := float64(c00)*(1-fracX) + float64(c10)*fracX
|
||||
bot := float64(c01)*(1-fracX) + float64(c11)*fracX
|
||||
val := top*(1-fracY) + bot*fracY
|
||||
return uint8(val / 256)
|
||||
}
|
||||
|
||||
r := interp(r00, r10, r01, r11)
|
||||
g := interp(g00, g10, g01, g11)
|
||||
b := interp(b00, b10, b01, b11)
|
||||
a := interp(a00, a10, a01, a11)
|
||||
|
||||
dst.SetRGBA(dx+int(dstBounds.Min.X), dy+int(dstBounds.Min.Y), color.RGBA{r, g, b, a})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
)
|
||||
|
||||
// ImageHandler 图片分析处理器
|
||||
type ImageHandler struct {
|
||||
cfg *config.Config
|
||||
fileStore *store.FileStore
|
||||
}
|
||||
|
||||
// NewImageHandler 创建图片分析处理器
|
||||
func NewImageHandler(cfg *config.Config, fileStore *store.FileStore) *ImageHandler {
|
||||
return &ImageHandler{
|
||||
cfg: cfg,
|
||||
fileStore: fileStore,
|
||||
}
|
||||
}
|
||||
|
||||
// ImageAnalysis 图片分析结果
|
||||
type ImageAnalysis struct {
|
||||
Format string `json:"format"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Description string `json:"description"`
|
||||
TopColors []ColorInfo `json:"top_colors,omitempty"`
|
||||
EXIF map[string]string `json:"exif,omitempty"`
|
||||
AnalyzedBy string `json:"analyzed_by"` // "openai_vision" | "local"
|
||||
}
|
||||
|
||||
// ColorInfo 颜色信息
|
||||
type ColorInfo struct {
|
||||
Hex string `json:"hex"`
|
||||
Percent float64 `json:"percent"`
|
||||
}
|
||||
|
||||
// AnalyzeRequestBody 分析请求体
|
||||
type AnalyzeRequestBody struct {
|
||||
FileID string `json:"file_id"`
|
||||
}
|
||||
|
||||
// ========== POST /api/v1/images/analyze ==========
|
||||
|
||||
// Analyze 分析上传的图片 (multipart/form-data 或 JSON)
|
||||
func (h *ImageHandler) Analyze(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
// 尝试 JSON body: {"file_id": "xxx"}
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
var body AnalyzeRequestBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.FileID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 file_id 字段", "errorType": "invalid_request"})
|
||||
return
|
||||
}
|
||||
h.analyzeByFileID(c, userID, body.FileID)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试 multipart/form-data: 直接上传图片分析
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
// 也尝试 "image" 字段名
|
||||
file, header, err = c.Request.FormFile("image")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到图片文件 (使用 file 或 image 字段)", "errorType": "missing_file"})
|
||||
return
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
h.analyzeUploadedFile(c, userID, file, header.Filename, header.Size)
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/images/analyze/:file_id ==========
|
||||
|
||||
// AnalyzeByID 对已上传的文件进行分析
|
||||
func (h *ImageHandler) AnalyzeByID(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
fileID := c.Param("file_id")
|
||||
if fileID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 file_id", "errorType": "invalid_request"})
|
||||
return
|
||||
}
|
||||
h.analyzeByFileID(c, userID, fileID)
|
||||
}
|
||||
|
||||
// analyzeByFileID 根据文件ID分析已存储的图片
|
||||
func (h *ImageHandler) analyzeByFileID(c *gin.Context, userID, fileID string) {
|
||||
if h.fileStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := h.fileStore.GetFile(fileID)
|
||||
if err != nil {
|
||||
log.Printf("[ImageHandler] 查询文件失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文件失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在", "errorType": "file_not_found"})
|
||||
return
|
||||
}
|
||||
if f.UserID != userID && !f.IsPublic {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
if !isImageType(f.MimeType) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件不是图片类型: " + f.MimeType, "errorType": "unsupported_type"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.analyzeImage(f.StoredPath, f.MimeType, f.Size)
|
||||
if err != nil {
|
||||
log.Printf("[ImageHandler] 图片分析失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片分析失败: " + err.Error(), "errorType": "analysis_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// analyzeUploadedFile 分析直接上传的图片文件
|
||||
func (h *ImageHandler) analyzeUploadedFile(c *gin.Context, userID string, file io.Reader, filename string, fileSize int64) {
|
||||
// 检查文件大小 (10MB 限制)
|
||||
const maxImageSize = 10 * 1024 * 1024
|
||||
if fileSize > maxImageSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片大小超过限制 (最大 10MB)", "errorType": "file_too_large"})
|
||||
return
|
||||
}
|
||||
|
||||
// 读取文件到内存
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "errorType": "read_error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检测格式
|
||||
_, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无法解码图片: " + err.Error(), "errorType": "decode_error"})
|
||||
return
|
||||
}
|
||||
|
||||
mimeType := "image/" + format
|
||||
supportedFormats := map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
}
|
||||
if !supportedFormats[mimeType] {
|
||||
// 允许所有 image/* 格式,但只对常见格式做深入分析
|
||||
}
|
||||
|
||||
// 写入临时文件进行分析
|
||||
tmpFile, err := os.CreateTemp("", "cyrene-image-*."+format)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败", "errorType": "server_error"})
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入临时文件失败", "errorType": "server_error"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.analyzeImage(tmpFile.Name(), mimeType, int64(len(data)))
|
||||
if err != nil {
|
||||
log.Printf("[ImageHandler] 图片分析失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片分析失败: " + err.Error(), "errorType": "analysis_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// analyzeImage 核心分析逻辑:先尝试 OpenAI Vision,失败则降级到本地分析
|
||||
func (h *ImageHandler) analyzeImage(filePath, mimeType string, fileSize int64) (*ImageAnalysis, error) {
|
||||
// 如果配置了 OpenAI API Key,尝试使用 Vision API
|
||||
apiKey := h.cfg.LLMAPIKey
|
||||
if apiKey != "" {
|
||||
result, err := h.analyzeWithOpenAIVision(filePath, mimeType)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
log.Printf("[ImageHandler] OpenAI Vision 分析失败,降级到本地分析: %v", err)
|
||||
}
|
||||
|
||||
// 降级到本地分析
|
||||
return analyzeImageLocally(filePath, mimeType, fileSize)
|
||||
}
|
||||
|
||||
// analyzeWithOpenAIVision 使用 OpenAI Vision API 分析图片
|
||||
func (h *ImageHandler) analyzeWithOpenAIVision(filePath, mimeType string) (*ImageAnalysis, error) {
|
||||
// 读取图片并编码为 base64
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取图片文件失败: %w", err)
|
||||
}
|
||||
|
||||
base64Data := base64.StdEncoding.EncodeToString(data)
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
||||
|
||||
// 获取本地基本信息
|
||||
localInfo, err := analyzeImageLocally(filePath, mimeType, int64(len(data)))
|
||||
if err != nil {
|
||||
localInfo = &ImageAnalysis{}
|
||||
}
|
||||
|
||||
// 构建 OpenAI Vision API 请求
|
||||
reqBody := map[string]interface{}{
|
||||
"model": h.cfg.LLMModel,
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "请详细描述这张图片的内容。用中文回答。请描述:1) 图片中的主要物体/人物 2) 场景/环境 3) 颜色和色调 4) 文字内容(如果有)5) 整体氛围和风格。请尽可能详细。",
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]string{
|
||||
"url": dataURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"max_tokens": 500,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
apiURL := strings.TrimRight(h.cfg.LLMAPIURL, "/") + "/chat/completions"
|
||||
httpReq, err := http.NewRequest("POST", apiURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+h.cfg.LLMAPIKey)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("API 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API 返回错误 (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
var description string
|
||||
if len(result.Choices) > 0 {
|
||||
description = result.Choices[0].Message.Content
|
||||
}
|
||||
|
||||
return &ImageAnalysis{
|
||||
Format: localInfo.Format,
|
||||
Width: localInfo.Width,
|
||||
Height: localInfo.Height,
|
||||
FileSize: localInfo.FileSize,
|
||||
Description: description,
|
||||
TopColors: localInfo.TopColors,
|
||||
EXIF: localInfo.EXIF,
|
||||
AnalyzedBy: "openai_vision",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// analyzeImageLocally 使用 Go 标准库进行本地图片分析
|
||||
func analyzeImageLocally(filePath, mimeType string, fileSize int64) (*ImageAnalysis, error) {
|
||||
// 1. 读取文件
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 解码图片
|
||||
img, format, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解码图片失败: %w", err)
|
||||
}
|
||||
|
||||
// 3. 获取尺寸
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
|
||||
// 4. 计算颜色直方图 (采样像素)
|
||||
topColors := computeColorHistogram(img, 5)
|
||||
|
||||
// 5. 读取 EXIF (简单实现: 仅 JPEG)
|
||||
exif := extractEXIF(data, format)
|
||||
|
||||
// 6. 生成描述文本
|
||||
description := generateLocalDescription(format, width, height, fileSize, topColors)
|
||||
|
||||
return &ImageAnalysis{
|
||||
Format: format,
|
||||
Width: width,
|
||||
Height: height,
|
||||
FileSize: fileSize,
|
||||
Description: description,
|
||||
TopColors: topColors,
|
||||
EXIF: exif,
|
||||
AnalyzedBy: "local",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// computeColorHistogram 计算颜色直方图,返回 top N 颜色
|
||||
func computeColorHistogram(img image.Image, topN int) []ColorInfo {
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
|
||||
// 采样间隔:每 step 个像素采样一个
|
||||
step := 1
|
||||
totalPixels := width * height
|
||||
if totalPixels > 10000 {
|
||||
step = (width * height) / 10000
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
}
|
||||
|
||||
colorCount := make(map[string]int)
|
||||
sampledCount := 0
|
||||
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y += step {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x += step {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
// 量化到 8-bit 并聚类(每 32 级一分组,减少颜色种类)
|
||||
qr := int(r>>8) / 32
|
||||
qg := int(g>>8) / 32
|
||||
qb := int(b>>8) / 32
|
||||
key := fmt.Sprintf("%02d_%02d_%02d", qr, qg, qb)
|
||||
colorCount[key]++
|
||||
sampledCount++
|
||||
}
|
||||
}
|
||||
|
||||
if sampledCount == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 排序取 topN
|
||||
type kv struct {
|
||||
key string
|
||||
count int
|
||||
}
|
||||
var sorted []kv
|
||||
for k, v := range colorCount {
|
||||
sorted = append(sorted, kv{k, v})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].count > sorted[j].count
|
||||
})
|
||||
|
||||
result := make([]ColorInfo, 0, topN)
|
||||
for i := 0; i < topN && i < len(sorted); i++ {
|
||||
var qr, qg, qb int
|
||||
fmt.Sscanf(sorted[i].key, "%d_%d_%d", &qr, &qg, &qb)
|
||||
// 量化组的中间值
|
||||
r := qr*32 + 16
|
||||
g := qg*32 + 16
|
||||
b := qb*32 + 16
|
||||
hex := fmt.Sprintf("#%02X%02X%02X", r, g, b)
|
||||
pct := float64(sorted[i].count) / float64(sampledCount) * 100
|
||||
result = append(result, ColorInfo{
|
||||
Hex: hex,
|
||||
Percent: pct,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractEXIF 简单提取 JPEG EXIF 信息
|
||||
func extractEXIF(data []byte, format string) map[string]string {
|
||||
if format != "jpeg" {
|
||||
return nil
|
||||
}
|
||||
|
||||
exif := make(map[string]string)
|
||||
|
||||
// 查找 EXIF 标记 (0xFFE1)
|
||||
for i := 0; i < len(data)-4; i++ {
|
||||
if data[i] == 0xFF && data[i+1] == 0xE1 {
|
||||
if i+10 >= len(data) {
|
||||
break
|
||||
}
|
||||
// 验证 EXIF 标识 "Exif\0\0"
|
||||
if string(data[i+4:i+10]) != "Exif\x00\x00" {
|
||||
continue
|
||||
}
|
||||
|
||||
exifStart := i + 10
|
||||
if exifStart+8 >= len(data) {
|
||||
break
|
||||
}
|
||||
|
||||
// 判断字节序
|
||||
var bigEndian bool
|
||||
if data[exifStart] == 'M' && data[exifStart+1] == 'M' {
|
||||
bigEndian = true
|
||||
} else if data[exifStart] == 'I' && data[exifStart+1] == 'I' {
|
||||
bigEndian = false
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
// 读取 IFD0
|
||||
tiffStart := exifStart
|
||||
readUint16 := func(offset int) uint16 {
|
||||
if offset+2 > len(data) {
|
||||
return 0
|
||||
}
|
||||
if bigEndian {
|
||||
return uint16(data[offset])<<8 | uint16(data[offset+1])
|
||||
}
|
||||
return uint16(data[offset+1])<<8 | uint16(data[offset])
|
||||
}
|
||||
|
||||
ifd0Offset := int(readUint16(tiffStart + 4))
|
||||
if ifd0Offset < 8 {
|
||||
break
|
||||
}
|
||||
ifd0Addr := tiffStart + ifd0Offset
|
||||
if ifd0Addr+2 >= len(data) {
|
||||
break
|
||||
}
|
||||
|
||||
numEntries := int(readUint16(ifd0Addr))
|
||||
entryAddr := ifd0Addr + 2
|
||||
|
||||
// 常见 EXIF 标签
|
||||
tagNames := map[uint16]string{
|
||||
0x010F: "Make",
|
||||
0x0110: "Model",
|
||||
0x0112: "Orientation",
|
||||
0x0132: "DateTime",
|
||||
0x829A: "ExposureTime",
|
||||
0x829D: "FNumber",
|
||||
0x8827: "ISO",
|
||||
0x9003: "DateTimeOriginal",
|
||||
0x920A: "FocalLength",
|
||||
}
|
||||
|
||||
for j := 0; j < numEntries && entryAddr+12 <= len(data); j++ {
|
||||
tag := readUint16(entryAddr)
|
||||
dataType := readUint16(entryAddr + 2)
|
||||
dataCount := int(readUint16(entryAddr + 4))
|
||||
|
||||
entryAddr += 12
|
||||
|
||||
if name, ok := tagNames[tag]; ok {
|
||||
valueLen := dataCount
|
||||
switch dataType {
|
||||
case 2: // ASCII
|
||||
valueLen = dataCount
|
||||
case 3, 4: // SHORT, LONG
|
||||
valueLen = dataCount * 2
|
||||
case 5: // RATIONAL
|
||||
valueLen = dataCount * 8
|
||||
}
|
||||
|
||||
if valueLen <= 4 {
|
||||
// 值在 tag 自身中
|
||||
valData := data[entryAddr-4 : entryAddr]
|
||||
valStr := extractASCIIValue(valData, dataType, dataCount, bigEndian)
|
||||
if valStr != "" {
|
||||
exif[name] = valStr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break // 只处理第一个 EXIF 块
|
||||
}
|
||||
}
|
||||
|
||||
if len(exif) == 0 {
|
||||
return nil
|
||||
}
|
||||
return exif
|
||||
}
|
||||
|
||||
// extractASCIIValue 从 EXIF 数据中提取 ASCII 值
|
||||
func extractASCIIValue(data []byte, dataType uint16, count int, bigEndian bool) string {
|
||||
switch dataType {
|
||||
case 2: // ASCII string
|
||||
s := string(data)
|
||||
if idx := strings.IndexByte(s, 0); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
return s
|
||||
case 3: // SHORT
|
||||
if len(data) >= 2 {
|
||||
var val uint16
|
||||
if bigEndian {
|
||||
val = uint16(data[0])<<8 | uint16(data[1])
|
||||
} else {
|
||||
val = uint16(data[1])<<8 | uint16(data[0])
|
||||
}
|
||||
return fmt.Sprintf("%d", val)
|
||||
}
|
||||
case 5: // RATIONAL
|
||||
// 简化处理:返回原始字节
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// generateLocalDescription 生成本地图片描述文本
|
||||
func generateLocalDescription(format string, width, height int, fileSize int64, topColors []ColorInfo) string {
|
||||
var sb strings.Builder
|
||||
|
||||
formatNames := map[string]string{
|
||||
"jpeg": "JPEG",
|
||||
"jpg": "JPEG",
|
||||
"png": "PNG",
|
||||
"gif": "GIF",
|
||||
"webp": "WebP",
|
||||
"bmp": "BMP",
|
||||
}
|
||||
|
||||
formatName := strings.ToUpper(format)
|
||||
if name, ok := formatNames[strings.ToLower(format)]; ok {
|
||||
formatName = name
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("这是一张 %s 格式的图片,", formatName))
|
||||
sb.WriteString(fmt.Sprintf("分辨率为 %d×%d 像素,", width, height))
|
||||
sb.WriteString(fmt.Sprintf("文件大小为 %s。", formatFileSize(fileSize)))
|
||||
|
||||
// 判断大致比例
|
||||
ratio := float64(width) / float64(height)
|
||||
if ratio > 1.8 {
|
||||
sb.WriteString("图片呈宽幅横幅比例。")
|
||||
} else if ratio < 0.6 {
|
||||
sb.WriteString("图片呈竖幅比例。")
|
||||
} else if ratio > 1.2 {
|
||||
sb.WriteString("图片接近横向画幅。")
|
||||
} else if ratio < 0.8 {
|
||||
sb.WriteString("图片接近纵向画幅。")
|
||||
} else {
|
||||
sb.WriteString("图片接近正方形比例。")
|
||||
}
|
||||
|
||||
// 描述主要颜色
|
||||
if len(topColors) > 0 {
|
||||
sb.WriteString(" 主要色调为")
|
||||
for i, c := range topColors {
|
||||
if i > 0 {
|
||||
if i == len(topColors)-1 {
|
||||
sb.WriteString(" 和 ")
|
||||
} else {
|
||||
sb.WriteString("、")
|
||||
}
|
||||
}
|
||||
colorName := getColorName(c.Hex)
|
||||
sb.WriteString(fmt.Sprintf("%s(%s, %.0f%%)", colorName, c.Hex, c.Percent))
|
||||
}
|
||||
sb.WriteString("。")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatFileSize 格式化文件大小
|
||||
func formatFileSize(size int64) string {
|
||||
if size < 1024 {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
if size < 1024*1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(size)/1024)
|
||||
}
|
||||
return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024))
|
||||
}
|
||||
|
||||
// getColorName 根据 hex 颜色获取中文颜色名
|
||||
func getColorName(hex string) string {
|
||||
if len(hex) < 7 {
|
||||
return hex
|
||||
}
|
||||
var r, g, b uint8
|
||||
fmt.Sscanf(hex, "#%02X%02X%02X", &r, &g, &b)
|
||||
|
||||
// 灰度判断
|
||||
if absDiff(r, g) < 20 && absDiff(g, b) < 20 && absDiff(r, b) < 20 {
|
||||
if r < 40 {
|
||||
return "黑色"
|
||||
}
|
||||
if r < 100 {
|
||||
return "深灰色"
|
||||
}
|
||||
if r < 180 {
|
||||
return "灰色"
|
||||
}
|
||||
if r < 230 {
|
||||
return "浅灰色"
|
||||
}
|
||||
return "白色"
|
||||
}
|
||||
|
||||
// HSL 近似判断色调
|
||||
maxC := max(r, max(g, b))
|
||||
minC := min(r, min(g, b))
|
||||
delta := maxC - minC
|
||||
|
||||
if delta < 30 {
|
||||
if maxC < 60 {
|
||||
return "暗色"
|
||||
}
|
||||
if maxC > 200 {
|
||||
return "浅色"
|
||||
}
|
||||
return "中性色"
|
||||
}
|
||||
|
||||
var hue string
|
||||
switch {
|
||||
case r == maxC:
|
||||
if g >= b {
|
||||
hue = "红色"
|
||||
} else {
|
||||
hue = "品红色"
|
||||
}
|
||||
case g == maxC:
|
||||
if b >= r {
|
||||
hue = "绿色"
|
||||
} else {
|
||||
hue = "黄绿色"
|
||||
}
|
||||
default:
|
||||
if r >= g {
|
||||
hue = "紫红色"
|
||||
} else {
|
||||
hue = "蓝色"
|
||||
}
|
||||
}
|
||||
|
||||
// 亮度修饰
|
||||
if maxC < 80 {
|
||||
hue = "深" + hue
|
||||
} else if minC > 200 {
|
||||
hue = "浅" + hue
|
||||
}
|
||||
|
||||
return hue
|
||||
}
|
||||
|
||||
func absDiff(a, b uint8) int {
|
||||
if a > b {
|
||||
return int(a - b)
|
||||
}
|
||||
return int(b - a)
|
||||
}
|
||||
|
||||
func max(a, b uint8) uint8 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b uint8) uint8 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ========== color.RGBA → string 辅助 ==========
|
||||
|
||||
var _ = color.RGBA{} // 确保 color 包被使用
|
||||
@@ -0,0 +1,589 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
)
|
||||
|
||||
// KnowledgeHandler 知识库处理器
|
||||
type KnowledgeHandler struct {
|
||||
store *store.KnowledgeStore
|
||||
fileStore *store.FileStore
|
||||
}
|
||||
|
||||
// NewKnowledgeHandler 创建知识库处理器
|
||||
func NewKnowledgeHandler(s *store.KnowledgeStore, fs *store.FileStore) *KnowledgeHandler {
|
||||
return &KnowledgeHandler{store: s, fileStore: fs}
|
||||
}
|
||||
|
||||
// checkStore 检查知识库存储是否可用,不可用时返回 true(调用方应 return)
|
||||
func (h *KnowledgeHandler) checkStore(c *gin.Context) bool {
|
||||
if h.store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "知识库服务不可用(数据库未连接)",
|
||||
"errorType": "service_unavailable",
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ========== 请求/响应类型 ==========
|
||||
|
||||
type createKBRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type updateKBRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type addDocRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
SourceType string `json:"source_type"`
|
||||
FileID string `json:"file_id"`
|
||||
}
|
||||
|
||||
type searchRequest struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
KBIDs []string `json:"kb_ids"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// ========== POST /api/v1/knowledge/bases — 创建知识库 ==========
|
||||
|
||||
func (h *KnowledgeHandler) CreateKB(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req createKBRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供知识库名称", "errorType": "invalid_request"})
|
||||
return
|
||||
}
|
||||
|
||||
kb := &store.KnowledgeBase{
|
||||
ID: store.GenerateUUID(),
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
}
|
||||
|
||||
if err := h.store.CreateKB(kb); err != nil {
|
||||
log.Printf("[KnowledgeHandler] 创建知识库失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建知识库失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, kb)
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/knowledge/bases — 列出用户的知识库 ==========
|
||||
|
||||
func (h *KnowledgeHandler) ListKBs(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
kbs, err := h.store.GetKBsByUser(userID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询知识库列表失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库列表失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"knowledge_bases": kbs, "total": len(kbs)})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/knowledge/bases/:id — 获取知识库详情 ==========
|
||||
|
||||
func (h *KnowledgeHandler) GetKB(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
kbID := c.Param("id")
|
||||
|
||||
kb, err := h.store.GetKB(kbID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if kb == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "知识库不存在", "errorType": "not_found"})
|
||||
return
|
||||
}
|
||||
if kb.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此知识库", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文档列表
|
||||
docs, err := h.store.GetDocumentsByKB(kbID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询文档列表失败: %v", err)
|
||||
docs = []store.KnowledgeDocument{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"knowledge_base": kb,
|
||||
"documents": docs,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== PUT /api/v1/knowledge/bases/:id — 更新知识库 ==========
|
||||
|
||||
func (h *KnowledgeHandler) UpdateKB(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
kbID := c.Param("id")
|
||||
|
||||
var req updateKBRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供知识库名称", "errorType": "invalid_request"})
|
||||
return
|
||||
}
|
||||
|
||||
kb, err := h.store.GetKB(kbID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if kb == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "知识库不存在", "errorType": "not_found"})
|
||||
return
|
||||
}
|
||||
if kb.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权修改此知识库", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateKB(kbID, req.Name, req.Description); err != nil {
|
||||
log.Printf("[KnowledgeHandler] 更新知识库失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新知识库失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
// ========== DELETE /api/v1/knowledge/bases/:id — 删除知识库 ==========
|
||||
|
||||
func (h *KnowledgeHandler) DeleteKB(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
kbID := c.Param("id")
|
||||
|
||||
kb, err := h.store.GetKB(kbID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if kb == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "知识库不存在", "errorType": "not_found"})
|
||||
return
|
||||
}
|
||||
if kb.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此知识库", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteKB(kbID); err != nil {
|
||||
log.Printf("[KnowledgeHandler] 删除知识库失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除知识库失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ========== POST /api/v1/knowledge/bases/:id/documents — 添加文档 ==========
|
||||
|
||||
func (h *KnowledgeHandler) AddDocument(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
kbID := c.Param("id")
|
||||
|
||||
// 检查知识库是否存在且属于当前用户
|
||||
kb, err := h.store.GetKB(kbID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if kb == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "知识库不存在", "errorType": "not_found"})
|
||||
return
|
||||
}
|
||||
if kb.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权操作此知识库", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var req addDocRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供文档标题", "errorType": "invalid_request"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.SourceType == "" {
|
||||
req.SourceType = "text"
|
||||
}
|
||||
|
||||
var content string
|
||||
var sourceRef string
|
||||
var contentType string
|
||||
|
||||
switch req.SourceType {
|
||||
case "text":
|
||||
content = req.Content
|
||||
contentType = "text/plain"
|
||||
if content == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供文档内容", "errorType": "invalid_request"})
|
||||
return
|
||||
}
|
||||
case "file":
|
||||
if req.FileID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供文件ID", "errorType": "invalid_request"})
|
||||
return
|
||||
}
|
||||
sourceRef = req.FileID
|
||||
|
||||
// 从 FileStore 读取文件内容
|
||||
if h.fileStore != nil {
|
||||
f, err := h.fileStore.GetFile(req.FileID)
|
||||
if err != nil || f == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在", "errorType": "not_found"})
|
||||
return
|
||||
}
|
||||
if f.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文件", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
// 注意:这里只支持文本类型文件的读取
|
||||
// 对于二进制文件,需要更复杂的解析逻辑
|
||||
sourceRef = f.Filename
|
||||
// 从磁盘读取文件内容
|
||||
content, contentType, _ = readFileContent(f.StoredPath, f.MimeType)
|
||||
if content == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件内容,仅支持文本文件", "errorType": "unsupported_file"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "文件存储不可用", "errorType": "service_unavailable"})
|
||||
return
|
||||
}
|
||||
case "url":
|
||||
sourceRef = req.FileID
|
||||
content = req.Content
|
||||
contentType = "text/html"
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的来源类型: " + req.SourceType, "errorType": "invalid_request"})
|
||||
return
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
content = req.Content
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
doc := &store.KnowledgeDocument{
|
||||
ID: store.GenerateUUID(),
|
||||
KBID: kbID,
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
SourceType: req.SourceType,
|
||||
SourceRef: sourceRef,
|
||||
ContentType: contentType,
|
||||
RawContent: content,
|
||||
}
|
||||
|
||||
if err := h.store.AddDocument(doc); err != nil {
|
||||
log.Printf("[KnowledgeHandler] 添加文档失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "添加文档失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 自动分块
|
||||
chunkCount, err := h.store.ChunkDocument(doc.ID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 文档分块失败: %v", err)
|
||||
// 分块失败不影响文档创建
|
||||
}
|
||||
|
||||
doc.ChunkCount = chunkCount
|
||||
|
||||
c.JSON(http.StatusCreated, doc)
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/knowledge/bases/:id/documents — 列出知识库中的文档 ==========
|
||||
|
||||
func (h *KnowledgeHandler) ListDocuments(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
kbID := c.Param("id")
|
||||
|
||||
// 检查权限
|
||||
kb, err := h.store.GetKB(kbID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询知识库失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询知识库失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if kb == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "知识库不存在", "errorType": "not_found"})
|
||||
return
|
||||
}
|
||||
if kb.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此知识库", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
docs, err := h.store.GetDocumentsByKB(kbID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询文档列表失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文档列表失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"documents": docs, "total": len(docs)})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/knowledge/documents/:id — 获取文档详情 ==========
|
||||
|
||||
func (h *KnowledgeHandler) GetDocument(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
docID := c.Param("id")
|
||||
|
||||
doc, err := h.store.GetDocument(docID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询文档失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文档失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if doc == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文档不存在", "errorType": "not_found"})
|
||||
return
|
||||
}
|
||||
if doc.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问此文档", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取分块
|
||||
chunks, err := h.store.GetChunksByDocID(docID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询分块失败: %v", err)
|
||||
chunks = []store.KnowledgeChunk{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"document": doc,
|
||||
"chunks": chunks,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== DELETE /api/v1/knowledge/documents/:id — 删除文档 ==========
|
||||
|
||||
func (h *KnowledgeHandler) DeleteDocument(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
docID := c.Param("id")
|
||||
|
||||
doc, err := h.store.GetDocument(docID)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 查询文档失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询文档失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if doc == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文档不存在", "errorType": "not_found"})
|
||||
return
|
||||
}
|
||||
if doc.UserID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除此文档", "errorType": "access_denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteDocument(docID); err != nil {
|
||||
log.Printf("[KnowledgeHandler] 删除文档失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文档失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ========== POST /api/v1/knowledge/search — 搜索知识库 ==========
|
||||
|
||||
func (h *KnowledgeHandler) Search(c *gin.Context) {
|
||||
if h.checkStore(c) {
|
||||
return
|
||||
}
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req searchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供搜索关键词", "errorType": "invalid_request"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 5
|
||||
}
|
||||
if req.Limit > 50 {
|
||||
req.Limit = 50
|
||||
}
|
||||
|
||||
var results []store.SearchChunkResult
|
||||
var err error
|
||||
|
||||
if len(req.KBIDs) == 0 {
|
||||
// 搜索所有知识库
|
||||
results, err = h.store.SearchAllKBs(userID, req.Query, req.Limit)
|
||||
} else {
|
||||
// 搜索指定知识库,需要验证权限
|
||||
results = []store.SearchChunkResult{}
|
||||
for _, kbID := range req.KBIDs {
|
||||
kb, checkErr := h.store.GetKB(kbID)
|
||||
if checkErr != nil || kb == nil || kb.UserID != userID {
|
||||
continue // 跳过无权限或不存在的知识库
|
||||
}
|
||||
kbResults, searchErr := h.store.SearchChunks(kbID, req.Query, req.Limit)
|
||||
if searchErr != nil {
|
||||
log.Printf("[KnowledgeHandler] 搜索知识库 %s 失败: %v", kbID, searchErr)
|
||||
continue
|
||||
}
|
||||
results = append(results, kbResults...)
|
||||
}
|
||||
|
||||
// 限制总结果数
|
||||
if len(results) > req.Limit {
|
||||
results = results[:req.Limit]
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeHandler] 搜索失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成高亮片段
|
||||
for i := range results {
|
||||
results[i].Headline = generateHeadline(results[i].Content, req.Query, 200)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"chunks": results,
|
||||
"total": len(results),
|
||||
"query": req.Query,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
// generateHeadline 生成高亮片段,提取查询关键词周围的文本
|
||||
func generateHeadline(content, query string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
maxLen = 200
|
||||
}
|
||||
|
||||
runes := []rune(content)
|
||||
if len(runes) <= maxLen {
|
||||
return content
|
||||
}
|
||||
|
||||
// 查找查询关键词位置
|
||||
queryRunes := []rune(query)
|
||||
pos := -1
|
||||
for i := 0; i <= len(runes)-len(queryRunes); i++ {
|
||||
match := true
|
||||
for j := 0; j < len(queryRunes); j++ {
|
||||
if runes[i+j] != queryRunes[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
pos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if pos < 0 {
|
||||
// 没有找到精确匹配,返回前 maxLen 个字符
|
||||
return string(runes[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
// 以匹配位置为中心,截取上下文
|
||||
half := maxLen / 2
|
||||
start := pos - half
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + maxLen
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
start = end - maxLen
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
|
||||
result := string(runes[start:end])
|
||||
if start > 0 {
|
||||
result = "..." + result
|
||||
}
|
||||
if end < len(runes) {
|
||||
result = result + "..."
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// readFileContent 从磁盘读取文件内容 (仅支持文本类型)
|
||||
func readFileContent(path, mimeType string) (content string, contentType string, err error) {
|
||||
// 只支持文本类型
|
||||
if !strings.HasPrefix(mimeType, "text/") && mimeType != "application/json" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return string(data), mimeType, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||
)
|
||||
|
||||
// NotificationHandler 通知推送处理器
|
||||
type NotificationHandler struct {
|
||||
cfg *config.Config
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// NewNotificationHandler 创建通知处理器
|
||||
func NewNotificationHandler(cfg *config.Config, hub *ws.Hub) *NotificationHandler {
|
||||
return &NotificationHandler{cfg: cfg, hub: hub}
|
||||
}
|
||||
|
||||
// PushNotificationRequest 推送通知请求体
|
||||
type PushNotificationRequest struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=info warning success thinking reminder"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Body string `json:"body" binding:"required"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Push 推送通知到指定用户 (需要 JWT 认证)
|
||||
// POST /api/v1/notifications/push
|
||||
func (h *NotificationHandler) Push(c *gin.Context) {
|
||||
var req PushNotificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成通知
|
||||
notif := h.buildNotification(req)
|
||||
|
||||
// 序列化 WS 消息
|
||||
msg := ws.ServerMessage{
|
||||
Type: "notification",
|
||||
MessageID: "notif_" + generateID(),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Notification: notif,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[notification] 序列化通知失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 通过 Hub 推送给指定用户
|
||||
h.hub.SendToUser(req.UserID, data)
|
||||
|
||||
log.Printf("[notification] 通知已推送: user=%s type=%s title=%s", req.UserID, req.Type, req.Title)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"notification": gin.H{
|
||||
"id": notif.ID,
|
||||
"type": notif.Type,
|
||||
"title": notif.Title,
|
||||
"user_id": req.UserID,
|
||||
"timestamp": notif.Timestamp,
|
||||
"delivered": h.hub.UserClientCount(req.UserID) > 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// InternalNotify 内部服务推送通知 (使用内部 service token)
|
||||
// POST /api/v1/internal/notify
|
||||
func (h *NotificationHandler) InternalNotify(c *gin.Context) {
|
||||
var req PushNotificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成通知
|
||||
notif := h.buildNotification(req)
|
||||
|
||||
// 序列化 WS 消息
|
||||
msg := ws.ServerMessage{
|
||||
Type: "notification",
|
||||
MessageID: "notif_" + generateID(),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Notification: notif,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[notification] 序列化通知失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 通过 Hub 推送给指定用户
|
||||
h.hub.SendToUser(req.UserID, data)
|
||||
|
||||
log.Printf("[notification] 内部通知已推送: user=%s type=%s title=%s", req.UserID, req.Type, req.Title)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"notification": gin.H{
|
||||
"id": notif.ID,
|
||||
"type": notif.Type,
|
||||
"title": notif.Title,
|
||||
"user_id": req.UserID,
|
||||
"timestamp": notif.Timestamp,
|
||||
"delivered": h.hub.UserClientCount(req.UserID) > 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// InternalNotifyAuth 内部服务认证中间件
|
||||
func (h *NotificationHandler) InternalNotifyAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Internal-Token")
|
||||
if token == "" {
|
||||
token = c.GetHeader("Authorization")
|
||||
if len(token) > 7 && token[:7] == "Bearer " {
|
||||
token = token[7:]
|
||||
}
|
||||
}
|
||||
|
||||
if token != h.cfg.InternalServiceToken {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "内部认证失败"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// buildNotification 构建 NotificationInfo
|
||||
func (h *NotificationHandler) buildNotification(req PushNotificationRequest) *ws.NotificationInfo {
|
||||
notifID := "notif_" + generateID()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if req.Data == nil {
|
||||
req.Data = make(map[string]interface{})
|
||||
}
|
||||
|
||||
return &ws.NotificationInfo{
|
||||
ID: notifID,
|
||||
Type: req.Type,
|
||||
Title: req.Title,
|
||||
Body: req.Body,
|
||||
Timestamp: now,
|
||||
Data: req.Data,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||
)
|
||||
|
||||
// ReminderHandler 提醒处理器
|
||||
type ReminderHandler struct {
|
||||
store *store.ReminderStore
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// NewReminderHandler 创建提醒处理器
|
||||
func NewReminderHandler(s *store.ReminderStore, hub *ws.Hub) *ReminderHandler {
|
||||
return &ReminderHandler{store: s, hub: hub}
|
||||
}
|
||||
|
||||
// CreateReminderRequest 创建提醒请求体
|
||||
type CreateReminderRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
RemindAt string `json:"remind_at" binding:"required"` // ISO 8601 格式
|
||||
RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// UpdateReminderRequest 更新提醒请求体
|
||||
type UpdateReminderRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RemindAt string `json:"remind_at"`
|
||||
Status string `json:"status"` // pending, completed, cancelled
|
||||
RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// List 获取提醒列表
|
||||
// GET /api/v1/reminders?user_id=xxx&status=pending&limit=50&offset=0
|
||||
func (h *ReminderHandler) List(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 user_id 参数"})
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if l, ok := c.GetQuery("limit"); ok {
|
||||
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
if o, ok := c.GetQuery("offset"); ok {
|
||||
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
||||
offset = v
|
||||
}
|
||||
}
|
||||
|
||||
reminders, err := h.store.GetRemindersByUser(userID, status, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("[reminder] 获取提醒列表失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取提醒列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"reminders": reminders,
|
||||
"count": len(reminders),
|
||||
})
|
||||
}
|
||||
|
||||
// Create 创建新提醒
|
||||
// POST /api/v1/reminders
|
||||
func (h *ReminderHandler) Create(c *gin.Context) {
|
||||
var req CreateReminderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 从 JWT 获取 userID
|
||||
userID := middleware.GetUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析时间
|
||||
remindAt, err := time.Parse(time.RFC3339, req.RemindAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "时间格式无效,请使用 ISO 8601 格式 (例如 2024-01-01T15:00:00Z)"})
|
||||
return
|
||||
}
|
||||
|
||||
// 默认值
|
||||
repeatType := req.RepeatType
|
||||
if repeatType == "" {
|
||||
repeatType = "none"
|
||||
}
|
||||
|
||||
reminder := &store.Reminder{
|
||||
ID: generateID(),
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
RemindAt: remindAt,
|
||||
Status: "pending",
|
||||
RepeatType: repeatType,
|
||||
SessionID: req.SessionID,
|
||||
Notified: false,
|
||||
}
|
||||
|
||||
if err := h.store.CreateReminder(reminder); err != nil {
|
||||
log.Printf("[reminder] 创建提醒失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建提醒失败"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[reminder] 提醒已创建: id=%s user=%s title=%s remind_at=%s repeat=%s",
|
||||
reminder.ID, userID, reminder.Title, remindAt.Format(time.RFC3339), repeatType)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"success": true,
|
||||
"reminder": reminder,
|
||||
})
|
||||
}
|
||||
|
||||
// Update 更新提醒
|
||||
// PUT /api/v1/reminders/:id
|
||||
func (h *ReminderHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少提醒 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req UpdateReminderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 先获取已有提醒
|
||||
reminders, err := h.store.GetRemindersByUser(userID, "", 100, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取提醒失败"})
|
||||
return
|
||||
}
|
||||
|
||||
var existing *store.Reminder
|
||||
for i := range reminders {
|
||||
if reminders[i].ID == id {
|
||||
existing = &reminders[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "提醒不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.Title != "" {
|
||||
existing.Title = req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
existing.Description = req.Description
|
||||
}
|
||||
if req.RemindAt != "" {
|
||||
remindAt, err := time.Parse(time.RFC3339, req.RemindAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "时间格式无效"})
|
||||
return
|
||||
}
|
||||
existing.RemindAt = remindAt
|
||||
}
|
||||
if req.Status != "" {
|
||||
existing.Status = req.Status
|
||||
if req.Status == "completed" || req.Status == "cancelled" {
|
||||
now := time.Now()
|
||||
existing.CompletedAt = &now
|
||||
}
|
||||
}
|
||||
if req.RepeatType != "" {
|
||||
existing.RepeatType = req.RepeatType
|
||||
}
|
||||
if req.SessionID != "" {
|
||||
existing.SessionID = req.SessionID
|
||||
}
|
||||
|
||||
if err := h.store.UpdateReminder(id, existing); err != nil {
|
||||
log.Printf("[reminder] 更新提醒失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新提醒失败"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[reminder] 提醒已更新: id=%s", id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"reminder": existing,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete 删除提醒
|
||||
// DELETE /api/v1/reminders/:id
|
||||
func (h *ReminderHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少提醒 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteReminder(id); err != nil {
|
||||
log.Printf("[reminder] 删除提醒失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除提醒失败"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[reminder] 提醒已删除: id=%s", id)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 提醒调度器 ==========
|
||||
|
||||
// StartReminderScheduler 启动提醒调度器,每 30 秒检查一次到期提醒
|
||||
func StartReminderScheduler(s *store.ReminderStore, hub *ws.Hub) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Println("[ReminderScheduler] 提醒调度器已启动 (检查间隔: 30秒)")
|
||||
|
||||
for range ticker.C {
|
||||
checkAndNotify(s, hub)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// checkAndNotify 检查到期提醒并推送通知
|
||||
func checkAndNotify(s *store.ReminderStore, hub *ws.Hub) {
|
||||
reminders, err := s.GetDueReminders()
|
||||
if err != nil {
|
||||
log.Printf("[ReminderScheduler] 获取到期提醒失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(reminders) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range reminders {
|
||||
// 1. 构建 WebSocket 通知消息
|
||||
notif := &ws.NotificationInfo{
|
||||
ID: "reminder_" + r.ID,
|
||||
Type: "reminder",
|
||||
Title: r.Title,
|
||||
Body: r.Description,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Data: map[string]interface{}{
|
||||
"reminder_id": r.ID,
|
||||
"session_id": r.SessionID,
|
||||
},
|
||||
}
|
||||
|
||||
msg := ws.ServerMessage{
|
||||
Type: "notification",
|
||||
MessageID: "reminder_" + r.ID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Notification: notif,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
log.Printf("[ReminderScheduler] 序列化通知失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. 通过 Hub 向用户推送
|
||||
hub.SendToUser(r.UserID, data)
|
||||
|
||||
// 3. 标记为已通知
|
||||
if err := s.MarkNotified(r.ID); err != nil {
|
||||
log.Printf("[ReminderScheduler] 标记已通知失败: id=%s err=%v", r.ID, err)
|
||||
}
|
||||
|
||||
// 4. 处理重复提醒
|
||||
if r.RepeatType != "" && r.RepeatType != "none" {
|
||||
nextTime := calculateNextRemindAt(r.RemindAt, r.RepeatType)
|
||||
r.RemindAt = nextTime
|
||||
r.Notified = false
|
||||
if err := s.UpdateReminder(r.ID, &r); err != nil {
|
||||
log.Printf("[ReminderScheduler] 更新重复提醒失败: id=%s err=%v", r.ID, err)
|
||||
} else {
|
||||
log.Printf("[ReminderScheduler] 重复提醒已更新: id=%s next=%s", r.ID, nextTime.Format(time.RFC3339))
|
||||
}
|
||||
} else {
|
||||
// 非重复提醒:标记为已完成
|
||||
now := time.Now()
|
||||
r.Status = "completed"
|
||||
r.CompletedAt = &now
|
||||
if err := s.UpdateReminder(r.ID, &r); err != nil {
|
||||
log.Printf("[ReminderScheduler] 标记提醒完成失败: id=%s err=%v", r.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[ReminderScheduler] 提醒已推送: user=%s title=%s id=%s", r.UserID, r.Title, r.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// calculateNextRemindAt 计算下一次提醒时间
|
||||
func calculateNextRemindAt(current time.Time, repeatType string) time.Time {
|
||||
switch repeatType {
|
||||
case "daily":
|
||||
return current.Add(24 * time.Hour)
|
||||
case "weekly":
|
||||
return current.Add(7 * 24 * time.Hour)
|
||||
case "monthly":
|
||||
return current.AddDate(0, 1, 0)
|
||||
default:
|
||||
return current
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -305,6 +308,270 @@ func (h *SessionHandler) GetSession(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, session)
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/messages/search?q=xxx&user_id=xxx&limit=50&offset=0 — 全文搜索消息 ==========
|
||||
|
||||
// SearchMessages 全文搜索消息
|
||||
func (h *SessionHandler) SearchMessages(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少搜索关键词参数 q", "errorType": "missing_query"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
userID = middleware.GetUserID(c)
|
||||
}
|
||||
|
||||
limit := 50
|
||||
offset := 0
|
||||
if l := c.Query("limit"); l != "" {
|
||||
parsed := 0
|
||||
for _, ch := range l {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
parsed = parsed*10 + int(ch-'0')
|
||||
}
|
||||
if parsed > 0 && parsed <= 200 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if o := c.Query("offset"); o != "" {
|
||||
parsed := 0
|
||||
for _, ch := range o {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
parsed = parsed*10 + int(ch-'0')
|
||||
}
|
||||
if parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if !h.useDB {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": []gin.H{},
|
||||
"total": 0,
|
||||
"query": query,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
results, total, err := h.store.SearchMessages(userID, query, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 搜索消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为统一格式
|
||||
items := make([]gin.H, 0, len(results))
|
||||
for _, r := range results {
|
||||
items = append(items, gin.H{
|
||||
"message_id": r.MessageID,
|
||||
"session_id": r.SessionID,
|
||||
"session_title": r.SessionTitle,
|
||||
"role": r.Role,
|
||||
"content": r.Content,
|
||||
"created_at": r.CreatedAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"results": items,
|
||||
"total": total,
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== GET /api/v1/sessions/:id/export?format=json|markdown|txt — 导出会话 ==========
|
||||
|
||||
// ExportSession 导出会话为指定格式
|
||||
func (h *SessionHandler) ExportSession(c *gin.Context) {
|
||||
sessionID := c.Param("id")
|
||||
format := c.Query("format")
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
// 验证格式
|
||||
switch format {
|
||||
case "json", "markdown", "txt":
|
||||
// valid
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "不支持的导出格式",
|
||||
"errorType": "invalid_format",
|
||||
"hint": "支持的格式: json, markdown, txt",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.useDB {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "会话存储不可用",
|
||||
"errorType": "store_unavailable",
|
||||
"hint": "数据库连接未建立,无法导出会话",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取会话信息
|
||||
session, err := h.store.GetSession(sessionID)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询会话失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询会话失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "会话不存在",
|
||||
"errorType": "session_not_found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取所有消息 (不限制数量,导出全部)
|
||||
messages, err := h.store.GetMessages(sessionID, 0)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
|
||||
return
|
||||
}
|
||||
if messages == nil {
|
||||
messages = []store.Message{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
h.exportJSON(c, session, messages, now)
|
||||
case "markdown":
|
||||
h.exportMarkdown(c, session, messages, now)
|
||||
case "txt":
|
||||
h.exportTXT(c, session, messages, now)
|
||||
}
|
||||
}
|
||||
|
||||
// exportJSON 导出 JSON 格式
|
||||
func (h *SessionHandler) exportJSON(c *gin.Context, session *store.Session, messages []store.Message, now time.Time) {
|
||||
type msgOut struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type sessionOut struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type export struct {
|
||||
Session sessionOut `json:"session"`
|
||||
Messages []msgOut `json:"messages"`
|
||||
}
|
||||
|
||||
msgs := make([]msgOut, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
msgs = append(msgs, msgOut{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
CreatedAt: m.CreatedAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
data := export{
|
||||
Session: sessionOut{
|
||||
ID: session.ID,
|
||||
Title: session.Title,
|
||||
CreatedAt: session.CreatedAt.UnixMilli(),
|
||||
UpdatedAt: session.UpdatedAt.UnixMilli(),
|
||||
},
|
||||
Messages: msgs,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] JSON序列化失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败", "errorType": "serialization_error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="session_%s.json"`, session.ID))
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", jsonBytes)
|
||||
}
|
||||
|
||||
// exportMarkdown 导出 Markdown 格式
|
||||
func (h *SessionHandler) exportMarkdown(c *gin.Context, session *store.Session, messages []store.Message, now time.Time) {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("# 对话导出: %s\n", session.Title))
|
||||
sb.WriteString(fmt.Sprintf("**会话 ID**: %s\n", session.ID))
|
||||
sb.WriteString(fmt.Sprintf("**导出时间**: %s\n", now.Format("2006-01-02 15:04:05")))
|
||||
sb.WriteString(fmt.Sprintf("**消息数量**: %d\n", len(messages)))
|
||||
sb.WriteString("\n---\n\n")
|
||||
|
||||
for _, m := range messages {
|
||||
timeStr := m.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
switch m.Role {
|
||||
case "user":
|
||||
sb.WriteString(fmt.Sprintf("### 👤 用户 (%s)\n\n", timeStr))
|
||||
case "assistant":
|
||||
sb.WriteString(fmt.Sprintf("### 🤖 昔涟 (%s)\n\n", timeStr))
|
||||
case "system":
|
||||
sb.WriteString(fmt.Sprintf("### ⚙️ 系统 (%s)\n\n", timeStr))
|
||||
default:
|
||||
sb.WriteString(fmt.Sprintf("### %s (%s)\n\n", m.Role, timeStr))
|
||||
}
|
||||
sb.WriteString(m.Content)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
content := sb.String()
|
||||
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="session_%s.md"`, session.ID))
|
||||
c.Data(http.StatusOK, "text/markdown; charset=utf-8", []byte(content))
|
||||
}
|
||||
|
||||
// exportTXT 导出纯文本格式
|
||||
func (h *SessionHandler) exportTXT(c *gin.Context, session *store.Session, messages []store.Message, now time.Time) {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("对话导出: %s\n", session.Title))
|
||||
sb.WriteString(fmt.Sprintf("会话 ID: %s\n", session.ID))
|
||||
sb.WriteString(fmt.Sprintf("导出时间: %s\n", now.Format("2006-01-02 15:04:05")))
|
||||
sb.WriteString(fmt.Sprintf("消息数量: %d\n", len(messages)))
|
||||
sb.WriteString(strings.Repeat("=", 50))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
for _, m := range messages {
|
||||
timeStr := m.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
roleLabel := m.Role
|
||||
switch m.Role {
|
||||
case "user":
|
||||
roleLabel = "用户"
|
||||
case "assistant":
|
||||
roleLabel = "昔涟"
|
||||
case "system":
|
||||
roleLabel = "系统"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%s] %s:\n%s\n\n", timeStr, roleLabel, m.Content))
|
||||
}
|
||||
|
||||
content := sb.String()
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="session_%s.txt"`, session.ID))
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(content))
|
||||
}
|
||||
|
||||
// 简单的工具函数
|
||||
func randomID(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VoiceHandler 语音处理器 — 代理到 voice-service
|
||||
type VoiceHandler struct {
|
||||
voiceServiceURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewVoiceHandler 创建语音处理器
|
||||
func NewVoiceHandler(voiceServiceURL string) *VoiceHandler {
|
||||
return &VoiceHandler{
|
||||
voiceServiceURL: voiceServiceURL,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
// Transcribe POST /api/v1/voice/transcribe
|
||||
// 代理 multipart/form-data 请求到 voice-service
|
||||
func (h *VoiceHandler) Transcribe(c *gin.Context) {
|
||||
// 限制上传大小 (10MB)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 10<<20)
|
||||
|
||||
// 读取原始请求体
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败或文件过大,最大支持 10MB"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建代理请求
|
||||
url := strings.TrimRight(h.voiceServiceURL, "/") + "/api/v1/transcribe"
|
||||
|
||||
proxyReq, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "构建代理请求失败"})
|
||||
return
|
||||
}
|
||||
proxyReq.Header.Set("Content-Type", c.GetHeader("Content-Type"))
|
||||
|
||||
resp, err := h.client.Do(proxyReq)
|
||||
if err != nil {
|
||||
log.Printf("[voice] Voice-Service 不可达 (Transcribe): %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": "Voice-Service 不可达: " + err.Error(),
|
||||
"errorType": "voice_service_unreachable",
|
||||
"hint": "Voice-Service 服务未启动或不可达,请先在「服务管理」面板中启动 Voice-Service",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 透传状态码和响应
|
||||
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody)
|
||||
}
|
||||
|
||||
// TTSSynthesize POST /api/v1/voice/tts
|
||||
// 代理 JSON 请求到 voice-service TTS 合成
|
||||
func (h *VoiceHandler) TTSSynthesize(c *gin.Context) {
|
||||
// 读取 JSON 请求体
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建代理请求
|
||||
url := strings.TrimRight(h.voiceServiceURL, "/") + "/api/v1/tts/synthesize"
|
||||
|
||||
proxyReq, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "构建代理请求失败"})
|
||||
return
|
||||
}
|
||||
proxyReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := h.client.Do(proxyReq)
|
||||
if err != nil {
|
||||
log.Printf("[voice] Voice-Service 不可达 (TTS): %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": "Voice-Service 不可达: " + err.Error(),
|
||||
"errorType": "voice_service_unreachable",
|
||||
"hint": "Voice-Service 服务未启动或不可达",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取 Voice-Service 响应失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 透传状态码、Content-Type 和响应体
|
||||
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody)
|
||||
}
|
||||
|
||||
// TTSVoices GET /api/v1/voice/tts/voices
|
||||
// 代理请求到 voice-service 获取可用语音列表
|
||||
func (h *VoiceHandler) TTSVoices(c *gin.Context) {
|
||||
url := strings.TrimRight(h.voiceServiceURL, "/") + "/api/v1/tts/voices"
|
||||
|
||||
resp, err := h.client.Get(url)
|
||||
if err != nil {
|
||||
log.Printf("[voice] Voice-Service 不可达 (Voices): %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": "Voice-Service 不可达: " + err.Error(),
|
||||
"errorType": "voice_service_unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 解析并透传
|
||||
var data interface{}
|
||||
json.Unmarshal(respBody, &data)
|
||||
c.JSON(resp.StatusCode, data)
|
||||
}
|
||||
|
||||
// TTSStatus GET /api/v1/voice/tts/status
|
||||
// 代理请求到 voice-service 获取 TTS 状态
|
||||
func (h *VoiceHandler) TTSStatus(c *gin.Context) {
|
||||
url := strings.TrimRight(h.voiceServiceURL, "/") + "/api/v1/tts/status"
|
||||
|
||||
resp, err := h.client.Get(url)
|
||||
if err != nil {
|
||||
log.Printf("[voice] Voice-Service 不可达 (TTS Status): %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": "Voice-Service 不可达: " + err.Error(),
|
||||
"errorType": "voice_service_unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var data interface{}
|
||||
json.Unmarshal(respBody, &data)
|
||||
c.JSON(resp.StatusCode, data)
|
||||
}
|
||||
|
||||
// VoiceStatus GET /api/v1/voice/status
|
||||
// 代理请求到 voice-service 获取完整状态(STT + TTS)
|
||||
func (h *VoiceHandler) VoiceStatus(c *gin.Context) {
|
||||
url := strings.TrimRight(h.voiceServiceURL, "/") + "/api/v1/status"
|
||||
|
||||
resp, err := h.client.Get(url)
|
||||
if err != nil {
|
||||
log.Printf("[voice] Voice-Service 不可达 (Status): %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": "Voice-Service 不可达: " + err.Error(),
|
||||
"errorType": "voice_service_unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var data interface{}
|
||||
json.Unmarshal(respBody, &data)
|
||||
c.JSON(resp.StatusCode, data)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/engine"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/handler"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/middleware"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
// Setup 注册所有路由
|
||||
func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.SessionStore) {
|
||||
func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.SessionStore, reminderStore *store.ReminderStore, briefingStore *store.BriefingStore, automationStore *store.AutomationStore, fileStore *store.FileStore, ruleEngine *engine.RuleEngine, knowledgeStore *store.KnowledgeStore, imageHandler *handler.ImageHandler) {
|
||||
// 限流器
|
||||
rateLimiter := middleware.NewRateLimiter(10, 20) // 每秒10个请求,突发20
|
||||
|
||||
@@ -24,6 +25,16 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
||||
memoryHandler := handler.NewMemoryHandler(cfg.MemoryServiceURL)
|
||||
chatHandler := handler.NewChatHandler(cfg, hub)
|
||||
webhookHandler := handler.NewWebhookHandler(cfg, hub)
|
||||
notificationHandler := handler.NewNotificationHandler(cfg, hub)
|
||||
reminderHandler := handler.NewReminderHandler(reminderStore, hub)
|
||||
briefingHandler := handler.NewBriefingHandler(cfg, hub, briefingStore, reminderStore)
|
||||
voiceHandler := handler.NewVoiceHandler(cfg.VoiceServiceURL)
|
||||
fileHandler := handler.NewFileHandler(fileStore)
|
||||
automationHandler := handler.NewAutomationHandler(automationStore, ruleEngine)
|
||||
knowledgeHandler := handler.NewKnowledgeHandler(knowledgeStore, fileStore)
|
||||
if imageHandler == nil {
|
||||
imageHandler = handler.NewImageHandler(cfg, fileStore)
|
||||
}
|
||||
|
||||
// ========== 公开路由 ==========
|
||||
api := r.Group("/api/v1")
|
||||
@@ -62,8 +73,12 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
||||
sessions.DELETE("/:id", sessionHandler.Delete) // DELETE /api/v1/sessions/:id
|
||||
sessions.GET("/:id/messages", sessionHandler.GetMessages) // GET /api/v1/sessions/:id/messages?limit=50
|
||||
sessions.DELETE("/:id/messages", sessionHandler.ClearMessages) // DELETE /api/v1/sessions/:id/messages
|
||||
sessions.GET("/:id/export", sessionHandler.ExportSession) // GET /api/v1/sessions/:id/export?format=json|markdown|txt
|
||||
}
|
||||
|
||||
// 消息搜索
|
||||
protected.GET("/messages/search", sessionHandler.SearchMessages) // GET /api/v1/messages/search?q=xxx&user_id=xxx&limit=50&offset=0
|
||||
|
||||
// 记忆管理
|
||||
memory := protected.Group("/memory")
|
||||
{
|
||||
@@ -73,6 +88,103 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
||||
memory.DELETE("", memoryHandler.Delete)
|
||||
}
|
||||
|
||||
// 通知推送 (需要认证)
|
||||
notifications := protected.Group("/notifications")
|
||||
{
|
||||
notifications.POST("/push", notificationHandler.Push)
|
||||
}
|
||||
|
||||
// 提醒管理 (需要认证)
|
||||
reminders := protected.Group("/reminders")
|
||||
{
|
||||
reminders.GET("", reminderHandler.List) // GET /api/v1/reminders?user_id=xxx&status=pending&limit=50
|
||||
reminders.POST("", reminderHandler.Create) // POST /api/v1/reminders
|
||||
reminders.PUT("/:id", reminderHandler.Update) // PUT /api/v1/reminders/:id
|
||||
reminders.DELETE("/:id", reminderHandler.Delete) // DELETE /api/v1/reminders/:id
|
||||
}
|
||||
|
||||
// 每日简报 (需要认证)
|
||||
briefings := protected.Group("/briefings")
|
||||
{
|
||||
briefings.GET("", briefingHandler.GetBriefing) // GET /api/v1/briefings?user_id=xxx&date=2024-01-01
|
||||
briefings.GET("/latest", briefingHandler.GetLatestBriefings) // GET /api/v1/briefings/latest?user_id=xxx&limit=7
|
||||
briefings.POST("/generate", briefingHandler.Generate) // POST /api/v1/briefings/generate
|
||||
}
|
||||
|
||||
// 语音识别 + TTS (需要认证)
|
||||
voice := protected.Group("/voice")
|
||||
{
|
||||
voice.POST("/transcribe", voiceHandler.Transcribe)
|
||||
voice.POST("/tts", voiceHandler.TTSSynthesize)
|
||||
voice.GET("/tts/voices", voiceHandler.TTSVoices)
|
||||
voice.GET("/tts/status", voiceHandler.TTSStatus)
|
||||
voice.GET("/status", voiceHandler.VoiceStatus)
|
||||
}
|
||||
|
||||
// 文件管理 (需要认证)
|
||||
files := protected.Group("/files")
|
||||
{
|
||||
files.POST("/upload", fileHandler.Upload)
|
||||
files.GET("", fileHandler.List)
|
||||
files.GET("/:id", fileHandler.Get)
|
||||
files.GET("/:id/download", fileHandler.Download)
|
||||
files.GET("/:id/thumbnail", fileHandler.Thumbnail)
|
||||
files.DELETE("/:id", fileHandler.Delete)
|
||||
}
|
||||
|
||||
// 自动化 (需要认证)
|
||||
automation := protected.Group("/automation")
|
||||
{
|
||||
// 规则
|
||||
rules := automation.Group("/rules")
|
||||
{
|
||||
rules.GET("", automationHandler.ListRules) // GET /api/v1/automation/rules
|
||||
rules.POST("", automationHandler.CreateRule) // POST /api/v1/automation/rules
|
||||
rules.GET("/:id", automationHandler.GetRule) // GET /api/v1/automation/rules/:id
|
||||
rules.PUT("/:id", automationHandler.UpdateRule) // PUT /api/v1/automation/rules/:id
|
||||
rules.DELETE("/:id", automationHandler.DeleteRule) // DELETE /api/v1/automation/rules/:id
|
||||
rules.POST("/:id/trigger", automationHandler.TriggerRule) // POST /api/v1/automation/rules/:id/trigger
|
||||
}
|
||||
|
||||
// 场景
|
||||
scenes := automation.Group("/scenes")
|
||||
{
|
||||
scenes.GET("", automationHandler.ListScenes) // GET /api/v1/automation/scenes
|
||||
scenes.POST("", automationHandler.CreateScene) // POST /api/v1/automation/scenes
|
||||
scenes.GET("/:id", automationHandler.GetScene) // GET /api/v1/automation/scenes/:id
|
||||
scenes.PUT("/:id", automationHandler.UpdateScene) // PUT /api/v1/automation/scenes/:id
|
||||
scenes.DELETE("/:id", automationHandler.DeleteScene) // DELETE /api/v1/automation/scenes/:id
|
||||
scenes.POST("/:id/execute", automationHandler.ExecuteScene) // POST /api/v1/automation/scenes/:id/execute
|
||||
}
|
||||
}
|
||||
|
||||
// 知识库管理 (需要认证)
|
||||
knowledge := protected.Group("/knowledge")
|
||||
{
|
||||
// 知识库 CRUD
|
||||
knowledge.POST("/bases", knowledgeHandler.CreateKB) // POST /api/v1/knowledge/bases
|
||||
knowledge.GET("/bases", knowledgeHandler.ListKBs) // GET /api/v1/knowledge/bases
|
||||
knowledge.GET("/bases/:id", knowledgeHandler.GetKB) // GET /api/v1/knowledge/bases/:id
|
||||
knowledge.PUT("/bases/:id", knowledgeHandler.UpdateKB) // PUT /api/v1/knowledge/bases/:id
|
||||
knowledge.DELETE("/bases/:id", knowledgeHandler.DeleteKB) // DELETE /api/v1/knowledge/bases/:id
|
||||
|
||||
// 文档管理
|
||||
knowledge.POST("/bases/:id/documents", knowledgeHandler.AddDocument) // POST /api/v1/knowledge/bases/:id/documents
|
||||
knowledge.GET("/bases/:id/documents", knowledgeHandler.ListDocuments) // GET /api/v1/knowledge/bases/:id/documents
|
||||
knowledge.GET("/documents/:id", knowledgeHandler.GetDocument) // GET /api/v1/knowledge/documents/:id
|
||||
knowledge.DELETE("/documents/:id", knowledgeHandler.DeleteDocument) // DELETE /api/v1/knowledge/documents/:id
|
||||
|
||||
// 搜索
|
||||
knowledge.POST("/search", knowledgeHandler.Search) // POST /api/v1/knowledge/search
|
||||
}
|
||||
|
||||
// 图片分析 (需要认证)
|
||||
images := protected.Group("/images")
|
||||
{
|
||||
images.POST("/analyze", imageHandler.Analyze) // POST /api/v1/images/analyze
|
||||
images.GET("/analyze/:file_id", imageHandler.AnalyzeByID) // GET /api/v1/images/analyze/:file_id
|
||||
}
|
||||
|
||||
// Admin 路由 (需要管理员权限)
|
||||
admin := protected.Group("/admin")
|
||||
admin.Use(adminAuth())
|
||||
@@ -83,6 +195,13 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 内部服务路由 (使用 Internal Service Token 认证) ==========
|
||||
internal := r.Group("/api/v1/internal")
|
||||
internal.Use(notificationHandler.InternalNotifyAuth())
|
||||
{
|
||||
internal.POST("/notify", notificationHandler.InternalNotify)
|
||||
}
|
||||
|
||||
// ========== WebSocket路由 ==========
|
||||
// WebSocket升级在HTTP层,token通过query参数或Header传递
|
||||
wsGroup := r.Group("/ws")
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AutomationRule 自动化规则模型
|
||||
type AutomationRule struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
TriggerConfig *json.RawMessage `json:"trigger_config"`
|
||||
Conditions *json.RawMessage `json:"conditions"`
|
||||
Actions *json.RawMessage `json:"actions"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastTriggeredAt *time.Time `json:"last_triggered_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AutomationScene 自动化场景模型
|
||||
type AutomationScene struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
RuleIDs *json.RawMessage `json:"rule_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AutomationStore 自动化持久化存储
|
||||
type AutomationStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewAutomationStore 使用已有数据库连接初始化自动化存储并自动建表
|
||||
func NewAutomationStore(db *sql.DB) (*AutomationStore, error) {
|
||||
store := &AutomationStore{db: db}
|
||||
|
||||
if err := store.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("自动化表迁移失败: %w", err)
|
||||
}
|
||||
|
||||
log.Println("[AutomationStore] 自动化持久化存储已初始化")
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// migrate 自动创建表结构
|
||||
func (s *AutomationStore) migrate() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS automation_rules (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
trigger_type VARCHAR(32) NOT NULL,
|
||||
trigger_config JSONB DEFAULT '{}',
|
||||
conditions JSONB DEFAULT '[]',
|
||||
actions JSONB NOT NULL DEFAULT '[]',
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
last_triggered_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_automation_rules_user_id ON automation_rules(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_automation_rules_enabled ON automation_rules(enabled)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS automation_scenes (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
icon VARCHAR(64) DEFAULT '',
|
||||
rule_ids JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_automation_scenes_user_id ON automation_scenes(user_id)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return fmt.Errorf("迁移SQL执行失败: %w\nSQL: %s", err, q)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== Rule CRUD ==========
|
||||
|
||||
// CreateRule 创建新规则
|
||||
func (s *AutomationStore) CreateRule(rule *AutomationRule) error {
|
||||
now := time.Now()
|
||||
rule.CreatedAt = now
|
||||
rule.UpdatedAt = now
|
||||
|
||||
triggerConfig := jsonNull(rule.TriggerConfig)
|
||||
conditions := jsonNull(rule.Conditions)
|
||||
actions := jsonNull(rule.Actions)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO automation_rules (id, user_id, name, description, trigger_type, trigger_config, conditions, actions, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
rule.ID, rule.UserID, rule.Name, rule.Description, rule.TriggerType,
|
||||
triggerConfig, conditions, actions, rule.Enabled, rule.CreatedAt, rule.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建规则失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRulesByUser 获取用户的所有规则
|
||||
func (s *AutomationStore) GetRulesByUser(userID string) ([]AutomationRule, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, description, trigger_type, trigger_config, conditions, actions, enabled, last_triggered_at, created_at, updated_at
|
||||
FROM automation_rules WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询用户规则失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []AutomationRule
|
||||
for rows.Next() {
|
||||
var r AutomationRule
|
||||
if err := rows.Scan(&r.ID, &r.UserID, &r.Name, &r.Description, &r.TriggerType,
|
||||
&r.TriggerConfig, &r.Conditions, &r.Actions, &r.Enabled, &r.LastTriggeredAt,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描规则行失败: %w", err)
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
|
||||
if rules == nil {
|
||||
rules = []AutomationRule{}
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
// GetRule 获取单个规则
|
||||
func (s *AutomationStore) GetRule(id string) (*AutomationRule, error) {
|
||||
var r AutomationRule
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, description, trigger_type, trigger_config, conditions, actions, enabled, last_triggered_at, created_at, updated_at
|
||||
FROM automation_rules WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&r.ID, &r.UserID, &r.Name, &r.Description, &r.TriggerType,
|
||||
&r.TriggerConfig, &r.Conditions, &r.Actions, &r.Enabled, &r.LastTriggeredAt,
|
||||
&r.CreatedAt, &r.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询规则失败: %w", err)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// UpdateRule 更新规则
|
||||
func (s *AutomationStore) UpdateRule(rule *AutomationRule) error {
|
||||
triggerConfig := jsonNull(rule.TriggerConfig)
|
||||
conditions := jsonNull(rule.Conditions)
|
||||
actions := jsonNull(rule.Actions)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE automation_rules SET name = $1, description = $2, trigger_type = $3,
|
||||
trigger_config = $4, conditions = $5, actions = $6, enabled = $7, updated_at = NOW()
|
||||
WHERE id = $8`,
|
||||
rule.Name, rule.Description, rule.TriggerType,
|
||||
triggerConfig, conditions, actions, rule.Enabled, rule.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新规则失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRule 删除规则
|
||||
func (s *AutomationStore) DeleteRule(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM automation_rules WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除规则失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEnabledRules 获取所有启用的规则(供引擎使用)
|
||||
func (s *AutomationStore) GetEnabledRules() ([]AutomationRule, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, description, trigger_type, trigger_config, conditions, actions, enabled, last_triggered_at, created_at, updated_at
|
||||
FROM automation_rules WHERE enabled = TRUE
|
||||
ORDER BY created_at ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询启用的规则失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []AutomationRule
|
||||
for rows.Next() {
|
||||
var r AutomationRule
|
||||
if err := rows.Scan(&r.ID, &r.UserID, &r.Name, &r.Description, &r.TriggerType,
|
||||
&r.TriggerConfig, &r.Conditions, &r.Actions, &r.Enabled, &r.LastTriggeredAt,
|
||||
&r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描规则行失败: %w", err)
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
|
||||
if rules == nil {
|
||||
rules = []AutomationRule{}
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
// MarkRuleTriggered 更新 last_triggered_at
|
||||
func (s *AutomationStore) MarkRuleTriggered(id string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE automation_rules SET last_triggered_at = NOW(), updated_at = NOW() WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("标记规则触发失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== Scene CRUD ==========
|
||||
|
||||
// CreateScene 创建新场景
|
||||
func (s *AutomationStore) CreateScene(scene *AutomationScene) error {
|
||||
ruleIDs := jsonNull(scene.RuleIDs)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO automation_scenes (id, user_id, name, icon, rule_ids, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||
scene.ID, scene.UserID, scene.Name, scene.Icon, ruleIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建场景失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetScenesByUser 获取用户的所有场景
|
||||
func (s *AutomationStore) GetScenesByUser(userID string) ([]AutomationScene, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, icon, rule_ids, created_at
|
||||
FROM automation_scenes WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询用户场景失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var scenes []AutomationScene
|
||||
for rows.Next() {
|
||||
var sc AutomationScene
|
||||
if err := rows.Scan(&sc.ID, &sc.UserID, &sc.Name, &sc.Icon, &sc.RuleIDs, &sc.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描场景行失败: %w", err)
|
||||
}
|
||||
scenes = append(scenes, sc)
|
||||
}
|
||||
|
||||
if scenes == nil {
|
||||
scenes = []AutomationScene{}
|
||||
}
|
||||
return scenes, rows.Err()
|
||||
}
|
||||
|
||||
// GetScene 获取单个场景
|
||||
func (s *AutomationStore) GetScene(id string) (*AutomationScene, error) {
|
||||
var sc AutomationScene
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, icon, rule_ids, created_at
|
||||
FROM automation_scenes WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&sc.ID, &sc.UserID, &sc.Name, &sc.Icon, &sc.RuleIDs, &sc.CreatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询场景失败: %w", err)
|
||||
}
|
||||
return &sc, nil
|
||||
}
|
||||
|
||||
// UpdateScene 更新场景
|
||||
func (s *AutomationStore) UpdateScene(scene *AutomationScene) error {
|
||||
ruleIDs := jsonNull(scene.RuleIDs)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE automation_scenes SET name = $1, icon = $2, rule_ids = $3 WHERE id = $4`,
|
||||
scene.Name, scene.Icon, ruleIDs, scene.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新场景失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteScene 删除场景
|
||||
func (s *AutomationStore) DeleteScene(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM automation_scenes WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除场景失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSceneRules 根据 scene 的 rule_ids 取出所有关联的 rules
|
||||
func (s *AutomationStore) GetSceneRules(sceneID string) ([]AutomationRule, error) {
|
||||
sc, err := s.GetScene(sceneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sc == nil {
|
||||
return []AutomationRule{}, nil
|
||||
}
|
||||
|
||||
var ruleIDs []string
|
||||
if sc.RuleIDs != nil {
|
||||
if err := json.Unmarshal(*sc.RuleIDs, &ruleIDs); err != nil {
|
||||
return nil, fmt.Errorf("解析场景规则ID失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ruleIDs) == 0 {
|
||||
return []AutomationRule{}, nil
|
||||
}
|
||||
|
||||
// 构建 IN 查询
|
||||
var rules []AutomationRule
|
||||
for _, rid := range ruleIDs {
|
||||
r, err := s.GetRule(rid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询场景关联规则失败: %w", err)
|
||||
}
|
||||
if r != nil {
|
||||
rules = append(rules, *r)
|
||||
}
|
||||
}
|
||||
|
||||
if rules == nil {
|
||||
rules = []AutomationRule{}
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// jsonNull 将 *json.RawMessage 转为可写入数据库的 JSON 或 null
|
||||
func jsonNull(raw *json.RawMessage) interface{} {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
return []byte(*raw)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Briefing 每日简报模型
|
||||
type Briefing struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
Weather *WeatherData `json:"weather"`
|
||||
News []NewsItem `json:"news"`
|
||||
Reminders []BriefReminder `json:"reminders"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"` // pending, generated, delivered
|
||||
GeneratedAt *time.Time `json:"generated_at,omitempty"`
|
||||
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// WeatherData 天气数据
|
||||
type WeatherData struct {
|
||||
Location string `json:"location"`
|
||||
Temp float64 `json:"temp"`
|
||||
Condition string `json:"condition"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// NewsItem 新闻条目
|
||||
type NewsItem struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Source string `json:"source"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// BriefReminder 简报中的提醒摘要
|
||||
type BriefReminder struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
RemindAt string `json:"remind_at"`
|
||||
}
|
||||
|
||||
// BriefingStore 每日简报持久化存储
|
||||
type BriefingStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewBriefingStore 使用已有数据库连接初始化简报存储并自动建表
|
||||
func NewBriefingStore(db *sql.DB) (*BriefingStore, error) {
|
||||
store := &BriefingStore{db: db}
|
||||
|
||||
if err := store.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("简报表迁移失败: %w", err)
|
||||
}
|
||||
|
||||
log.Println("[BriefingStore] 简报持久化存储已初始化")
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// migrate 自动创建简报表结构
|
||||
func (s *BriefingStore) migrate() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS daily_briefings (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
weather JSONB DEFAULT '{}',
|
||||
news JSONB DEFAULT '[]',
|
||||
reminders JSONB DEFAULT '[]',
|
||||
summary TEXT DEFAULT '',
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
generated_at TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, date)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_briefings_user_id ON daily_briefings(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_briefings_date ON daily_briefings(date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_briefings_user_date ON daily_briefings(user_id, date)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return fmt.Errorf("迁移SQL执行失败: %w\nSQL: %s", err, q)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateOrUpdateBriefing upsert 简报
|
||||
func (s *BriefingStore) CreateOrUpdateBriefing(b *Briefing) error {
|
||||
weatherJSON, err := json.Marshal(b.Weather)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化天气数据失败: %w", err)
|
||||
}
|
||||
newsJSON, err := json.Marshal(b.News)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化新闻数据失败: %w", err)
|
||||
}
|
||||
remindersJSON, err := json.Marshal(b.Reminders)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化提醒数据失败: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO daily_briefings (id, user_id, date, weather, news, reminders, summary, status, generated_at, delivered_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||
weather = EXCLUDED.weather,
|
||||
news = EXCLUDED.news,
|
||||
reminders = EXCLUDED.reminders,
|
||||
summary = EXCLUDED.summary,
|
||||
status = EXCLUDED.status,
|
||||
generated_at = EXCLUDED.generated_at,
|
||||
delivered_at = EXCLUDED.delivered_at`,
|
||||
b.ID, b.UserID, b.Date, string(weatherJSON), string(newsJSON), string(remindersJSON),
|
||||
b.Summary, b.Status, b.GeneratedAt, b.DeliveredAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert 简报失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBriefingByDate 获取指定日期简报
|
||||
func (s *BriefingStore) GetBriefingByDate(userID, date string) (*Briefing, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, user_id, date::TEXT, weather, news, reminders, summary, status, generated_at, delivered_at, created_at
|
||||
FROM daily_briefings WHERE user_id = $1 AND date = $2::DATE`,
|
||||
userID, date,
|
||||
)
|
||||
|
||||
b, err := s.scanBriefing(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询简报失败: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// GetLatestBriefings 获取最近简报列表
|
||||
func (s *BriefingStore) GetLatestBriefings(userID string, limit int) ([]Briefing, error) {
|
||||
if limit <= 0 {
|
||||
limit = 7
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, date::TEXT, weather, news, reminders, summary, status, generated_at, delivered_at, created_at
|
||||
FROM daily_briefings WHERE user_id = $1
|
||||
ORDER BY date DESC LIMIT $2`,
|
||||
userID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询简报列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var briefings []Briefing
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, uid, date, summary, status string
|
||||
weatherRaw, newsRaw, remindersRaw []byte
|
||||
generatedAt, deliveredAt, createdAt sql.NullTime
|
||||
)
|
||||
if err := rows.Scan(&id, &uid, &date, &weatherRaw, &newsRaw, &remindersRaw,
|
||||
&summary, &status, &generatedAt, &deliveredAt, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描简报行失败: %w", err)
|
||||
}
|
||||
|
||||
b := Briefing{
|
||||
ID: id,
|
||||
UserID: uid,
|
||||
Date: date,
|
||||
Summary: summary,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if weatherRaw != nil {
|
||||
var w WeatherData
|
||||
if err := json.Unmarshal(weatherRaw, &w); err == nil {
|
||||
b.Weather = &w
|
||||
}
|
||||
}
|
||||
if newsRaw != nil {
|
||||
json.Unmarshal(newsRaw, &b.News)
|
||||
}
|
||||
if remindersRaw != nil {
|
||||
json.Unmarshal(remindersRaw, &b.Reminders)
|
||||
}
|
||||
if generatedAt.Valid {
|
||||
b.GeneratedAt = &generatedAt.Time
|
||||
}
|
||||
if deliveredAt.Valid {
|
||||
b.DeliveredAt = &deliveredAt.Time
|
||||
}
|
||||
b.CreatedAt = createdAt.Time
|
||||
|
||||
// 确保切片不为 nil
|
||||
if b.News == nil {
|
||||
b.News = []NewsItem{}
|
||||
}
|
||||
if b.Reminders == nil {
|
||||
b.Reminders = []BriefReminder{}
|
||||
}
|
||||
if b.Weather == nil {
|
||||
b.Weather = &WeatherData{}
|
||||
}
|
||||
|
||||
briefings = append(briefings, b)
|
||||
}
|
||||
|
||||
if briefings == nil {
|
||||
briefings = []Briefing{}
|
||||
}
|
||||
return briefings, rows.Err()
|
||||
}
|
||||
|
||||
// GetUsersWithBriefings 获取拥有简报的所有用户 ID 列表(用于调度器)
|
||||
func (s *BriefingStore) GetUsersWithBriefings() ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT DISTINCT user_id FROM daily_briefings`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询简报用户列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userIDs []string
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
return nil, fmt.Errorf("扫描用户ID失败: %w", err)
|
||||
}
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
if userIDs == nil {
|
||||
userIDs = []string{}
|
||||
}
|
||||
return userIDs, rows.Err()
|
||||
}
|
||||
|
||||
// GetAllUsers 获取所有用户 ID(从 reminders 表获取,作为降级方案)
|
||||
func (s *BriefingStore) GetAllUsers() ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT DISTINCT user_id FROM reminders`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询用户列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userIDs []string
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
return nil, fmt.Errorf("扫描用户ID失败: %w", err)
|
||||
}
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
if userIDs == nil {
|
||||
userIDs = []string{}
|
||||
}
|
||||
return userIDs, rows.Err()
|
||||
}
|
||||
|
||||
// scanBriefing 扫描单行简报
|
||||
func (s *BriefingStore) scanBriefing(row *sql.Row) (*Briefing, error) {
|
||||
var (
|
||||
id, uid, date, summary, status string
|
||||
weatherRaw, newsRaw, remindersRaw []byte
|
||||
generatedAt, deliveredAt, createdAt sql.NullTime
|
||||
)
|
||||
|
||||
if err := row.Scan(&id, &uid, &date, &weatherRaw, &newsRaw, &remindersRaw,
|
||||
&summary, &status, &generatedAt, &deliveredAt, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b := &Briefing{
|
||||
ID: id,
|
||||
UserID: uid,
|
||||
Date: date,
|
||||
Summary: summary,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if weatherRaw != nil {
|
||||
var w WeatherData
|
||||
if err := json.Unmarshal(weatherRaw, &w); err == nil {
|
||||
b.Weather = &w
|
||||
}
|
||||
}
|
||||
if b.Weather == nil {
|
||||
b.Weather = &WeatherData{}
|
||||
}
|
||||
if newsRaw != nil {
|
||||
json.Unmarshal(newsRaw, &b.News)
|
||||
}
|
||||
if b.News == nil {
|
||||
b.News = []NewsItem{}
|
||||
}
|
||||
if remindersRaw != nil {
|
||||
json.Unmarshal(remindersRaw, &b.Reminders)
|
||||
}
|
||||
if b.Reminders == nil {
|
||||
b.Reminders = []BriefReminder{}
|
||||
}
|
||||
if generatedAt.Valid {
|
||||
b.GeneratedAt = &generatedAt.Time
|
||||
}
|
||||
if deliveredAt.Valid {
|
||||
b.DeliveredAt = &deliveredAt.Time
|
||||
}
|
||||
b.CreatedAt = createdAt.Time
|
||||
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// File 文件元数据模型
|
||||
type File struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Filename string `json:"filename"`
|
||||
StoredPath string `json:"stored_path"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FileStore 文件元数据持久化存储
|
||||
type FileStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewFileStore 使用已有数据库连接初始化文件存储并自动建表
|
||||
func NewFileStore(db *sql.DB) (*FileStore, error) {
|
||||
store := &FileStore{db: db}
|
||||
|
||||
if err := store.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("文件表迁移失败: %w", err)
|
||||
}
|
||||
|
||||
log.Println("[FileStore] 文件持久化存储已初始化")
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// migrate 自动创建文件表结构
|
||||
func (s *FileStore) migrate() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS files (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
filename VARCHAR(500) NOT NULL,
|
||||
stored_path VARCHAR(1000) NOT NULL,
|
||||
mime_type VARCHAR(255) NOT NULL DEFAULT 'application/octet-stream',
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
hash VARCHAR(64) NOT NULL DEFAULT '',
|
||||
is_public BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_files_user_id ON files(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_files_created_at ON files(user_id, created_at DESC)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return fmt.Errorf("迁移SQL执行失败: %w\nSQL: %s", err, q)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateFile 创建文件元数据记录
|
||||
func (s *FileStore) CreateFile(f *File) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO files (id, user_id, filename, stored_path, mime_type, size, hash, is_public, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
f.ID, f.UserID, f.Filename, f.StoredPath, f.MimeType, f.Size, f.Hash, f.IsPublic, f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建文件记录失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFile 根据ID获取文件元数据
|
||||
func (s *FileStore) GetFile(id string) (*File, error) {
|
||||
var f File
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, filename, stored_path, mime_type, size, hash, is_public, created_at
|
||||
FROM files WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&f.ID, &f.UserID, &f.Filename, &f.StoredPath, &f.MimeType, &f.Size, &f.Hash, &f.IsPublic, &f.CreatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询文件失败: %w", err)
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// GetUserFiles 获取用户的所有文件,支持分页
|
||||
func (s *FileStore) GetUserFiles(userID string, page, limit int) ([]File, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
// 获取总数
|
||||
var total int
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM files WHERE user_id = $1`,
|
||||
userID,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("查询文件总数失败: %w", err)
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, filename, stored_path, mime_type, size, hash, is_public, created_at
|
||||
FROM files WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
userID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("查询用户文件失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []File
|
||||
for rows.Next() {
|
||||
var f File
|
||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Filename, &f.StoredPath, &f.MimeType, &f.Size, &f.Hash, &f.IsPublic, &f.CreatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("扫描文件行失败: %w", err)
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
|
||||
if files == nil {
|
||||
files = []File{}
|
||||
}
|
||||
return files, total, rows.Err()
|
||||
}
|
||||
|
||||
// GetFileByHash 根据SHA256哈希查找文件(用于去重)
|
||||
func (s *FileStore) GetFileByHash(hash string) (*File, error) {
|
||||
if hash == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var f File
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, filename, stored_path, mime_type, size, hash, is_public, created_at
|
||||
FROM files WHERE hash = $1
|
||||
ORDER BY created_at ASC LIMIT 1`,
|
||||
hash,
|
||||
).Scan(&f.ID, &f.UserID, &f.Filename, &f.StoredPath, &f.MimeType, &f.Size, &f.Hash, &f.IsPublic, &f.CreatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("按哈希查询文件失败: %w", err)
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件元数据记录
|
||||
func (s *FileStore) DeleteFile(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM files WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除文件记录失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ========== 模型定义 ==========
|
||||
|
||||
// KnowledgeBase 知识库
|
||||
type KnowledgeBase struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DocumentCount int `json:"document_count"`
|
||||
ChunkCount int `json:"chunk_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// KnowledgeDocument 知识库文档
|
||||
type KnowledgeDocument struct {
|
||||
ID string `json:"id"`
|
||||
KBID string `json:"kb_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
SourceType string `json:"source_type"` // "file", "text", "url"
|
||||
SourceRef string `json:"source_ref"` // 文件 ID 或 URL
|
||||
ContentType string `json:"content_type"` // "text/plain", "text/markdown", "text/html"
|
||||
RawContent string `json:"raw_content"`
|
||||
ChunkCount int `json:"chunk_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// KnowledgeChunk 文档分块
|
||||
type KnowledgeChunk struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"doc_id"`
|
||||
KBID string `json:"kb_id"`
|
||||
ChunkIndex int `json:"chunk_index"`
|
||||
Content string `json:"content"`
|
||||
TokenCount int `json:"token_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SearchChunkResult 搜索结果的块,包含额外上下文信息
|
||||
type SearchChunkResult struct {
|
||||
KnowledgeChunk
|
||||
Relevance float64 `json:"relevance"`
|
||||
DocumentTitle string `json:"document_title"`
|
||||
KBName string `json:"kb_name"`
|
||||
Headline string `json:"headline"`
|
||||
}
|
||||
|
||||
// ========== KnowledgeStore ==========
|
||||
|
||||
// KnowledgeStore 知识库持久化存储
|
||||
type KnowledgeStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewKnowledgeStore 使用已有数据库连接初始化知识库存储并自动建表
|
||||
func NewKnowledgeStore(db *sql.DB) (*KnowledgeStore, error) {
|
||||
store := &KnowledgeStore{db: db}
|
||||
|
||||
if err := store.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("知识库表迁移失败: %w", err)
|
||||
}
|
||||
|
||||
log.Println("[KnowledgeStore] 知识库持久化存储已初始化")
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// migrate 自动创建知识库相关表结构
|
||||
func (s *KnowledgeStore) migrate() error {
|
||||
queries := []string{
|
||||
// 知识库表
|
||||
`CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
document_count INT DEFAULT 0,
|
||||
chunk_count INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_kb_user_id ON knowledge_bases(user_id)`,
|
||||
|
||||
// 文档表
|
||||
`CREATE TABLE IF NOT EXISTS knowledge_documents (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
kb_id VARCHAR(64) NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(512) NOT NULL,
|
||||
source_type VARCHAR(32) DEFAULT 'text',
|
||||
source_ref VARCHAR(1024) DEFAULT '',
|
||||
content_type VARCHAR(64) DEFAULT 'text/plain',
|
||||
raw_content TEXT DEFAULT '',
|
||||
chunk_count INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_kd_kb_id ON knowledge_documents(kb_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_kd_user_id ON knowledge_documents(user_id)`,
|
||||
|
||||
// 分块表
|
||||
`CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
doc_id VARCHAR(64) NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
|
||||
kb_id VARCHAR(64) NOT NULL,
|
||||
chunk_index INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
token_count INT DEFAULT 0,
|
||||
tsv TSVECTOR,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_kc_doc_id ON knowledge_chunks(doc_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_kc_kb_id ON knowledge_chunks(kb_id)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return fmt.Errorf("迁移SQL执行失败: %w\nSQL: %s", err, q)
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试创建 GIN 索引(可能因权限或扩展问题失败,但不影响功能)
|
||||
_, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_kc_tsv_gin ON knowledge_chunks USING GIN(tsv)`)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeStore] ⚠ GIN索引创建失败(将使用ILIKE降级搜索): %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== 知识库 CRUD ==========
|
||||
|
||||
// CreateKB 创建知识库
|
||||
func (s *KnowledgeStore) CreateKB(kb *KnowledgeBase) error {
|
||||
now := time.Now()
|
||||
if kb.CreatedAt.IsZero() {
|
||||
kb.CreatedAt = now
|
||||
}
|
||||
if kb.UpdatedAt.IsZero() {
|
||||
kb.UpdatedAt = now
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO knowledge_bases (id, user_id, name, description, document_count, chunk_count, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
kb.ID, kb.UserID, kb.Name, kb.Description, kb.DocumentCount, kb.ChunkCount, kb.CreatedAt, kb.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建知识库失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKBsByUser 获取用户的所有知识库
|
||||
func (s *KnowledgeStore) GetKBsByUser(userID string) ([]KnowledgeBase, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, description, document_count, chunk_count, created_at, updated_at
|
||||
FROM knowledge_bases WHERE user_id = $1
|
||||
ORDER BY updated_at DESC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询知识库列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var kbs []KnowledgeBase
|
||||
for rows.Next() {
|
||||
var kb KnowledgeBase
|
||||
if err := rows.Scan(&kb.ID, &kb.UserID, &kb.Name, &kb.Description,
|
||||
&kb.DocumentCount, &kb.ChunkCount, &kb.CreatedAt, &kb.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描知识库行失败: %w", err)
|
||||
}
|
||||
kbs = append(kbs, kb)
|
||||
}
|
||||
if kbs == nil {
|
||||
kbs = []KnowledgeBase{}
|
||||
}
|
||||
return kbs, rows.Err()
|
||||
}
|
||||
|
||||
// GetKB 获取单个知识库
|
||||
func (s *KnowledgeStore) GetKB(id string) (*KnowledgeBase, error) {
|
||||
var kb KnowledgeBase
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, description, document_count, chunk_count, created_at, updated_at
|
||||
FROM knowledge_bases WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&kb.ID, &kb.UserID, &kb.Name, &kb.Description,
|
||||
&kb.DocumentCount, &kb.ChunkCount, &kb.CreatedAt, &kb.UpdatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询知识库失败: %w", err)
|
||||
}
|
||||
return &kb, nil
|
||||
}
|
||||
|
||||
// UpdateKB 更新知识库名称和描述
|
||||
func (s *KnowledgeStore) UpdateKB(id string, name, description string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE knowledge_bases SET name = $1, description = $2, updated_at = NOW() WHERE id = $3`,
|
||||
name, description, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新知识库失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteKB 删除知识库(级联删除文档和块)
|
||||
func (s *KnowledgeStore) DeleteKB(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM knowledge_bases WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除知识库失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateKBStats 更新知识库的统计计数
|
||||
func (s *KnowledgeStore) updateKBStats(kbID string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE knowledge_bases SET
|
||||
document_count = (SELECT COUNT(*) FROM knowledge_documents WHERE kb_id = $1),
|
||||
chunk_count = (SELECT COUNT(*) FROM knowledge_chunks WHERE kb_id = $1),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
kbID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ========== 文档 CRUD ==========
|
||||
|
||||
// AddDocument 添加文档,返回创建的文档
|
||||
func (s *KnowledgeStore) AddDocument(doc *KnowledgeDocument) error {
|
||||
if doc.CreatedAt.IsZero() {
|
||||
doc.CreatedAt = time.Now()
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO knowledge_documents (id, kb_id, user_id, title, source_type, source_ref, content_type, raw_content, chunk_count, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
doc.ID, doc.KBID, doc.UserID, doc.Title, doc.SourceType, doc.SourceRef,
|
||||
doc.ContentType, doc.RawContent, doc.ChunkCount, doc.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("添加文档失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新知识库统计
|
||||
if err := s.updateKBStats(doc.KBID); err != nil {
|
||||
log.Printf("[KnowledgeStore] 更新知识库统计失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDocument 获取单个文档
|
||||
func (s *KnowledgeStore) GetDocument(id string) (*KnowledgeDocument, error) {
|
||||
var doc KnowledgeDocument
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, kb_id, user_id, title, source_type, source_ref, content_type, raw_content, chunk_count, created_at
|
||||
FROM knowledge_documents WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&doc.ID, &doc.KBID, &doc.UserID, &doc.Title, &doc.SourceType, &doc.SourceRef,
|
||||
&doc.ContentType, &doc.RawContent, &doc.ChunkCount, &doc.CreatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询文档失败: %w", err)
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// GetDocumentsByKB 获取知识库中的所有文档
|
||||
func (s *KnowledgeStore) GetDocumentsByKB(kbID string) ([]KnowledgeDocument, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, kb_id, user_id, title, source_type, source_ref, content_type, raw_content, chunk_count, created_at
|
||||
FROM knowledge_documents WHERE kb_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
kbID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询文档列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var docs []KnowledgeDocument
|
||||
for rows.Next() {
|
||||
var doc KnowledgeDocument
|
||||
if err := rows.Scan(&doc.ID, &doc.KBID, &doc.UserID, &doc.Title, &doc.SourceType, &doc.SourceRef,
|
||||
&doc.ContentType, &doc.RawContent, &doc.ChunkCount, &doc.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描文档行失败: %w", err)
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
if docs == nil {
|
||||
docs = []KnowledgeDocument{}
|
||||
}
|
||||
return docs, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateDocumentChunkCount 更新文档的分块计数
|
||||
func (s *KnowledgeStore) UpdateDocumentChunkCount(docID string, count int) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE knowledge_documents SET chunk_count = $1 WHERE id = $2`,
|
||||
count, docID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDocument 删除文档(级联删除块)
|
||||
func (s *KnowledgeStore) DeleteDocument(id string) error {
|
||||
// 先获取 kb_id 以便后续更新统计
|
||||
var kbID string
|
||||
err := s.db.QueryRow(`SELECT kb_id FROM knowledge_documents WHERE id = $1`, id).Scan(&kbID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("查询文档失败: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`DELETE FROM knowledge_documents WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除文档失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新知识库统计
|
||||
if err := s.updateKBStats(kbID); err != nil {
|
||||
log.Printf("[KnowledgeStore] 更新知识库统计失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== 分块操作 ==========
|
||||
|
||||
// AddChunk 添加单个分块
|
||||
func (s *KnowledgeStore) AddChunk(chunk *KnowledgeChunk) error {
|
||||
if chunk.CreatedAt.IsZero() {
|
||||
chunk.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
// 尝试使用 to_tsvector('chinese', content) 设置 tsv
|
||||
// 如果中文分词不可用,使用 simple 配置
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO knowledge_chunks (id, doc_id, kb_id, chunk_index, content, token_count, tsv, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6,
|
||||
CASE WHEN (SELECT count(*) FROM pg_ts_config WHERE cfgname = 'chinese') > 0
|
||||
THEN to_tsvector('chinese', $5)
|
||||
ELSE to_tsvector('simple', $5)
|
||||
END,
|
||||
$7)`,
|
||||
chunk.ID, chunk.DocID, chunk.KBID, chunk.ChunkIndex, chunk.Content, chunk.TokenCount, chunk.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
// 降级:不使用 tsv
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO knowledge_chunks (id, doc_id, kb_id, chunk_index, content, token_count, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
chunk.ID, chunk.DocID, chunk.KBID, chunk.ChunkIndex, chunk.Content, chunk.TokenCount, chunk.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("添加分块失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteChunksByDocID 删除文档的所有分块
|
||||
func (s *KnowledgeStore) DeleteChunksByDocID(docID string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM knowledge_chunks WHERE doc_id = $1`, docID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetChunksByDocID 获取文档的所有分块
|
||||
func (s *KnowledgeStore) GetChunksByDocID(docID string) ([]KnowledgeChunk, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, doc_id, kb_id, chunk_index, content, token_count, created_at
|
||||
FROM knowledge_chunks WHERE doc_id = $1
|
||||
ORDER BY chunk_index ASC`,
|
||||
docID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询分块失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var chunks []KnowledgeChunk
|
||||
for rows.Next() {
|
||||
var c KnowledgeChunk
|
||||
if err := rows.Scan(&c.ID, &c.DocID, &c.KBID, &c.ChunkIndex, &c.Content, &c.TokenCount, &c.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描分块行失败: %w", err)
|
||||
}
|
||||
chunks = append(chunks, c)
|
||||
}
|
||||
if chunks == nil {
|
||||
chunks = []KnowledgeChunk{}
|
||||
}
|
||||
return chunks, rows.Err()
|
||||
}
|
||||
|
||||
// ========== 分块逻辑 ==========
|
||||
|
||||
// ChunkDocument 将文档分块并存储
|
||||
func (s *KnowledgeStore) ChunkDocument(docID string) (int, error) {
|
||||
// 获取文档
|
||||
doc, err := s.GetDocument(docID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if doc == nil {
|
||||
return 0, fmt.Errorf("文档不存在: %s", docID)
|
||||
}
|
||||
|
||||
// 删除旧的分块
|
||||
if err := s.DeleteChunksByDocID(docID); err != nil {
|
||||
return 0, fmt.Errorf("删除旧分块失败: %w", err)
|
||||
}
|
||||
|
||||
// 分块
|
||||
chunks := splitTextIntoChunks(doc.RawContent, 500, 50)
|
||||
|
||||
// 存储分块
|
||||
for i, content := range chunks {
|
||||
chunk := &KnowledgeChunk{
|
||||
ID: generateUUIDv4(),
|
||||
DocID: docID,
|
||||
KBID: doc.KBID,
|
||||
ChunkIndex: i,
|
||||
Content: content,
|
||||
TokenCount: estimateTokenCount(content),
|
||||
}
|
||||
if err := s.AddChunk(chunk); err != nil {
|
||||
return 0, fmt.Errorf("存储分块 %d 失败: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新文档的分块计数
|
||||
if err := s.UpdateDocumentChunkCount(docID, len(chunks)); err != nil {
|
||||
log.Printf("[KnowledgeStore] 更新文档分块计数失败: %v", err)
|
||||
}
|
||||
|
||||
// 更新知识库统计
|
||||
if err := s.updateKBStats(doc.KBID); err != nil {
|
||||
log.Printf("[KnowledgeStore] 更新知识库统计失败: %v", err)
|
||||
}
|
||||
|
||||
return len(chunks), nil
|
||||
}
|
||||
|
||||
// ========== 搜索 ==========
|
||||
|
||||
// SearchChunks 在指定知识库中搜索
|
||||
func (s *KnowledgeStore) SearchChunks(kbID, query string, limit int) ([]SearchChunkResult, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
|
||||
// 尝试使用 PostgreSQL 全文搜索
|
||||
results, err := s.searchWithFullText(kbID, query, limit)
|
||||
if err != nil {
|
||||
log.Printf("[KnowledgeStore] 全文搜索失败,降级为ILIKE: %v", err)
|
||||
// 降级为 ILIKE
|
||||
results, err = s.searchWithILike(kbID, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if results == nil {
|
||||
results = []SearchChunkResult{}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchAllKBs 在用户的所有知识库中搜索
|
||||
func (s *KnowledgeStore) SearchAllKBs(userID, query string, limit int) ([]SearchChunkResult, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
|
||||
results, err := s.searchAllWithILike(userID, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if results == nil {
|
||||
results = []SearchChunkResult{}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// searchWithFullText 使用 PostgreSQL ts_rank + plainto_tsquery 搜索
|
||||
func (s *KnowledgeStore) searchWithFullText(kbID, query string, limit int) ([]SearchChunkResult, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT kc.id, kc.doc_id, kc.kb_id, kc.chunk_index, kc.content, kc.token_count, kc.created_at,
|
||||
ts_rank(kc.tsv, plainto_tsquery('chinese', $2)) AS relevance,
|
||||
kd.title AS document_title,
|
||||
kb.name AS kb_name
|
||||
FROM knowledge_chunks kc
|
||||
JOIN knowledge_documents kd ON kc.doc_id = kd.id
|
||||
JOIN knowledge_bases kb ON kc.kb_id = kb.id
|
||||
WHERE kc.kb_id = $1 AND kc.tsv @@ plainto_tsquery('chinese', $2)
|
||||
ORDER BY relevance DESC
|
||||
LIMIT $3`,
|
||||
kbID, query, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanSearchResults(rows)
|
||||
}
|
||||
|
||||
// searchWithILike 使用 ILIKE 降级搜索
|
||||
func (s *KnowledgeStore) searchWithILike(kbID, query string, limit int) ([]SearchChunkResult, error) {
|
||||
// 构建 ILIKE 模式
|
||||
keywords := tokenizeQuery(query)
|
||||
if len(keywords) == 0 {
|
||||
return []SearchChunkResult{}, nil
|
||||
}
|
||||
|
||||
// 对每个关键词构建 ILIKE 条件
|
||||
conditions := make([]string, len(keywords))
|
||||
args := []interface{}{kbID}
|
||||
placeholderIdx := 2
|
||||
for i, kw := range keywords {
|
||||
conditions[i] = fmt.Sprintf("kc.content ILIKE $%d", placeholderIdx)
|
||||
args = append(args, "%"+kw+"%")
|
||||
placeholderIdx++
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
querySQL := fmt.Sprintf(
|
||||
`SELECT kc.id, kc.doc_id, kc.kb_id, kc.chunk_index, kc.content, kc.token_count, kc.created_at,
|
||||
0.0 AS relevance,
|
||||
kd.title AS document_title,
|
||||
kb.name AS kb_name
|
||||
FROM knowledge_chunks kc
|
||||
JOIN knowledge_documents kd ON kc.doc_id = kd.id
|
||||
JOIN knowledge_bases kb ON kc.kb_id = kb.id
|
||||
WHERE kc.kb_id = $1 AND (%s)
|
||||
LIMIT $%d`,
|
||||
strings.Join(conditions, " AND "),
|
||||
placeholderIdx,
|
||||
)
|
||||
|
||||
rows, err := s.db.Query(querySQL, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ILIKE搜索失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanSearchResults(rows)
|
||||
}
|
||||
|
||||
// searchAllWithILike 跨所有用户知识库使用 ILIKE 搜索
|
||||
func (s *KnowledgeStore) searchAllWithILike(userID, query string, limit int) ([]SearchChunkResult, error) {
|
||||
keywords := tokenizeQuery(query)
|
||||
if len(keywords) == 0 {
|
||||
return []SearchChunkResult{}, nil
|
||||
}
|
||||
|
||||
conditions := make([]string, len(keywords))
|
||||
args := []interface{}{userID}
|
||||
placeholderIdx := 2
|
||||
for i, kw := range keywords {
|
||||
conditions[i] = fmt.Sprintf("kc.content ILIKE $%d", placeholderIdx)
|
||||
args = append(args, "%"+kw+"%")
|
||||
placeholderIdx++
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
querySQL := fmt.Sprintf(
|
||||
`SELECT kc.id, kc.doc_id, kc.kb_id, kc.chunk_index, kc.content, kc.token_count, kc.created_at,
|
||||
0.0 AS relevance,
|
||||
kd.title AS document_title,
|
||||
kb.name AS kb_name
|
||||
FROM knowledge_chunks kc
|
||||
JOIN knowledge_documents kd ON kc.doc_id = kd.id
|
||||
JOIN knowledge_bases kb ON kc.kb_id = kb.id
|
||||
WHERE kb.user_id = $1 AND (%s)
|
||||
ORDER BY kc.created_at DESC
|
||||
LIMIT $%d`,
|
||||
strings.Join(conditions, " AND "),
|
||||
placeholderIdx,
|
||||
)
|
||||
|
||||
rows, err := s.db.Query(querySQL, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("全知识库ILIKE搜索失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanSearchResults(rows)
|
||||
}
|
||||
|
||||
// scanSearchResults 扫描搜索结果
|
||||
func scanSearchResults(rows *sql.Rows) ([]SearchChunkResult, error) {
|
||||
var results []SearchChunkResult
|
||||
for rows.Next() {
|
||||
var r SearchChunkResult
|
||||
if err := rows.Scan(&r.ID, &r.DocID, &r.KBID, &r.ChunkIndex, &r.Content,
|
||||
&r.TokenCount, &r.CreatedAt, &r.Relevance, &r.DocumentTitle, &r.KBName); err != nil {
|
||||
return nil, fmt.Errorf("扫描搜索结果行失败: %w", err)
|
||||
}
|
||||
// 生成高亮片段
|
||||
r.Headline = r.Content
|
||||
results = append(results, r)
|
||||
}
|
||||
if results == nil {
|
||||
results = []SearchChunkResult{}
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// ========== 文本分块函数 ==========
|
||||
|
||||
// splitTextIntoChunks 将文本按 maxLen 分块,块之间有 overlap 字符重叠
|
||||
func splitTextIntoChunks(text string, maxLen int, overlap int) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 按段落分割
|
||||
paragraphs := strings.Split(text, "\n\n")
|
||||
var chunks []string
|
||||
var currentChunk strings.Builder
|
||||
|
||||
for _, para := range paragraphs {
|
||||
para = strings.TrimSpace(para)
|
||||
if para == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
paraLen := utf8.RuneCountInString(para)
|
||||
|
||||
if paraLen <= maxLen {
|
||||
// 如果当前块 + 段落不超过 maxLen,追加到当前块
|
||||
if utf8.RuneCountInString(currentChunk.String()) == 0 {
|
||||
currentChunk.WriteString(para)
|
||||
} else if utf8.RuneCountInString(currentChunk.String())+1+paraLen <= maxLen {
|
||||
currentChunk.WriteString("\n\n")
|
||||
currentChunk.WriteString(para)
|
||||
} else {
|
||||
// 保存当前块,开始新块
|
||||
chunks = append(chunks, currentChunk.String())
|
||||
currentChunk.Reset()
|
||||
currentChunk.WriteString(para)
|
||||
}
|
||||
} else {
|
||||
// 段落超过 maxLen,需要按句子分割
|
||||
// 先保存当前块
|
||||
if currentChunk.Len() > 0 {
|
||||
chunks = append(chunks, currentChunk.String())
|
||||
currentChunk.Reset()
|
||||
}
|
||||
|
||||
// 按句子分割
|
||||
sentences := splitIntoSentences(para)
|
||||
for _, sent := range sentences {
|
||||
sent = strings.TrimSpace(sent)
|
||||
if sent == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sentLen := utf8.RuneCountInString(sent)
|
||||
|
||||
if sentLen <= maxLen {
|
||||
if utf8.RuneCountInString(currentChunk.String()) == 0 {
|
||||
currentChunk.WriteString(sent)
|
||||
} else if utf8.RuneCountInString(currentChunk.String())+sentLen <= maxLen {
|
||||
currentChunk.WriteString(sent)
|
||||
} else {
|
||||
chunks = append(chunks, currentChunk.String())
|
||||
currentChunk.Reset()
|
||||
currentChunk.WriteString(sent)
|
||||
}
|
||||
} else {
|
||||
// 句子超过 maxLen,按 maxLen 截断
|
||||
if currentChunk.Len() > 0 {
|
||||
chunks = append(chunks, currentChunk.String())
|
||||
currentChunk.Reset()
|
||||
}
|
||||
|
||||
// 按 maxLen 截断,带 overlap
|
||||
runes := []rune(sent)
|
||||
start := 0
|
||||
for start < len(runes) {
|
||||
end := start + maxLen
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
chunks = append(chunks, string(runes[start:end]))
|
||||
if end >= len(runes) {
|
||||
break
|
||||
}
|
||||
// 下一块从 end-overlap 开始
|
||||
start = end - overlap
|
||||
if start <= 0 {
|
||||
start = end
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存最后一个块
|
||||
if currentChunk.Len() > 0 {
|
||||
chunks = append(chunks, currentChunk.String())
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
// splitIntoSentences 按句子分割文本(中文。!?和英文标点)
|
||||
func splitIntoSentences(text string) []string {
|
||||
var sentences []string
|
||||
runes := []rune(text)
|
||||
var current strings.Builder
|
||||
|
||||
for i := 0; i < len(runes); i++ {
|
||||
current.WriteRune(runes[i])
|
||||
|
||||
// 检查句子结束标志
|
||||
if runes[i] == '。' || runes[i] == '!' || runes[i] == '?' ||
|
||||
runes[i] == '!' || runes[i] == '?' ||
|
||||
(runes[i] == '\n' && i+1 < len(runes) && runes[i+1] != '\n') {
|
||||
sentences = append(sentences, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
// 剩余内容
|
||||
if current.Len() > 0 {
|
||||
remaining := strings.TrimSpace(current.String())
|
||||
if remaining != "" {
|
||||
sentences = append(sentences, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
return sentences
|
||||
}
|
||||
|
||||
// estimateTokenCount 估算 token 数量(中文按每个字符1.2个token,英文按每4个字符1个token)
|
||||
func estimateTokenCount(text string) int {
|
||||
runes := []rune(text)
|
||||
total := 0
|
||||
for _, r := range runes {
|
||||
if r >= 0x4e00 && r <= 0x9fff {
|
||||
// 中文字符,约1.2个token
|
||||
total += 1
|
||||
}
|
||||
}
|
||||
// 非中文字符粗略估算:字符数/4
|
||||
nonChinese := len(runes) - total
|
||||
total = int(float64(total)*1.2) + nonChinese/4
|
||||
if total < 1 {
|
||||
total = 1
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// tokenizeQuery 将查询字符串分词(简单按空格和标点分割)
|
||||
func tokenizeQuery(query string) []string {
|
||||
// 按空格、中文标点、英文标点分割
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 先用空格分割
|
||||
parts := strings.Fields(query)
|
||||
var tokens []string
|
||||
for _, part := range parts {
|
||||
part = strings.Trim(part, "。!?!?,.;::;、()()[]{}《》\"'")
|
||||
if part != "" {
|
||||
tokens = append(tokens, part)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// GenerateUUID 使用 crypto/rand 生成 UUID v4 格式的字符串(导出供其他包使用)
|
||||
func GenerateUUID() string {
|
||||
return generateUUIDv4()
|
||||
}
|
||||
|
||||
// generateUUIDv4 使用 crypto/rand 生成 UUID v4 格式的字符串
|
||||
func generateUUIDv4() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// 降级方案:基于时间戳 + 简单随机
|
||||
ts := time.Now().UnixNano()
|
||||
for i := 0; i < 16; i++ {
|
||||
b[i] = byte((ts >> (i * 4)) & 0xFF)
|
||||
}
|
||||
}
|
||||
// 设置 UUID v4 版本位 (version = 4)
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
// 设置 UUID variant 位 (variant = 10xx)
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reminder 提醒模型
|
||||
type Reminder struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RemindAt time.Time `json:"remind_at"`
|
||||
Status string `json:"status"` // pending, completed, cancelled
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
RepeatType string `json:"repeat_type"` // none, daily, weekly, monthly
|
||||
SessionID string `json:"session_id"`
|
||||
Notified bool `json:"notified"`
|
||||
}
|
||||
|
||||
// ReminderStore 提醒持久化存储
|
||||
type ReminderStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewReminderStore 使用已有数据库连接初始化提醒存储并自动建表
|
||||
func NewReminderStore(db *sql.DB) (*ReminderStore, error) {
|
||||
store := &ReminderStore{db: db}
|
||||
|
||||
if err := store.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("提醒表迁移失败: %w", err)
|
||||
}
|
||||
|
||||
log.Println("[ReminderStore] 提醒持久化存储已初始化")
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// migrate 自动创建提醒表结构
|
||||
func (s *ReminderStore) migrate() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS reminders (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
remind_at TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
repeat_type VARCHAR(20) DEFAULT '',
|
||||
session_id VARCHAR(36) DEFAULT '',
|
||||
notified BOOLEAN DEFAULT FALSE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reminders_user_id ON reminders(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reminders_remind_at ON reminders(remind_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(remind_at, status, notified)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return fmt.Errorf("迁移SQL执行失败: %w\nSQL: %s", err, q)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateReminder 创建新提醒
|
||||
func (s *ReminderStore) CreateReminder(r *Reminder) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO reminders (id, user_id, title, description, remind_at, status, repeat_type, session_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
r.ID, r.UserID, r.Title, r.Description, r.RemindAt, r.Status, r.RepeatType, r.SessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建提醒失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRemindersByUser 获取用户的提醒列表(可按状态筛选,按 remind_at 升序)
|
||||
func (s *ReminderStore) GetRemindersByUser(userID, status string, limit, offset int) ([]Reminder, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if status != "" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified
|
||||
FROM reminders WHERE user_id = $1 AND status = $2
|
||||
ORDER BY remind_at ASC LIMIT $3 OFFSET $4`,
|
||||
userID, status, limit, offset,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified
|
||||
FROM reminders WHERE user_id = $1
|
||||
ORDER BY remind_at ASC LIMIT $2 OFFSET $3`,
|
||||
userID, limit, offset,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询提醒列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reminders []Reminder
|
||||
for rows.Next() {
|
||||
var r Reminder
|
||||
if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt,
|
||||
&r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified); err != nil {
|
||||
return nil, fmt.Errorf("扫描提醒行失败: %w", err)
|
||||
}
|
||||
reminders = append(reminders, r)
|
||||
}
|
||||
|
||||
if reminders == nil {
|
||||
reminders = []Reminder{}
|
||||
}
|
||||
return reminders, rows.Err()
|
||||
}
|
||||
|
||||
// GetDueReminders 获取所有到期且未通知的提醒
|
||||
func (s *ReminderStore) GetDueReminders() ([]Reminder, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, title, description, remind_at, status, created_at, completed_at, repeat_type, session_id, notified
|
||||
FROM reminders
|
||||
WHERE remind_at <= NOW() AND status = 'pending' AND notified = FALSE
|
||||
ORDER BY remind_at ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询到期提醒失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reminders []Reminder
|
||||
for rows.Next() {
|
||||
var r Reminder
|
||||
if err := rows.Scan(&r.ID, &r.UserID, &r.Title, &r.Description, &r.RemindAt,
|
||||
&r.Status, &r.CreatedAt, &r.CompletedAt, &r.RepeatType, &r.SessionID, &r.Notified); err != nil {
|
||||
return nil, fmt.Errorf("扫描到期提醒行失败: %w", err)
|
||||
}
|
||||
reminders = append(reminders, r)
|
||||
}
|
||||
|
||||
if reminders == nil {
|
||||
reminders = []Reminder{}
|
||||
}
|
||||
return reminders, rows.Err()
|
||||
}
|
||||
|
||||
// MarkNotified 标记提醒为已通知
|
||||
func (s *ReminderStore) MarkNotified(id string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE reminders SET notified = TRUE WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("标记提醒已通知失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateReminder 更新提醒字段
|
||||
func (s *ReminderStore) UpdateReminder(id string, r *Reminder) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE reminders SET title = $1, description = $2, remind_at = $3, status = $4,
|
||||
completed_at = $5, repeat_type = $6, session_id = $7, notified = $8
|
||||
WHERE id = $9`,
|
||||
r.Title, r.Description, r.RemindAt, r.Status, r.CompletedAt, r.RepeatType, r.SessionID, r.Notified, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新提醒失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteReminder 删除提醒
|
||||
func (s *ReminderStore) DeleteReminder(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM reminders WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除提醒失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -253,6 +253,70 @@ func (s *SessionStore) ClearSessionMessages(sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchResult 搜索结果
|
||||
type SearchResult struct {
|
||||
MessageID int `json:"message_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
SessionTitle string `json:"session_title"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SearchMessages 全文搜索消息 (使用 ILIKE 进行模糊匹配)
|
||||
// 返回搜索结果列表、总数和可能的错误
|
||||
func (s *SessionStore) SearchMessages(userID, query string, limit, offset int) ([]SearchResult, int, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
// 获取匹配总数
|
||||
var total int
|
||||
countSQL := `SELECT COUNT(*) FROM messages m
|
||||
JOIN sessions s ON m.session_id = s.id
|
||||
WHERE s.user_id = $1 AND m.content ILIKE '%' || $2 || '%'`
|
||||
if err := s.db.QueryRow(countSQL, userID, query).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("搜索计数失败: %w", err)
|
||||
}
|
||||
|
||||
// 分页查询,关联 sessions 获取会话标题
|
||||
rows, err := s.db.Query(
|
||||
`SELECT m.id, m.session_id, COALESCE(s.title, '') AS session_title, m.role, m.content, m.created_at
|
||||
FROM messages m
|
||||
JOIN sessions s ON m.session_id = s.id
|
||||
WHERE s.user_id = $1 AND m.content ILIKE '%' || $2 || '%'
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3 OFFSET $4`,
|
||||
userID, query, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("搜索消息失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []SearchResult
|
||||
for rows.Next() {
|
||||
var r SearchResult
|
||||
if err := rows.Scan(&r.MessageID, &r.SessionID, &r.SessionTitle, &r.Role, &r.Content, &r.CreatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("扫描搜索结果行失败: %w", err)
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
if results == nil {
|
||||
results = []SearchResult{}
|
||||
}
|
||||
return results, total, rows.Err()
|
||||
}
|
||||
|
||||
// DB 返回底层数据库连接,供其他 store 复用
|
||||
func (s *SessionStore) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func (s *SessionStore) Close() error {
|
||||
if s.db != nil {
|
||||
|
||||
@@ -32,10 +32,11 @@ type SessionMessage struct {
|
||||
|
||||
// Message 完整对话消息(用于缓存)
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Attachments []MessageAttachment `json:"attachments,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
const maxRecentMessages = 20
|
||||
|
||||
@@ -1,32 +1,56 @@
|
||||
package ws
|
||||
|
||||
// MessageAttachment 消息附件 (图片等)
|
||||
type MessageAttachment struct {
|
||||
Type string `json:"type"` // image
|
||||
URL string `json:"url"` // 图片 URL 或 data URL
|
||||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Size int64 `json:"size,omitempty"` // 文件大小 bytes
|
||||
Description string `json:"description,omitempty"` // AI 对图片的描述
|
||||
}
|
||||
|
||||
// 客户端 → 服务端消息
|
||||
type ClientMessage struct {
|
||||
Type string `json:"type"` // message | voice_input | ping | history
|
||||
SessionID string `json:"session_id"`
|
||||
Mode string `json:"mode"` // text | voice_msg | voice_assistant
|
||||
Content string `json:"content"`
|
||||
AudioData string `json:"audio_data,omitempty"` // base64
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Type string `json:"type"` // message | voice_input | ping | history
|
||||
SessionID string `json:"session_id"`
|
||||
Mode string `json:"mode"` // text | voice_msg | voice_assistant
|
||||
Content string `json:"content"`
|
||||
AudioData string `json:"audio_data,omitempty"` // base64
|
||||
Attachments []MessageAttachment `json:"attachments,omitempty"` // 图片等附件
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// 服务端 → 客户端消息
|
||||
type ServerMessage struct {
|
||||
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking
|
||||
MessageID string `json:"message_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
|
||||
Role string `json:"role,omitempty"` // stream 消息的角色
|
||||
SessionID string `json:"session_id,omitempty"` // 会话 ID
|
||||
Segments []VoiceSegment `json:"segments,omitempty"` // 断句数组
|
||||
FullAudioURL string `json:"full_audio_url,omitempty"`
|
||||
ResponseMode string `json:"response_mode"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Messages []Message `json:"messages,omitempty"` // 历史消息列表
|
||||
Devices []IotDeviceInfo `json:"devices,omitempty"` // IoT 设备状态
|
||||
ThinkingStatus string `json:"thinking_status,omitempty"` // 后台思考状态
|
||||
Type string `json:"type"` // response | segment | audio | error | device_update | pong | history_response | stream_chunk | stream_end | background_thinking | notification
|
||||
MessageID string `json:"message_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Content string `json:"content,omitempty"` // stream_chunk 的增量文本
|
||||
Role string `json:"role,omitempty"` // stream 消息的角色
|
||||
SessionID string `json:"session_id,omitempty"` // 会话 ID
|
||||
Segments []VoiceSegment `json:"segments,omitempty"` // 断句数组
|
||||
FullAudioURL string `json:"full_audio_url,omitempty"`
|
||||
ResponseMode string `json:"response_mode"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Messages []Message `json:"messages,omitempty"` // 历史消息列表
|
||||
Devices []IotDeviceInfo `json:"devices,omitempty"` // IoT 设备状态
|
||||
ThinkingStatus string `json:"thinking_status,omitempty"` // 后台思考状态
|
||||
Notification *NotificationInfo `json:"notification,omitempty"` // 通知推送
|
||||
}
|
||||
|
||||
// NotificationInfo 通知推送信息
|
||||
type NotificationInfo struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // info | warning | success | thinking | reminder
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// IotDeviceInfo IoT 设备信息(用于 WebSocket 推送)
|
||||
|
||||
@@ -6,4 +6,5 @@ use (
|
||||
./iot-debug-service
|
||||
./memory-service
|
||||
./tool-engine
|
||||
./voice-service
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/yourname/cyrene-ai/voice-service/internal/config"
|
||||
"github.com/yourname/cyrene-ai/voice-service/internal/handler"
|
||||
"github.com/yourname/cyrene-ai/voice-service/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Println("🎤 Voice-Service (STT + TTS) 启动中...")
|
||||
|
||||
// 加载配置
|
||||
cfg := config.Load()
|
||||
|
||||
log.Printf("配置: 端口=%s, WhisperBinary=%s, WhisperModel=%s, Language=%s",
|
||||
cfg.Port, cfg.WhisperBinary, cfg.WhisperModel, cfg.WhisperLanguage)
|
||||
|
||||
// 初始化 STT 服务
|
||||
sttSvc := service.NewSTTService(cfg)
|
||||
|
||||
// 检查 whisper 引擎是否可用
|
||||
if !sttSvc.IsAvailable() {
|
||||
log.Printf("⚠️ Whisper 引擎未安装 (%s),STT 功能不可用", cfg.WhisperBinary)
|
||||
log.Printf(" 请运行: bash scripts/setup-whisper.sh")
|
||||
} else {
|
||||
log.Println("✅ Whisper 引擎已就绪")
|
||||
}
|
||||
|
||||
// 初始化 TTS 服务
|
||||
ttsSvc := service.NewTTSService()
|
||||
|
||||
if !ttsSvc.IsAvailable() {
|
||||
log.Println("⚠️ TTS 引擎不可用 (请安装: pip install edge-tts)")
|
||||
} else {
|
||||
ttsStatus := ttsSvc.GetEngineStatus()
|
||||
log.Printf("✅ TTS 引擎已就绪 (引擎: %s)", ttsStatus["engine"])
|
||||
}
|
||||
|
||||
// 初始化 HTTP 处理器
|
||||
sttHandler := handler.NewSTTHandler(sttSvc, cfg)
|
||||
sttHandler.SetTTSService(ttsSvc)
|
||||
ttsHandler := handler.NewTTSHandler(ttsSvc)
|
||||
|
||||
// 注册路由
|
||||
mux := http.NewServeMux()
|
||||
sttHandler.RegisterRoutes(mux)
|
||||
ttsHandler.RegisterRoutes(mux)
|
||||
|
||||
// 启动 HTTP 服务
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("🚀 Voice-Service 已启动在端口 %s", cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("服务启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 优雅关闭
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("正在关闭 Voice-Service...")
|
||||
srv.Close()
|
||||
log.Println("Voice-Service 已关闭")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/yourname/cyrene-ai/voice-service
|
||||
|
||||
go 1.26.2
|
||||
@@ -0,0 +1,30 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
// Config STT 语音识别服务配置
|
||||
type Config struct {
|
||||
Port string
|
||||
WhisperBinary string
|
||||
WhisperModel string
|
||||
WhisperLanguage string
|
||||
MaxAudioSize int64 // 字节
|
||||
}
|
||||
|
||||
// Load 从环境变量加载配置
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8093"),
|
||||
WhisperBinary: getEnv("WHISPER_BINARY", "./whisper.cpp/main"),
|
||||
WhisperModel: getEnv("WHISPER_MODEL", "./whisper.cpp/models/ggml-small.bin"),
|
||||
WhisperLanguage: getEnv("WHISPER_LANGUAGE", "zh"),
|
||||
MaxAudioSize: 10 * 1024 * 1024, // 10MB
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yourname/cyrene-ai/voice-service/internal/config"
|
||||
"github.com/yourname/cyrene-ai/voice-service/internal/service"
|
||||
)
|
||||
|
||||
// STTHandler HTTP API 处理器
|
||||
type STTHandler struct {
|
||||
svc *service.STTService
|
||||
ttsSvc *service.TTSService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewSTTHandler 创建 STT 处理器(可选传入 TTSService 用于组合状态)
|
||||
func NewSTTHandler(svc *service.STTService, cfg *config.Config) *STTHandler {
|
||||
return &STTHandler{svc: svc, cfg: cfg}
|
||||
}
|
||||
|
||||
// SetTTSService 设置 TTS 服务引用,用于组合状态端点
|
||||
func (h *STTHandler) SetTTSService(ttsSvc *service.TTSService) {
|
||||
h.ttsSvc = ttsSvc
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册所有路由到 mux
|
||||
func (h *STTHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/transcribe", h.handleTranscribe)
|
||||
mux.HandleFunc("/api/v1/health", h.handleHealth)
|
||||
mux.HandleFunc("/api/v1/status", h.handleStatus)
|
||||
}
|
||||
|
||||
// handleTranscribe POST /api/v1/transcribe
|
||||
// 接受 multipart/form-data,字段 audio (文件) 和 language (可选)
|
||||
func (h *STTHandler) handleTranscribe(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
// 限制上传大小
|
||||
r.Body = http.MaxBytesReader(w, r.Body, h.cfg.MaxAudioSize)
|
||||
|
||||
if err := r.ParseMultipartForm(h.cfg.MaxAudioSize); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "文件过大或解析失败,最大支持 10MB")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取上传的文件
|
||||
file, header, err := r.FormFile("audio")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "缺少 audio 文件字段")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 读取文件内容
|
||||
audioData, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "读取音频文件失败")
|
||||
return
|
||||
}
|
||||
|
||||
if len(audioData) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "音频文件为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取语言参数 (可选)
|
||||
language := r.FormValue("language")
|
||||
|
||||
// 推断音频格式
|
||||
format := inferFormat(header.Filename)
|
||||
if !isSupportedFormat(format) {
|
||||
writeError(w, http.StatusBadRequest, "不支持的音频格式: "+format+",支持的格式: WAV, MP3, OGG, FLAC, M4A")
|
||||
return
|
||||
}
|
||||
|
||||
// 执行转录
|
||||
startTime := time.Now()
|
||||
text, err := h.svc.Transcribe(audioData, format, language)
|
||||
durationMs := time.Since(startTime).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[stt-handler] 转录失败: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
actualLang := language
|
||||
if actualLang == "" {
|
||||
actualLang = h.cfg.WhisperLanguage
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"text": text,
|
||||
"language": actualLang,
|
||||
"duration_ms": durationMs,
|
||||
})
|
||||
}
|
||||
|
||||
// handleHealth GET /api/v1/health
|
||||
func (h *STTHandler) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
sttStatus := h.svc.GetStatus()
|
||||
healthStatus := "ok"
|
||||
if !sttStatus["available"].(bool) {
|
||||
healthStatus = "degraded"
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"status": healthStatus,
|
||||
"service": "voice-service",
|
||||
"stt": sttStatus,
|
||||
}
|
||||
|
||||
// 如果有 TTS 服务,也包含 TTS 状态
|
||||
if h.ttsSvc != nil {
|
||||
resp["tts"] = h.ttsSvc.GetEngineStatus()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleStatus GET /api/v1/status
|
||||
func (h *STTHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"service": "voice-service",
|
||||
"stt": h.svc.GetStatus(),
|
||||
}
|
||||
|
||||
// 如果有 TTS 服务,也包含 TTS 状态
|
||||
if h.ttsSvc != nil {
|
||||
resp["tts"] = h.ttsSvc.GetEngineStatus()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// --- 辅助函数 ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]interface{}{
|
||||
"error": message,
|
||||
})
|
||||
}
|
||||
|
||||
// inferFormat 根据文件名推断音频格式
|
||||
func inferFormat(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
switch ext {
|
||||
case ".wav", ".wave":
|
||||
return "wav"
|
||||
case ".mp3", ".mpeg":
|
||||
return "mp3"
|
||||
case ".ogg", ".opus":
|
||||
return "ogg"
|
||||
case ".flac":
|
||||
return "flac"
|
||||
case ".m4a", ".mp4", ".aac":
|
||||
return "m4a"
|
||||
default:
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
// isSupportedFormat 检查是否支持的音频格式
|
||||
func isSupportedFormat(format string) bool {
|
||||
switch format {
|
||||
case "wav", "mp3", "ogg", "flac", "m4a":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/yourname/cyrene-ai/voice-service/internal/service"
|
||||
)
|
||||
|
||||
// TTSHandler TTS HTTP API 处理器
|
||||
type TTSHandler struct {
|
||||
svc *service.TTSService
|
||||
}
|
||||
|
||||
// NewTTSHandler 创建 TTS 处理器
|
||||
func NewTTSHandler(svc *service.TTSService) *TTSHandler {
|
||||
return &TTSHandler{svc: svc}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册 TTS 路由
|
||||
func (h *TTSHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/tts/synthesize", h.handleSynthesize)
|
||||
mux.HandleFunc("/api/v1/tts/voices", h.handleVoices)
|
||||
mux.HandleFunc("/api/v1/tts/status", h.handleStatus)
|
||||
}
|
||||
|
||||
// TTSSynthesizeRequest TTS 合成请求体
|
||||
type TTSSynthesizeRequest struct {
|
||||
Text string `json:"text"`
|
||||
Voice string `json:"voice"`
|
||||
Rate string `json:"rate"`
|
||||
}
|
||||
|
||||
// handleSynthesize POST /api/v1/tts/synthesize
|
||||
func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 JSON 请求体
|
||||
var req TTSSynthesizeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "请求体解析失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Text == "" {
|
||||
writeError(w, http.StatusBadRequest, "text 字段不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 TTS 引擎是否可用
|
||||
if !h.svc.IsAvailable() {
|
||||
log.Printf("[tts-handler] TTS 引擎不可用")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "TTS 引擎不可用,请安装 edge-tts (pip install edge-tts) 或 espeak-ng",
|
||||
"code": "TTS_UNAVAILABLE",
|
||||
"install": "pip install edge-tts",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用合成
|
||||
audioData, format, err := h.svc.Synthesize(req.Text, req.Voice, req.Rate)
|
||||
if err != nil {
|
||||
log.Printf("[tts-handler] TTS 合成失败: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "TTS 合成失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回音频流
|
||||
contentType := "audio/mpeg"
|
||||
if format == "wav" {
|
||||
contentType = "audio/wav"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", "inline; filename=synthesized."+format)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(audioData)
|
||||
}
|
||||
|
||||
// handleVoices GET /api/v1/tts/voices
|
||||
func (h *TTSHandler) handleVoices(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
voices := h.svc.GetVoices()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"voices": voices,
|
||||
"count": len(voices),
|
||||
})
|
||||
}
|
||||
|
||||
// handleStatus GET /api/v1/tts/status
|
||||
func (h *TTSHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
status := h.svc.GetEngineStatus()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"service": "voice-service",
|
||||
"tts": status,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yourname/cyrene-ai/voice-service/internal/config"
|
||||
)
|
||||
|
||||
// SupportedLanguages STT 支持的语言列表
|
||||
var SupportedLanguages = []string{"zh", "en", "ja", "ko", "auto"}
|
||||
|
||||
// STTService 语音转文字服务
|
||||
type STTService struct {
|
||||
whisperBinary string
|
||||
whisperModel string
|
||||
language string
|
||||
}
|
||||
|
||||
// NewSTTService 创建 STT 服务
|
||||
func NewSTTService(cfg *config.Config) *STTService {
|
||||
return &STTService{
|
||||
whisperBinary: cfg.WhisperBinary,
|
||||
whisperModel: cfg.WhisperModel,
|
||||
language: cfg.WhisperLanguage,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAvailable 检查 whisper binary 是否存在
|
||||
func (s *STTService) IsAvailable() bool {
|
||||
_, err := os.Stat(s.whisperBinary)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Transcribe 将音频数据转录为文字
|
||||
// audioData: 音频文件的二进制数据
|
||||
// format: 音频格式 (wav, mp3, ogg, flac, m4a)
|
||||
// language: 转录语言 (zh, en, ja, ko, auto),为空则使用默认语言
|
||||
func (s *STTService) Transcribe(audioData []byte, format string, language string) (string, error) {
|
||||
if !s.IsAvailable() {
|
||||
return "", fmt.Errorf("STT 引擎未安装,请运行 scripts/setup-whisper.sh")
|
||||
}
|
||||
|
||||
// 如果未指定语言,使用默认语言
|
||||
if language == "" {
|
||||
language = s.language
|
||||
}
|
||||
|
||||
// 验证语言是否支持
|
||||
if !isSupportedLanguage(language) {
|
||||
return "", fmt.Errorf("不支持的语言: %s,支持的语言: %s", language, strings.Join(SupportedLanguages, ", "))
|
||||
}
|
||||
|
||||
// 将音频数据写入临时文件
|
||||
ext := normalizeExt(format)
|
||||
tmpFile, err := os.CreateTemp("/tmp", "cyrene-stt-*"+ext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmpFile.Write(audioData); err != nil {
|
||||
tmpFile.Close()
|
||||
return "", fmt.Errorf("写入临时文件失败: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// 如果不是 WAV 格式,尝试用 ffmpeg 转换
|
||||
inputPath := tmpPath
|
||||
if format != "wav" && format != "" {
|
||||
convertedPath := tmpPath + ".wav"
|
||||
if err := convertToWav(tmpPath, convertedPath); err == nil {
|
||||
defer os.Remove(convertedPath)
|
||||
inputPath = convertedPath
|
||||
}
|
||||
// 转换失败则仍使用原始文件(whisper.cpp 也支持其他格式)
|
||||
}
|
||||
|
||||
// 调用 whisper.cpp
|
||||
outputTxt := inputPath + ".txt"
|
||||
|
||||
cmd := exec.Command(s.whisperBinary,
|
||||
"-m", s.whisperModel,
|
||||
"-l", language,
|
||||
"-f", inputPath,
|
||||
"-otxt",
|
||||
"-of", strings.TrimSuffix(inputPath, filepath.Ext(inputPath)),
|
||||
)
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
os.Remove(outputTxt)
|
||||
return "", fmt.Errorf("whisper 转录失败: %w", err)
|
||||
}
|
||||
|
||||
// 读取输出文本
|
||||
defer os.Remove(outputTxt)
|
||||
|
||||
txtData, err := os.ReadFile(outputTxt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取转录结果失败: %w", err)
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(string(txtData))
|
||||
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// GetStatus 返回服务状态
|
||||
func (s *STTService) GetStatus() map[string]interface{} {
|
||||
binaryAvailable := s.IsAvailable()
|
||||
modelExists := false
|
||||
if _, err := os.Stat(s.whisperModel); err == nil {
|
||||
modelExists = true
|
||||
}
|
||||
|
||||
modelName := filepath.Base(s.whisperModel)
|
||||
|
||||
return map[string]interface{}{
|
||||
"available": binaryAvailable && modelExists,
|
||||
"binary_available": binaryAvailable,
|
||||
"model_loaded": modelExists,
|
||||
"binary_path": s.whisperBinary,
|
||||
"model_path": s.whisperModel,
|
||||
"model_name": modelName,
|
||||
"default_language": s.language,
|
||||
"supported_languages": SupportedLanguages,
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeExt 规范化文件扩展名
|
||||
func normalizeExt(format string) string {
|
||||
switch strings.ToLower(format) {
|
||||
case "wav":
|
||||
return ".wav"
|
||||
case "mp3", "mpeg":
|
||||
return ".mp3"
|
||||
case "ogg", "opus":
|
||||
return ".ogg"
|
||||
case "flac":
|
||||
return ".flac"
|
||||
case "m4a", "mp4", "aac":
|
||||
return ".m4a"
|
||||
default:
|
||||
return ".wav"
|
||||
}
|
||||
}
|
||||
|
||||
// isSupportedLanguage 检查语言是否支持
|
||||
func isSupportedLanguage(lang string) bool {
|
||||
for _, l := range SupportedLanguages {
|
||||
if l == lang {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// convertToWav 使用 ffmpeg 将音频转换为 WAV 格式
|
||||
func convertToWav(inputPath, outputPath string) error {
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", inputPath,
|
||||
"-ar", "16000",
|
||||
"-ac", "1",
|
||||
"-c:a", "pcm_s16le",
|
||||
outputPath,
|
||||
"-y",
|
||||
)
|
||||
cmd.Stderr = nil
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TTSVoice 表示一个可用的 TTS 语音
|
||||
type TTSVoice struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Gender string `json:"gender"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
// BuiltinVoices 内置的 edge-tts 中文语音列表
|
||||
var BuiltinVoices = []TTSVoice{
|
||||
{Name: "zh-CN-XiaoxiaoNeural", DisplayName: "晓晓 (女声)", Gender: "Female", Locale: "zh-CN"},
|
||||
{Name: "zh-CN-YunxiNeural", DisplayName: "云希 (男声)", Gender: "Male", Locale: "zh-CN"},
|
||||
{Name: "zh-CN-XiaoyiNeural", DisplayName: "晓伊 (女声)", Gender: "Female", Locale: "zh-CN"},
|
||||
}
|
||||
|
||||
// TTSService 文字转语音服务
|
||||
type TTSService struct{}
|
||||
|
||||
// NewTTSService 创建 TTS 服务
|
||||
func NewTTSService() *TTSService {
|
||||
return &TTSService{}
|
||||
}
|
||||
|
||||
// IsAvailable 检查 TTS 引擎是否可用
|
||||
// 优先级: edge-tts > espeak-ng > 纯 Go fallback
|
||||
func (s *TTSService) IsAvailable() bool {
|
||||
return s.edgeTTSAvailable() || s.espeakAvailable()
|
||||
}
|
||||
|
||||
// edgeTTSAvailable 检查 edge-tts 是否可用
|
||||
func (s *TTSService) edgeTTSAvailable() bool {
|
||||
_, err := exec.LookPath("edge-tts")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// espeakAvailable 检查 espeak-ng 是否可用
|
||||
func (s *TTSService) espeakAvailable() bool {
|
||||
_, err := exec.LookPath("espeak-ng")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Synthesize 将文字合成为音频
|
||||
// text: 要合成的文字
|
||||
// voice: 语音名称 (zh-CN-XiaoxiaoNeural 等)
|
||||
// rate: 语速调整 ("+0%", "+20%", "-20%" 等)
|
||||
// 返回: 音频数据, 音频格式 (mp3/wav), 错误
|
||||
func (s *TTSService) Synthesize(text string, voice string, rate string) ([]byte, string, error) {
|
||||
if text == "" {
|
||||
return nil, "", fmt.Errorf("文字内容为空")
|
||||
}
|
||||
|
||||
// 方案 A: edge-tts (推荐)
|
||||
if s.edgeTTSAvailable() {
|
||||
return s.synthesizeEdgeTTS(text, voice, rate)
|
||||
}
|
||||
|
||||
// 方案 B: espeak-ng
|
||||
if s.espeakAvailable() {
|
||||
return s.synthesizeEspeak(text, voice)
|
||||
}
|
||||
|
||||
// 方案 C: 纯 Go fallback
|
||||
return s.synthesizeFallback()
|
||||
}
|
||||
|
||||
// synthesizeEdgeTTS 使用 edge-tts 合成语音
|
||||
func (s *TTSService) synthesizeEdgeTTS(text string, voice string, rate string) ([]byte, string, error) {
|
||||
if voice == "" {
|
||||
voice = "zh-CN-XiaoxiaoNeural"
|
||||
}
|
||||
if rate == "" {
|
||||
rate = "+0%"
|
||||
}
|
||||
|
||||
// 写入文本到临时文件
|
||||
tmpText, err := os.CreateTemp("/tmp", "cyrene-tts-text-*.txt")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("创建临时文本文件失败: %w", err)
|
||||
}
|
||||
tmpTextPath := tmpText.Name()
|
||||
defer os.Remove(tmpTextPath)
|
||||
|
||||
if _, err := tmpText.WriteString(text); err != nil {
|
||||
tmpText.Close()
|
||||
return nil, "", fmt.Errorf("写入临时文本失败: %w", err)
|
||||
}
|
||||
tmpText.Close()
|
||||
|
||||
// 输出音频文件
|
||||
tmpOutput, err := os.CreateTemp("/tmp", "cyrene-tts-output-*.mp3")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("创建临时输出文件失败: %w", err)
|
||||
}
|
||||
tmpOutputPath := tmpOutput.Name()
|
||||
tmpOutput.Close()
|
||||
defer os.Remove(tmpOutputPath)
|
||||
|
||||
// 构建 edge-tts 命令
|
||||
cmd := exec.Command("edge-tts",
|
||||
"--voice", voice,
|
||||
"--rate="+rate,
|
||||
"--text", text,
|
||||
"--write-media", tmpOutputPath,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("edge-tts 合成失败: %w\n输出: %s", err, string(output))
|
||||
}
|
||||
|
||||
// 读取生成的音频
|
||||
audioData, err := os.ReadFile(tmpOutputPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("读取合成的音频失败: %w", err)
|
||||
}
|
||||
|
||||
if len(audioData) == 0 {
|
||||
return nil, "", fmt.Errorf("edge-tts 生成的音频为空")
|
||||
}
|
||||
|
||||
return audioData, "mp3", nil
|
||||
}
|
||||
|
||||
// synthesizeEspeak 使用 espeak-ng 合成语音
|
||||
func (s *TTSService) synthesizeEspeak(text string, voice string) ([]byte, string, error) {
|
||||
if voice == "" {
|
||||
voice = "zh"
|
||||
}
|
||||
|
||||
// 输出 WAV 文件
|
||||
tmpOutput, err := os.CreateTemp("/tmp", "cyrene-tts-espeak-*.wav")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("创建临时输出文件失败: %w", err)
|
||||
}
|
||||
tmpOutputPath := tmpOutput.Name()
|
||||
tmpOutput.Close()
|
||||
defer os.Remove(tmpOutputPath)
|
||||
|
||||
cmd := exec.Command("espeak-ng",
|
||||
"-v", voice,
|
||||
"-w", tmpOutputPath,
|
||||
text,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("espeak-ng 合成失败: %w\n输出: %s", err, string(output))
|
||||
}
|
||||
|
||||
audioData, err := os.ReadFile(tmpOutputPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("读取合成的音频失败: %w", err)
|
||||
}
|
||||
|
||||
if len(audioData) == 0 {
|
||||
return nil, "", fmt.Errorf("espeak-ng 生成的音频为空")
|
||||
}
|
||||
|
||||
return audioData, "wav", nil
|
||||
}
|
||||
|
||||
// synthesizeFallback 生成静默 WAV 作为降级方案
|
||||
// 生成 1 秒 16kHz 16-bit mono 静默 PCM WAV
|
||||
func (s *TTSService) synthesizeFallback() ([]byte, string, error) {
|
||||
// 1 秒 @ 16kHz mono 16-bit = 32000 字节采样数据
|
||||
sampleRate := 16000
|
||||
numChannels := 1
|
||||
bitsPerSample := 16
|
||||
durationSec := 1
|
||||
|
||||
dataSize := sampleRate * numChannels * (bitsPerSample / 8) * durationSec
|
||||
// WAV header 44 bytes + data
|
||||
wav := make([]byte, 44+dataSize)
|
||||
|
||||
// RIFF header
|
||||
copy(wav[0:4], "RIFF")
|
||||
writeUint32LE(wav[4:8], uint32(36+dataSize))
|
||||
copy(wav[8:12], "WAVE")
|
||||
|
||||
// fmt chunk
|
||||
copy(wav[12:16], "fmt ")
|
||||
writeUint32LE(wav[16:20], 16) // chunk size
|
||||
writeUint16LE(wav[20:22], 1) // PCM
|
||||
writeUint16LE(wav[22:24], uint16(numChannels)) // channels
|
||||
writeUint32LE(wav[24:28], uint32(sampleRate)) // sample rate
|
||||
writeUint32LE(wav[28:32], uint32(sampleRate*numChannels*bitsPerSample/8)) // byte rate
|
||||
writeUint16LE(wav[32:34], uint16(numChannels*bitsPerSample/8)) // block align
|
||||
writeUint16LE(wav[34:36], uint16(bitsPerSample)) // bits per sample
|
||||
|
||||
// data chunk
|
||||
copy(wav[36:40], "data")
|
||||
writeUint32LE(wav[40:44], uint32(dataSize))
|
||||
// 采样数据全是 0 (静默)
|
||||
|
||||
return wav, "wav", nil
|
||||
}
|
||||
|
||||
func writeUint16LE(buf []byte, v uint16) {
|
||||
buf[0] = byte(v)
|
||||
buf[1] = byte(v >> 8)
|
||||
}
|
||||
|
||||
func writeUint32LE(buf []byte, v uint32) {
|
||||
buf[0] = byte(v)
|
||||
buf[1] = byte(v >> 8)
|
||||
buf[2] = byte(v >> 16)
|
||||
buf[3] = byte(v >> 24)
|
||||
}
|
||||
|
||||
// GetVoices 返回可用语音列表
|
||||
func (s *TTSService) GetVoices() []TTSVoice {
|
||||
// 检查 edge-tts 是否可用,尝试获取完整语音列表
|
||||
if s.edgeTTSAvailable() {
|
||||
cmd := exec.Command("edge-tts", "--list-voices")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
voices := s.parseEdgeTTSVoices(string(output))
|
||||
if len(voices) > 0 {
|
||||
return voices
|
||||
}
|
||||
}
|
||||
}
|
||||
return BuiltinVoices
|
||||
}
|
||||
|
||||
// parseEdgeTTSVoices 解析 edge-tts --list-voices 输出
|
||||
// 简单解析:查找包含 "zh-CN" 的语音
|
||||
func (s *TTSService) parseEdgeTTSVoices(output string) []TTSVoice {
|
||||
var voices []TTSVoice
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.Contains(line, "zh-CN") {
|
||||
continue
|
||||
}
|
||||
|
||||
voice := TTSVoice{
|
||||
Name: "",
|
||||
Gender: "Unknown",
|
||||
Locale: "zh-CN",
|
||||
}
|
||||
|
||||
// 简单解析 "Name: zh-CN-XiaoxiaoNeural" 和 "Gender: Female" 格式
|
||||
for _, field := range strings.Split(line, ",") {
|
||||
field = strings.TrimSpace(field)
|
||||
if strings.HasPrefix(field, "Name:") {
|
||||
voice.Name = strings.TrimSpace(strings.TrimPrefix(field, "Name:"))
|
||||
}
|
||||
if strings.HasPrefix(field, "Gender:") {
|
||||
voice.Gender = strings.TrimSpace(strings.TrimPrefix(field, "Gender:"))
|
||||
}
|
||||
}
|
||||
|
||||
if voice.Name != "" {
|
||||
voice.DisplayName = voice.Name
|
||||
voices = append(voices, voice)
|
||||
}
|
||||
}
|
||||
|
||||
if len(voices) == 0 {
|
||||
return nil
|
||||
}
|
||||
return voices
|
||||
}
|
||||
|
||||
// GetEngineStatus 返回 TTS 引擎状态
|
||||
func (s *TTSService) GetEngineStatus() map[string]interface{} {
|
||||
status := map[string]interface{}{
|
||||
"available": s.IsAvailable(),
|
||||
"edge_tts": s.edgeTTSAvailable(),
|
||||
"espeak_ng": s.espeakAvailable(),
|
||||
"engine": "none",
|
||||
"default_voice": "zh-CN-XiaoxiaoNeural",
|
||||
"builtin_voices": len(BuiltinVoices),
|
||||
}
|
||||
|
||||
if s.edgeTTSAvailable() {
|
||||
status["engine"] = "edge-tts"
|
||||
} else if s.espeakAvailable() {
|
||||
status["engine"] = "espeak-ng"
|
||||
} else {
|
||||
status["engine"] = "fallback (silent WAV)"
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
+449
-10
@@ -419,6 +419,181 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
border-color: var(--accent) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========== 记忆时间线样式 ========== */
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
padding-left: 40px;
|
||||
}
|
||||
.timeline-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, var(--blue) 0%, var(--border2) 50%, #a855f7 100%);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
transition: all .2s ease;
|
||||
}
|
||||
.timeline-item:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 14px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
z-index: 2;
|
||||
border: 2px solid var(--border2);
|
||||
background: var(--bg2);
|
||||
transition: all .2s;
|
||||
}
|
||||
.timeline-dot.memory {
|
||||
border-color: var(--blue);
|
||||
background: var(--blue-bg);
|
||||
box-shadow: 0 0 8px rgba(59,130,246,.3);
|
||||
}
|
||||
.timeline-dot.thinking {
|
||||
border-color: #a855f7;
|
||||
background: rgba(168,85,247,.12);
|
||||
box-shadow: 0 0 8px rgba(168,85,247,.3);
|
||||
}
|
||||
.timeline-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
transition: all .2s ease;
|
||||
}
|
||||
.timeline-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.2);
|
||||
}
|
||||
.timeline-card.memory-card {
|
||||
border-left: 3px solid var(--blue);
|
||||
}
|
||||
.timeline-card.thinking-card {
|
||||
border-left: 3px solid #a855f7;
|
||||
}
|
||||
.timeline-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.timeline-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.timeline-card-title.memory { color: #60a5fa; }
|
||||
.timeline-card-title.thinking { color: #c084fc; }
|
||||
.timeline-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.timeline-card-body {
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.timeline-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text3);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.timeline-importance-stars {
|
||||
color: #f59e0b;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timeline-trigger-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.timeline-trigger-badge.scheduled { background: var(--blue-bg); color: var(--blue); }
|
||||
.timeline-trigger-badge.manual { background: var(--orange-bg); color: var(--orange); }
|
||||
.timeline-detail {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.timeline-detail.open {
|
||||
display: block;
|
||||
}
|
||||
.timeline-detail .detail-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.timeline-detail .detail-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.timeline-detail .detail-label {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.timeline-detail .detail-content {
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.timeline-detail .tool-call-item {
|
||||
padding: 6px 10px;
|
||||
background: var(--bg3);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
/* 筛选标签样式 */
|
||||
.timeline-filter-tab {
|
||||
padding: 4px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
background: var(--bg3);
|
||||
color: var(--text2);
|
||||
transition: all .15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.timeline-filter-tab:hover { background: var(--bg4); color: var(--text); }
|
||||
.timeline-filter-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -460,6 +635,9 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
<button class="nav-item" data-panel="thinking">
|
||||
<span class="nav-icon">💭</span><span class="nav-label">自主思考</span>
|
||||
</button>
|
||||
<button class="nav-item" data-panel="timeline">
|
||||
<span class="nav-icon">⏱️</span><span class="nav-label">记忆时间线</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<span id="ws-dot" class="disconnected"></span>
|
||||
@@ -492,6 +670,8 @@ input[type="range"] { accent-color: var(--accent); padding: 0; }
|
||||
<div class="panel" id="panel-toolCalls"></div>
|
||||
<!-- 自主思考日志 -->
|
||||
<div class="panel" id="panel-thinking"></div>
|
||||
<!-- 记忆时间线 -->
|
||||
<div class="panel" id="panel-timeline"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -534,6 +714,12 @@ const STATE = {
|
||||
memoryFilterImportance: 0,
|
||||
memorySearchText: '',
|
||||
memoryPanelInitialized: false,
|
||||
// 时间线面板状态
|
||||
timelineData: [],
|
||||
timelineUserId: 'admin_admin',
|
||||
timelineFilterType: 'all',
|
||||
timelineAutoRefresh: null,
|
||||
timelineLimit: 100,
|
||||
};
|
||||
|
||||
// ========== WebSocket ==========
|
||||
@@ -705,7 +891,7 @@ function switchPanel(name) {
|
||||
const titles = {
|
||||
dashboard: '🏠 仪表盘', memory: '🧠 记忆管理', sessions: '💬 会话监看',
|
||||
services: '🖥 服务管理', iot: '🏠 IoT 设备控制', performance: '📊 性能监控', database: '🗄️ 数据库监看',
|
||||
toolCalls: '🔧 工具调用记录', thinking: '💭 自主思考',
|
||||
toolCalls: '🔧 工具调用记录', thinking: '💭 自主思考', timeline: '⏱️ 记忆时间线',
|
||||
};
|
||||
document.getElementById('panel-title').textContent = titles[name] || name;
|
||||
|
||||
@@ -718,15 +904,16 @@ function switchPanel(name) {
|
||||
|
||||
// 渲染面板
|
||||
switch (name) {
|
||||
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); break;
|
||||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); break;
|
||||
case 'dashboard': renderDashboard(); startDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'memory': renderMemoryPanel(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'sessions': renderSessionsPanel(); startSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'services': renderServicesPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'iot': renderIoTPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); startIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'performance': renderPerformancePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'database': renderDatabasePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); startDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'toolCalls': renderToolCallsPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'thinking': renderThinkingPanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); stopTimelineAutoRefresh(); break;
|
||||
case 'timeline': renderTimelinePanel(); stopSessionsAutoRefresh(); stopDashboardAutoRefresh(); stopDbAutoRefresh(); stopIoTRefresh(); startTimelineAutoRefresh(); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2628,6 +2815,258 @@ function toggleThinkingAutoRefresh(on) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 面板10: 记忆时间线 ==========
|
||||
function stopTimelineAutoRefresh() {
|
||||
if (STATE.timelineAutoRefresh) { clearInterval(STATE.timelineAutoRefresh); STATE.timelineAutoRefresh = null; }
|
||||
}
|
||||
|
||||
function startTimelineAutoRefresh() {
|
||||
stopTimelineAutoRefresh();
|
||||
STATE.timelineAutoRefresh = setInterval(function() {
|
||||
if (STATE.activePanel === 'timeline') renderTimelinePanel();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function importanceToStarsTimeline(imp) {
|
||||
var full = Math.round(imp / 2);
|
||||
var empty = 5 - full;
|
||||
return '<span style="color:#f59e0b">' + '★'.repeat(full) + '</span><span style="color:var(--text3)">' + '☆'.repeat(empty) + '</span>';
|
||||
}
|
||||
|
||||
async function renderTimelinePanel() {
|
||||
var container = document.getElementById('panel-timeline');
|
||||
if (!container) return;
|
||||
|
||||
var actionsEl = document.getElementById('panel-actions');
|
||||
var autoRefreshOn = STATE.timelineAutoRefresh !== null;
|
||||
actionsEl.innerHTML = '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer;">' +
|
||||
'<input type="checkbox" id="timeline-autorefresh" ' + (autoRefreshOn ? 'checked' : '') + ' onchange="toggleTimelineAutoRefresh(this.checked)">' +
|
||||
'自动刷新 (30s)</label>' +
|
||||
'<button class="btn btn-sm" onclick="renderTimelinePanel()" style="margin-left:8px">🔄 刷新</button>';
|
||||
|
||||
// 加载数据
|
||||
var userId = STATE.timelineUserId || 'admin_admin';
|
||||
var data = await api('/api/memory-timeline?user_id=' + encodeURIComponent(userId) + '&limit=' + STATE.timelineLimit);
|
||||
|
||||
if (data.error) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escHtml(data.error) +
|
||||
(data.hint ? '<br><small>' + escHtml(data.hint) + '</small>' : '') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var timeline = data.timeline || [];
|
||||
var stats = data.stats || {};
|
||||
STATE.timelineData = timeline;
|
||||
|
||||
// 筛选
|
||||
var filtered = timeline;
|
||||
if (STATE.timelineFilterType && STATE.timelineFilterType !== 'all') {
|
||||
filtered = timeline.filter(function(item) { return item.type === STATE.timelineFilterType; });
|
||||
}
|
||||
|
||||
// 统计卡片
|
||||
var memCount = stats.total_memories || 0;
|
||||
var thinkCount = stats.total_thinking || 0;
|
||||
var latestMemTime = stats.latest_memory_time ? formatTime(stats.latest_memory_time) : '—';
|
||||
var latestThinkTime = stats.latest_thinking_time ? formatTime(stats.latest_thinking_time) : '—';
|
||||
|
||||
var statsCardsHtml = '<div class="cards-grid cards-4" style="margin-bottom:14px;">' +
|
||||
'<div class="stat-card accent"><div class="stat-value">' + memCount + '</div><div class="stat-label">🧠 总记忆数</div></div>' +
|
||||
'<div class="stat-card blue"><div class="stat-value">' + thinkCount + '</div><div class="stat-label">💭 总思考次数</div></div>' +
|
||||
'<div class="stat-card green"><div class="stat-value">' + latestMemTime + '</div><div class="stat-label">📅 最新记忆</div></div>' +
|
||||
'<div class="stat-card orange"><div class="stat-value">' + latestThinkTime + '</div><div class="stat-label">🕐 最新思考</div></div>' +
|
||||
'</div>';
|
||||
|
||||
// 筛选栏
|
||||
var allActive = STATE.timelineFilterType === 'all' ? ' active' : '';
|
||||
var memActive = STATE.timelineFilterType === 'memory' ? ' active' : '';
|
||||
var thinkActive = STATE.timelineFilterType === 'thinking' ? ' active' : '';
|
||||
|
||||
var filterHtml = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap;">' +
|
||||
'<span style="font-size:12px;color:var(--text2);">筛选类型:</span>' +
|
||||
'<button class="timeline-filter-tab' + allActive + '" onclick="filterTimeline(\'all\')">📋 全部</button>' +
|
||||
'<button class="timeline-filter-tab' + memActive + '" onclick="filterTimeline(\'memory\')">🧠 记忆</button>' +
|
||||
'<button class="timeline-filter-tab' + thinkActive + '" onclick="filterTimeline(\'thinking\')">💭 思考</button>' +
|
||||
'<span style="font-size:11px;color:var(--text3);margin-left:auto;">显示 ' + filtered.length + ' / ' + timeline.length + ' 条</span>' +
|
||||
'</div>';
|
||||
|
||||
// 时间线主体
|
||||
var timelineHtml = '';
|
||||
if (filtered.length === 0) {
|
||||
timelineHtml = '<div class="empty-state"><div class="icon">📭</div>暂无匹配的时间线条目</div>';
|
||||
} else {
|
||||
timelineHtml = '<div class="timeline-container">';
|
||||
for (var i = 0; i < filtered.length; i++) {
|
||||
var item = filtered[i];
|
||||
var itemId = 'tl-' + i;
|
||||
var isMemory = item.type === 'memory';
|
||||
|
||||
// 圆点图标
|
||||
var dotIcon = isMemory ? '🧠' : '💭';
|
||||
var dotClass = isMemory ? 'memory' : 'thinking';
|
||||
var cardClass = isMemory ? 'memory-card' : 'thinking-card';
|
||||
var titleClass = isMemory ? 'memory' : 'thinking';
|
||||
|
||||
// 标题
|
||||
var title = item.title || (isMemory ? '记忆' : '思考');
|
||||
|
||||
// 摘要
|
||||
var summary = '';
|
||||
if (isMemory) {
|
||||
summary = item.summary || item.content || '';
|
||||
if (summary.length > 200) summary = summary.substring(0, 197) + '...';
|
||||
} else {
|
||||
summary = item.summary || '';
|
||||
}
|
||||
|
||||
// 重要性星级
|
||||
var starsHtml = '';
|
||||
if (isMemory && item.importance) {
|
||||
starsHtml = '<span class="timeline-importance-stars">' + importanceToStarsTimeline(item.importance) + '</span>';
|
||||
}
|
||||
|
||||
// 分类标签
|
||||
var catLabel = '';
|
||||
if (isMemory && item.category) {
|
||||
var cc = getCatColor(item.category);
|
||||
catLabel = '<span style="display:inline-block;padding:1px 8px;border-radius:10px;font-size:10px;font-weight:500;background:' + cc.bg + ';color:' + cc.text + ';">' + cc.icon + ' ' + cc.name + '</span>';
|
||||
}
|
||||
|
||||
// 工具调用数
|
||||
var toolCallLabel = '';
|
||||
if (!isMemory && item.tool_call_count > 0) {
|
||||
toolCallLabel = '<span style="display:inline-block;padding:1px 8px;border-radius:10px;font-size:10px;font-weight:500;background:var(--accent-bg);color:var(--accent);">🔧 ' + item.tool_call_count + ' 次工具调用</span>';
|
||||
}
|
||||
|
||||
// 触发方式
|
||||
var triggerLabel = '';
|
||||
if (!isMemory) {
|
||||
var trigger = item.trigger || '定时';
|
||||
var triggerClass = trigger === '手动' ? 'manual' : 'scheduled';
|
||||
triggerLabel = '<span class="timeline-trigger-badge ' + triggerClass + '">' + (trigger === '手动' ? '👆 手动' : '⏰ 定时') + '</span>';
|
||||
}
|
||||
|
||||
// 来源
|
||||
var sourceLabel = '';
|
||||
if (isMemory) {
|
||||
var srcText = item.source === 'thinking' ? '🤔 后台思考' : item.source === 'conversation' ? '💬 对话' : '📝 ' + (item.source || '未知');
|
||||
sourceLabel = '<span>' + srcText + '</span>';
|
||||
}
|
||||
|
||||
timelineHtml += '<div class="timeline-item" id="' + itemId + '">' +
|
||||
'<div class="timeline-dot ' + dotClass + '">' + dotIcon + '</div>' +
|
||||
'<div class="timeline-card ' + cardClass + '" onclick="toggleTimelineDetail(\'' + itemId + '\')">' +
|
||||
'<div class="timeline-card-header">' +
|
||||
'<span class="timeline-card-title ' + titleClass + '">' + escHtml(title) + '</span>' +
|
||||
'<div class="timeline-card-meta">' +
|
||||
starsHtml +
|
||||
'<span style="font-size:10px;white-space:nowrap;">' + formatTime(item.timestamp) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(summary ? '<div class="timeline-card-body">' + escHtml(summary) + '</div>' : '') +
|
||||
'<div class="timeline-card-footer">' +
|
||||
catLabel +
|
||||
toolCallLabel +
|
||||
triggerLabel +
|
||||
sourceLabel +
|
||||
(item.session_id ? '<span style="font-size:10px;color:var(--text3);max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">💬 ' + escHtml((item.session_id || '').substring(0, 20)) + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
// 详情展开区
|
||||
'<div class="timeline-detail" id="' + itemId + '-detail">' +
|
||||
renderTimelineDetail(item) +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
timelineHtml += '</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = statsCardsHtml + filterHtml + timelineHtml;
|
||||
}
|
||||
|
||||
function renderTimelineDetail(item) {
|
||||
var html = '';
|
||||
if (item.type === 'memory') {
|
||||
// 记忆详情
|
||||
html += '<div class="detail-section">' +
|
||||
'<div class="detail-label">📝 完整内容</div>' +
|
||||
'<div class="detail-content">' + escHtml(item.content || '') + '</div>' +
|
||||
'</div>';
|
||||
if (item.keywords && item.keywords.length > 0) {
|
||||
html += '<div class="detail-section">' +
|
||||
'<div class="detail-label">🏷️ 关键词</div>' +
|
||||
'<div style="display:flex;gap:4px;flex-wrap:wrap;">' +
|
||||
item.keywords.map(function(k) { return '<span style="padding:2px 8px;background:var(--bg3);border-radius:10px;font-size:10px;">' + escHtml(k) + '</span>'; }).join('') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
html += '<div class="detail-section" style="display:flex;gap:20px;flex-wrap:wrap;">' +
|
||||
'<span><strong>重要性:</strong> ' + (item.importance || 0) + '/10</span>' +
|
||||
'<span><strong>访问次数:</strong> ' + (item.access_count || 0) + '</span>' +
|
||||
'<span><strong>来源:</strong> ' + escHtml(item.source || '未知') + '</span>' +
|
||||
'<span><strong>ID:</strong> <code style="font-size:10px;">' + escHtml((item.id || '').substring(0, 16)) + '</code></span>' +
|
||||
'</div>';
|
||||
} else {
|
||||
// 思考详情
|
||||
html += '<div class="detail-section">' +
|
||||
'<div class="detail-label">💭 思考内容</div>' +
|
||||
'<div class="detail-content">' + escHtml(item.content || '') + '</div>' +
|
||||
'</div>';
|
||||
if (item.tool_calls) {
|
||||
var toolCalls = item.tool_calls;
|
||||
try {
|
||||
if (typeof toolCalls === 'string') toolCalls = JSON.parse(toolCalls);
|
||||
} catch(e) { toolCalls = null; }
|
||||
if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) {
|
||||
html += '<div class="detail-section">' +
|
||||
'<div class="detail-label">🔧 工具调用详情 (' + toolCalls.length + ')</div>';
|
||||
for (var i = 0; i < toolCalls.length; i++) {
|
||||
var tc = toolCalls[i];
|
||||
var tcName = tc.function ? (tc.function.name || '未知') : (tc.name || '未知');
|
||||
var tcArgs = tc.function ? (tc.function.arguments || '') : (tc.arguments || '');
|
||||
if (typeof tcArgs === 'object') tcArgs = JSON.stringify(tcArgs);
|
||||
html += '<div class="tool-call-item">' +
|
||||
'<strong style="color:var(--accent2);">' + escHtml(tcName) + '</strong>' +
|
||||
'<pre style="font-size:10px;margin:4px 0 0;white-space:pre-wrap;color:var(--text2);">' + escHtml(String(tcArgs).slice(0, 300)) + '</pre>' +
|
||||
'</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
html += '<div class="detail-section" style="display:flex;gap:20px;flex-wrap:wrap;">' +
|
||||
'<span><strong>内容长度:</strong> ' + (item.content_length || 0) + ' 字符</span>' +
|
||||
'<span><strong>工具调用数:</strong> ' + (item.tool_call_count || 0) + '</span>' +
|
||||
'<span><strong>触发方式:</strong> ' + escHtml(item.trigger || '定时') + '</span>' +
|
||||
'<span><strong>ID:</strong> <code style="font-size:10px;">' + escHtml((item.id || '').substring(0, 16)) + '</code></span>' +
|
||||
'</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function toggleTimelineDetail(itemId) {
|
||||
var detail = document.getElementById(itemId + '-detail');
|
||||
if (detail) {
|
||||
detail.classList.toggle('open');
|
||||
}
|
||||
}
|
||||
|
||||
function filterTimeline(type) {
|
||||
STATE.timelineFilterType = type;
|
||||
renderTimelinePanel();
|
||||
}
|
||||
|
||||
function toggleTimelineAutoRefresh(on) {
|
||||
if (STATE.timelineAutoRefresh) {
|
||||
clearInterval(STATE.timelineAutoRefresh);
|
||||
STATE.timelineAutoRefresh = null;
|
||||
}
|
||||
if (on) {
|
||||
STATE.timelineAutoRefresh = setInterval(function() {
|
||||
if (STATE.activePanel === 'timeline') renderTimelinePanel();
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<script src="iot-panel.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -67,6 +67,22 @@ export const SERVICES = {
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
},
|
||||
'voice-service': {
|
||||
name: '语音识别服务',
|
||||
cwd: path.join(ROOT, 'backend/voice-service'),
|
||||
command: './main',
|
||||
env: {
|
||||
PORT: '8093',
|
||||
WHISPER_BINARY: './whisper.cpp/main',
|
||||
WHISPER_MODEL: './whisper.cpp/models/ggml-small.bin',
|
||||
WHISPER_LANGUAGE: 'zh',
|
||||
},
|
||||
healthUrl: 'http://localhost:8093/api/v1/health',
|
||||
port: 8093,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
},
|
||||
frontend: {
|
||||
name: 'Frontend',
|
||||
cwd: path.join(ROOT, 'frontend/web'),
|
||||
|
||||
@@ -20,6 +20,7 @@ import { performanceMonitor } from './performance.js';
|
||||
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile, GATEWAY_URL, TOOL_ENGINE_URL, ADMIN_USERNAME, ADMIN_PASSWORD } from './config.js';
|
||||
|
||||
const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || 'http://localhost:8091';
|
||||
const VOICE_SERVICE_URL = process.env.VOICE_SERVICE_URL || 'http://localhost:8093';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const TUNNEL_SCRIPT = path.join(ROOT, 'scripts/tunnel.sh');
|
||||
@@ -621,6 +622,55 @@ app.get('/api/tool-calls/stats', async (_req, res) => {
|
||||
|
||||
// ---- 自主思考日志代理 (转发到 memory-service) ----
|
||||
|
||||
// ---- 语音识别服务代理 (转发到 voice-service) ----
|
||||
|
||||
/**
|
||||
* 代理请求到 Voice-Service
|
||||
* @param {string} path - Voice-Service API 路径
|
||||
* @param {object} opts - fetch 选项
|
||||
*/
|
||||
async function proxyToVoiceService(path, opts = {}) {
|
||||
const url = `${VOICE_SERVICE_URL}${path}`;
|
||||
const logPrefix = `[VoiceService代理]`;
|
||||
try {
|
||||
console.log(`${logPrefix} ${opts.method || 'GET'} ${path}`);
|
||||
const resp = await fetch(url, {
|
||||
...opts,
|
||||
signal: AbortSignal.timeout(60000), // 语音转录可能需要较长时间
|
||||
});
|
||||
const body = await resp.json().catch(() => null);
|
||||
if (!resp.ok) {
|
||||
console.log(`${logPrefix} 请求失败 (HTTP ${resp.status}): ${path}`);
|
||||
}
|
||||
return { status: resp.status, body };
|
||||
} catch (err) {
|
||||
const isConnRefused = err.message?.includes('ECONNREFUSED') || err.cause?.code === 'ECONNREFUSED';
|
||||
console.error(`${logPrefix} 请求异常: ${path} - ${err.message}`);
|
||||
return {
|
||||
status: 502,
|
||||
body: {
|
||||
error: `Voice-Service 不可达: ${err.message}`,
|
||||
errorType: isConnRefused ? 'voice_service_not_running' : 'voice_service_unreachable',
|
||||
hint: isConnRefused
|
||||
? 'Voice-Service 服务未启动,请先在「服务管理」面板中启动 Voice-Service'
|
||||
: 'Voice-Service 服务无响应,请检查网络连接和服务状态',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/voice/status — 获取 STT 服务状态
|
||||
app.get('/api/voice/status', async (_req, res) => {
|
||||
const result = await proxyToVoiceService('/api/v1/status');
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// GET /api/voice/health — STT 健康检查
|
||||
app.get('/api/voice/health', async (_req, res) => {
|
||||
const result = await proxyToVoiceService('/api/v1/health');
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
/**
|
||||
* 代理请求到 Memory-Service
|
||||
* @param {string} path - Memory-Service API 路径
|
||||
@@ -691,6 +741,123 @@ app.get('/api/v1/thinking/:id', async (req, res) => {
|
||||
res.status(result.status).json(result.body);
|
||||
});
|
||||
|
||||
// ---- 记忆时间线 (合并记忆 + 思考) ----
|
||||
app.get('/api/memory-timeline', async (req, res) => {
|
||||
const { user_id, limit } = req.query;
|
||||
if (!user_id) {
|
||||
return res.status(400).json({ error: '缺少 user_id 参数' });
|
||||
}
|
||||
const maxItems = parseInt(limit) || 100;
|
||||
|
||||
try {
|
||||
// 并行调用记忆和思考 API
|
||||
const memQs = new URLSearchParams({ user_id, limit: String(maxItems) }).toString();
|
||||
const thinkQs = new URLSearchParams({ user_id, limit: String(maxItems), offset: '0' }).toString();
|
||||
|
||||
const [memResult, thinkResult] = await Promise.all([
|
||||
proxyToMemoryService(`/api/v1/memories?${memQs}`),
|
||||
proxyToMemoryService(`/api/v1/thinking?${thinkQs}`),
|
||||
]);
|
||||
|
||||
const memories = [];
|
||||
const thinkingLogs = [];
|
||||
|
||||
// 解析记忆列表
|
||||
if (memResult.body && !memResult.body.error) {
|
||||
const memData = Array.isArray(memResult.body) ? memResult.body : (memResult.body.memories || memResult.body.results || []);
|
||||
for (const m of memData) {
|
||||
const createdAt = m.created_at || m.CreatedAt || m.timestamp;
|
||||
memories.push({
|
||||
id: m.id || m.ID || '',
|
||||
type: 'memory',
|
||||
title: m.title || (m.content || '').substring(0, 60),
|
||||
content: m.content || '',
|
||||
summary: m.summary || '',
|
||||
importance: m.importance || 1,
|
||||
category: m.category || 'other',
|
||||
source: m.source || 'unknown',
|
||||
session_id: m.session_id || '',
|
||||
keywords: m.keywords || [],
|
||||
access_count: m.access_count || 0,
|
||||
timestamp: createdAt ? new Date(createdAt).toISOString() : null,
|
||||
user_id: m.user_id || user_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 解析思考日志列表
|
||||
if (thinkResult.body && !thinkResult.body.error) {
|
||||
const thinkData = thinkResult.body.logs || (Array.isArray(thinkResult.body) ? thinkResult.body : []);
|
||||
for (const t of thinkData) {
|
||||
const createdAt = t.created_at || t.CreatedAt || t.timestamp;
|
||||
// 解析思考主题:提取第一行或前80个字符
|
||||
const content = t.content || '';
|
||||
const firstLine = content.split('\n')[0] || '';
|
||||
const topic = firstLine.length > 80 ? firstLine.substring(0, 77) + '...' : firstLine;
|
||||
|
||||
// 尝试从内容推断触发方式
|
||||
let trigger = '定时';
|
||||
if (content.includes('用户') || content.includes('手动') || content.includes('manual')) {
|
||||
trigger = '手动';
|
||||
} else if (content.includes('scheduled') || content.includes('定时') || content.includes('interval')) {
|
||||
trigger = '定时';
|
||||
}
|
||||
|
||||
thinkingLogs.push({
|
||||
id: t.id || t.ID || '',
|
||||
type: 'thinking',
|
||||
title: topic || '自主思考',
|
||||
content: content,
|
||||
summary: content.length > 200 ? content.substring(0, 197) + '...' : content,
|
||||
tool_call_count: t.tool_call_count || 0,
|
||||
content_length: t.content_length || content.length,
|
||||
trigger: trigger,
|
||||
tool_calls: t.tool_calls || null,
|
||||
timestamp: createdAt ? new Date(createdAt).toISOString() : null,
|
||||
user_id: t.user_id || user_id,
|
||||
// 思考没有重要性,设为0用于排序
|
||||
importance: 0,
|
||||
source: 'thinking',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 合并并按时间排序(降序:最新的在前)
|
||||
const timeline = [...memories, ...thinkingLogs].sort((a, b) => {
|
||||
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
||||
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
|
||||
// 截取限制条数
|
||||
const result = timeline.slice(0, maxItems);
|
||||
|
||||
// 统计摘要
|
||||
const stats = {
|
||||
total_memories: memories.length,
|
||||
total_thinking: thinkingLogs.length,
|
||||
latest_memory_time: memories.length > 0 ? memories.reduce((max, m) => {
|
||||
const t = m.timestamp ? new Date(m.timestamp).getTime() : 0;
|
||||
return t > max ? t : max;
|
||||
}, 0) : null,
|
||||
latest_thinking_time: thinkingLogs.length > 0 ? thinkingLogs.reduce((max, t) => {
|
||||
const ts = t.timestamp ? new Date(t.timestamp).getTime() : 0;
|
||||
return ts > max ? ts : max;
|
||||
}, 0) : null,
|
||||
};
|
||||
|
||||
res.json({
|
||||
timeline: result,
|
||||
stats,
|
||||
total: timeline.length,
|
||||
user_id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[记忆时间线] 错误:', err.message);
|
||||
res.status(500).json({ error: `获取时间线失败: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 健康检查代理 ----
|
||||
app.get('/api/proxy/:id/health', async (req, res) => {
|
||||
const svc = SERVICES[req.params.id];
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#ec4899" />
|
||||
<meta name="description" content="你的 AI 生活伴侣,支持 IoT 控制、知识库、语音交互" />
|
||||
<link rel="apple-touch-icon" href="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png" />
|
||||
<title>昔涟 - Cyrene</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "Cyrene - AI 智能助手",
|
||||
"short_name": "Cyrene",
|
||||
"description": "你的 AI 生活伴侣,支持 IoT 控制、知识库、语音交互",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#fdf2f8",
|
||||
"theme_color": "#ec4899",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "utilities"],
|
||||
"lang": "zh-CN",
|
||||
"dir": "ltr",
|
||||
"scope": "/",
|
||||
"prefer_related_applications": false,
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "新对话",
|
||||
"url": "/#/new",
|
||||
"description": "开始新对话"
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/#/share",
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>昔涟 - 离线页面</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: linear-gradient(135deg, #fdf2f8 0%, #fce7f3 50%, #fbcfe8 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #374151;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
.avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 28px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 8px 32px rgba(236, 72, 153, 0.3);
|
||||
margin-bottom: 1.5rem;
|
||||
border: 3px solid rgba(236, 72, 153, 0.3);
|
||||
}
|
||||
.avatar-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, #ec4899, #f472b6);
|
||||
margin: 0 auto 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 64px;
|
||||
box-shadow: 0 8px 32px rgba(236, 72, 153, 0.3);
|
||||
border: 3px solid rgba(236, 72, 153, 0.3);
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #be185d;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
p {
|
||||
font-size: 0.95rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.status-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: linear-gradient(135deg, #ec4899, #f472b6);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 16px rgba(236, 72, 153, 0.3);
|
||||
}
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 24px rgba(236, 72, 153, 0.4);
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.auto-retry {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.auto-retry.connected {
|
||||
color: #059669;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Cyrene Avatar -->
|
||||
<img
|
||||
class="avatar"
|
||||
src="/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png"
|
||||
alt="昔涟"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
|
||||
/>
|
||||
<div class="avatar-placeholder" style="display:none;">🌸</div>
|
||||
|
||||
<div class="status-icon">📡</div>
|
||||
<h1>哎呀,网络连接断开了</h1>
|
||||
<p>请检查网络连接后重试<br />昔涟正在等你回来~</p>
|
||||
|
||||
<button onclick="retry()">🔄 重新连接</button>
|
||||
<div id="autoRetry" class="auto-retry">正在监听网络恢复...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function retry() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const autoRetryEl = document.getElementById('autoRetry');
|
||||
|
||||
window.addEventListener('online', function() {
|
||||
autoRetryEl.textContent = '✅ 网络已恢复,正在重新加载...';
|
||||
autoRetryEl.classList.add('connected');
|
||||
setTimeout(function() {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
const CACHE_NAME = 'cyrene-v1';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
];
|
||||
|
||||
// Install: 缓存核心资源
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(ASSETS_TO_CACHE);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: 清理旧缓存
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => {
|
||||
return Promise.all(
|
||||
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch: 缓存优先策略(对 API 请求使用网络优先)
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// API 请求:网络优先,失败时返回离线 JSON
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(networkFirst(event.request));
|
||||
} else if (url.pathname.startsWith('/ws')) {
|
||||
// WebSocket 连接不缓存
|
||||
event.respondWith(fetch(event.request));
|
||||
} else {
|
||||
// 静态资源:缓存优先
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
}
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
// 返回离线页面(HTML 请求)
|
||||
if (request.headers.get('Accept')?.includes('text/html')) {
|
||||
return caches.match('/offline.html');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
return response;
|
||||
} catch (e) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
return new Response(JSON.stringify({ error: '离线状态,请检查网络连接' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Push 通知
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() || { title: 'Cyrene', body: '新消息' };
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png',
|
||||
badge: '/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png',
|
||||
data: data.data || {},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const sessionId = event.notification.data?.session_id;
|
||||
const targetUrl = sessionId ? `/#/session/${sessionId}` : '/';
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(targetUrl) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow(targetUrl);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// 自动化规则和场景 API
|
||||
|
||||
import { request, type ApiResponse } from './client';
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
/** 自动化规则 */
|
||||
export interface AutomationRule {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger_type: string;
|
||||
trigger_config: unknown;
|
||||
conditions: unknown;
|
||||
actions: unknown;
|
||||
enabled: boolean;
|
||||
last_triggered_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 自动化场景 */
|
||||
export interface AutomationScene {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
rule_ids: unknown;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 创建规则请求 */
|
||||
export interface CreateRuleRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
trigger_type: string;
|
||||
trigger_config?: unknown;
|
||||
conditions?: unknown;
|
||||
actions: unknown;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** 更新规则请求 */
|
||||
export interface UpdateRuleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
trigger_type?: string;
|
||||
trigger_config?: unknown;
|
||||
conditions?: unknown;
|
||||
actions?: unknown;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** 创建场景请求 */
|
||||
export interface CreateSceneRequest {
|
||||
name: string;
|
||||
icon?: string;
|
||||
rule_ids?: string[];
|
||||
}
|
||||
|
||||
/** 更新场景请求 */
|
||||
export interface UpdateSceneRequest {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
rule_ids?: string[];
|
||||
}
|
||||
|
||||
/** 规则列表响应 */
|
||||
export interface RuleListResponse {
|
||||
rules: AutomationRule[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 场景列表响应 */
|
||||
export interface SceneListResponse {
|
||||
scenes: AutomationScene[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ========== 规则 API ==========
|
||||
|
||||
/**
|
||||
* 获取用户的所有规则
|
||||
*/
|
||||
export async function listRules(): Promise<ApiResponse<RuleListResponse>> {
|
||||
return request<RuleListResponse>('/automation/rules');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新规则
|
||||
*/
|
||||
export async function createRule(data: CreateRuleRequest): Promise<ApiResponse<{ success: boolean; rule: AutomationRule }>> {
|
||||
return request<{ success: boolean; rule: AutomationRule }>('/automation/rules', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单条规则
|
||||
*/
|
||||
export async function getRule(id: string): Promise<ApiResponse<{ rule: AutomationRule }>> {
|
||||
return request<{ rule: AutomationRule }>(`/automation/rules/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新规则
|
||||
*/
|
||||
export async function updateRule(id: string, data: UpdateRuleRequest): Promise<ApiResponse<{ success: boolean; rule: AutomationRule }>> {
|
||||
return request<{ success: boolean; rule: AutomationRule }>(`/automation/rules/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除规则
|
||||
*/
|
||||
export async function deleteRule(id: string): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return request<{ success: boolean }>(`/automation/rules/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发规则
|
||||
*/
|
||||
export async function triggerRule(id: string): Promise<ApiResponse<{ success: boolean; message: string }>> {
|
||||
return request<{ success: boolean; message: string }>(`/automation/rules/${id}/trigger`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换规则启用状态
|
||||
*/
|
||||
export async function toggleRule(id: string, enabled: boolean): Promise<ApiResponse<{ success: boolean; rule: AutomationRule }>> {
|
||||
return updateRule(id, { enabled });
|
||||
}
|
||||
|
||||
// ========== 场景 API ==========
|
||||
|
||||
/**
|
||||
* 获取用户的所有场景
|
||||
*/
|
||||
export async function listScenes(): Promise<ApiResponse<SceneListResponse>> {
|
||||
return request<SceneListResponse>('/automation/scenes');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新场景
|
||||
*/
|
||||
export async function createScene(data: CreateSceneRequest): Promise<ApiResponse<{ success: boolean; scene: AutomationScene }>> {
|
||||
return request<{ success: boolean; scene: AutomationScene }>('/automation/scenes', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个场景
|
||||
*/
|
||||
export async function getScene(id: string): Promise<ApiResponse<{ scene: AutomationScene }>> {
|
||||
return request<{ scene: AutomationScene }>(`/automation/scenes/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新场景
|
||||
*/
|
||||
export async function updateScene(id: string, data: UpdateSceneRequest): Promise<ApiResponse<{ success: boolean; scene: AutomationScene }>> {
|
||||
return request<{ success: boolean; scene: AutomationScene }>(`/automation/scenes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除场景
|
||||
*/
|
||||
export async function deleteScene(id: string): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return request<{ success: boolean }>(`/automation/scenes/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动执行场景
|
||||
*/
|
||||
export async function executeScene(id: string): Promise<ApiResponse<{ success: boolean; message: string }>> {
|
||||
return request<{ success: boolean; message: string }>(`/automation/scenes/${id}/execute`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 每日简报 API
|
||||
|
||||
import { request, type ApiResponse } from './client';
|
||||
|
||||
// ========== 类型定义 ==========
|
||||
|
||||
export interface WeatherData {
|
||||
location: string;
|
||||
temp: number;
|
||||
condition: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
title: string;
|
||||
url: string;
|
||||
source: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface BriefReminder {
|
||||
id: string;
|
||||
title: string;
|
||||
remind_at: string;
|
||||
}
|
||||
|
||||
export interface Briefing {
|
||||
id: string;
|
||||
user_id: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
weather?: WeatherData;
|
||||
news: NewsItem[];
|
||||
reminders: BriefReminder[];
|
||||
summary: string;
|
||||
status: 'pending' | 'generated' | 'delivered';
|
||||
generated_at?: string;
|
||||
delivered_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GenerateBriefingResponse {
|
||||
success: boolean;
|
||||
briefing?: Briefing;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ========== API 方法 ==========
|
||||
|
||||
/** 获取指定日期简报 */
|
||||
export async function getBriefing(userId: string, date: string): Promise<ApiResponse<{ briefing: Briefing | null; message?: string }>> {
|
||||
return request(`/briefings?user_id=${encodeURIComponent(userId)}&date=${encodeURIComponent(date)}`);
|
||||
}
|
||||
|
||||
/** 获取最近简报列表 */
|
||||
export async function getLatestBriefings(userId: string, limit = 7): Promise<ApiResponse<{ briefings: Briefing[]; total: number }>> {
|
||||
return request(`/briefings/latest?user_id=${encodeURIComponent(userId)}&limit=${limit}`);
|
||||
}
|
||||
|
||||
/** 手动生成今日简报 */
|
||||
export async function generateBriefing(userId: string): Promise<ApiResponse<GenerateBriefingResponse>> {
|
||||
return request('/briefings/generate', {
|
||||
method: 'POST',
|
||||
body: { user_id: userId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 格式化日期 */
|
||||
export function formatBriefingDate(date: string): string {
|
||||
try {
|
||||
const d = new Date(date + 'T00:00:00');
|
||||
return d.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
});
|
||||
} catch {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// 文件管理 API — 对接 Gateway REST API
|
||||
|
||||
import { request } from './client';
|
||||
|
||||
/** 文件元信息 */
|
||||
export interface FileInfo {
|
||||
id: string;
|
||||
user_id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
is_public: boolean;
|
||||
created_at: string; // UnixMilli
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/** 文件列表响应 */
|
||||
interface FileListResponse {
|
||||
files: FileInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** 单文件响应 */
|
||||
interface FileResponse {
|
||||
id: string;
|
||||
user_id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
is_public: boolean;
|
||||
created_at: number;
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
/**
|
||||
* 获取带授权头的文件下载/缩略图 URL (用于 fetch 直接获取 blob)
|
||||
*/
|
||||
function authFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
const token = localStorage.getItem('token');
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* POST /api/v1/files/upload (multipart/form-data)
|
||||
*/
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
sessionId?: string,
|
||||
): Promise<FileInfo> {
|
||||
const token = localStorage.getItem('token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (sessionId) {
|
||||
formData.append('session_id', sessionId);
|
||||
}
|
||||
|
||||
const resp = await fetch(`${API_BASE}/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: '上传失败' }));
|
||||
throw new Error(err.error || `上传失败 (${resp.status})`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出用户的所有文件 (支持分页)
|
||||
* GET /api/v1/files?page=&limit=
|
||||
*/
|
||||
export async function listFiles(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
): Promise<{ files: FileInfo[]; total: number }> {
|
||||
const resp = await request<FileListResponse>(
|
||||
`/files?page=${page}&limit=${limit}`,
|
||||
);
|
||||
if (resp.error) {
|
||||
console.error('[files] 获取文件列表失败:', resp.error);
|
||||
return { files: [], total: 0 };
|
||||
}
|
||||
const data = resp.data as FileListResponse;
|
||||
return { files: data?.files || [], total: data?.total || 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个文件元数据
|
||||
* GET /api/v1/files/:id
|
||||
*/
|
||||
export async function getFile(id: string): Promise<FileInfo | null> {
|
||||
const resp = await request<FileResponse>(`/files/${encodeURIComponent(id)}`);
|
||||
if (resp.error) {
|
||||
console.error('[files] 获取文件信息失败:', resp.error);
|
||||
return null;
|
||||
}
|
||||
const data = resp.data;
|
||||
if (!data) return null;
|
||||
return {
|
||||
...data,
|
||||
created_at: String(data.created_at),
|
||||
thumbnail_url: data.thumbnail_url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* DELETE /api/v1/files/:id
|
||||
*/
|
||||
export async function deleteFile(id: string): Promise<boolean> {
|
||||
const resp = await request(`/files/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (resp.error) {
|
||||
console.error('[files] 删除文件失败:', resp.error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载URL
|
||||
*/
|
||||
export function getFileDownloadUrl(id: string): string {
|
||||
return `${API_BASE}/files/${encodeURIComponent(id)}/download`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件缩略图URL
|
||||
*/
|
||||
export function getFileThumbnailUrl(id: string): string {
|
||||
return `${API_BASE}/files/${encodeURIComponent(id)}/thumbnail`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 fetch 下载文件并触发浏览器下载
|
||||
*/
|
||||
export async function downloadFile(id: string, filename?: string): Promise<void> {
|
||||
const resp = await authFetch(getFileDownloadUrl(id));
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: '下载失败' }));
|
||||
throw new Error(err.error || `下载失败 (${resp.status})`);
|
||||
}
|
||||
|
||||
// 从 Content-Disposition 获取文件名
|
||||
const disposition = resp.headers.get('Content-Disposition');
|
||||
let downloadName = filename || 'download';
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";\n]+)"?/);
|
||||
if (match) downloadName = match[1];
|
||||
}
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缩略图 blob URL (用于 <img> 标签)
|
||||
*/
|
||||
export async function getThumbnailBlobUrl(id: string): Promise<string | null> {
|
||||
try {
|
||||
const resp = await authFetch(getFileThumbnailUrl(id));
|
||||
if (!resp.ok) return null;
|
||||
const blob = await resp.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// 知识库 API — 对接 Gateway REST API
|
||||
|
||||
import { request } from './client';
|
||||
|
||||
/** 知识库 */
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
document_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 知识库文档 */
|
||||
export interface KnowledgeDocument {
|
||||
id: string;
|
||||
kb_id: string;
|
||||
title: string;
|
||||
content_type: string;
|
||||
source_type: string;
|
||||
source_ref: string;
|
||||
chunk_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 搜索结果 */
|
||||
export interface SearchChunkResult {
|
||||
chunk_id: string;
|
||||
doc_id: string;
|
||||
kb_id: string;
|
||||
content: string;
|
||||
rank: number;
|
||||
headline: string;
|
||||
doc_title: string;
|
||||
kb_name: string;
|
||||
}
|
||||
|
||||
/** 知识库列表响应 */
|
||||
interface KBListResponse {
|
||||
bases: KnowledgeBase[];
|
||||
}
|
||||
|
||||
/** 文档列表响应 */
|
||||
interface DocListResponse {
|
||||
documents: KnowledgeDocument[];
|
||||
}
|
||||
|
||||
/** 搜索响应 */
|
||||
interface SearchResponse {
|
||||
results: SearchChunkResult[];
|
||||
total: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
// ========== 知识库 CRUD ==========
|
||||
|
||||
/** 创建知识库 */
|
||||
export async function createKB(name: string, description?: string): Promise<KnowledgeBase> {
|
||||
const res = await request<KnowledgeBase>(`/knowledge/bases`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 列出知识库 */
|
||||
export async function listKBs(): Promise<KnowledgeBase[]> {
|
||||
const res = await request<KBListResponse>(`/knowledge/bases`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data?.bases || [];
|
||||
}
|
||||
|
||||
/** 获取知识库 */
|
||||
export async function getKB(id: string): Promise<KnowledgeBase> {
|
||||
const res = await request<KnowledgeBase>(`/knowledge/bases/${id}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 更新知识库 */
|
||||
export async function updateKB(id: string, name: string, description?: string): Promise<KnowledgeBase> {
|
||||
const res = await request<KnowledgeBase>(`/knowledge/bases/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 删除知识库 */
|
||||
export async function deleteKB(id: string): Promise<boolean> {
|
||||
const res = await request<{ message: string }>(`/knowledge/bases/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return !res.error;
|
||||
}
|
||||
|
||||
// ========== 文档管理 ==========
|
||||
|
||||
/** 添加文档 (文本内容) */
|
||||
export async function addDocument(
|
||||
kbId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
sourceType: 'text' | 'url' = 'text',
|
||||
): Promise<KnowledgeDocument> {
|
||||
const res = await request<KnowledgeDocument>(`/knowledge/bases/${kbId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, content, source_type: sourceType }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 从文件添加文档 */
|
||||
export async function addDocumentFromFile(
|
||||
kbId: string,
|
||||
title: string,
|
||||
fileId: string,
|
||||
): Promise<KnowledgeDocument> {
|
||||
const res = await request<KnowledgeDocument>(`/knowledge/bases/${kbId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, source_type: 'file', file_id: fileId }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 列出文档 */
|
||||
export async function listDocuments(kbId: string): Promise<KnowledgeDocument[]> {
|
||||
const res = await request<DocListResponse>(`/knowledge/bases/${kbId}/documents`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data?.documents || [];
|
||||
}
|
||||
|
||||
/** 获取文档 */
|
||||
export async function getDocument(id: string): Promise<KnowledgeDocument> {
|
||||
const res = await request<KnowledgeDocument>(`/knowledge/documents/${id}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/** 删除文档 */
|
||||
export async function deleteDocument(id: string): Promise<boolean> {
|
||||
const res = await request<{ message: string }>(`/knowledge/documents/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return !res.error;
|
||||
}
|
||||
|
||||
// ========== 搜索 ==========
|
||||
|
||||
/** 搜索知识库 */
|
||||
export async function searchKnowledge(
|
||||
query: string,
|
||||
kbIds?: string[],
|
||||
limit?: number,
|
||||
): Promise<SearchResponse> {
|
||||
const res = await request<SearchResponse>(`/knowledge/search`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, kb_ids: kbIds, limit: limit || 10 }),
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// 提醒 API
|
||||
|
||||
import { request, type ApiResponse } from './client';
|
||||
|
||||
/** 提醒类型 */
|
||||
export interface Reminder {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
remind_at: string; // ISO 8601
|
||||
status: 'pending' | 'completed' | 'cancelled';
|
||||
created_at: string;
|
||||
completed_at?: string | null;
|
||||
repeat_type: 'none' | 'daily' | 'weekly' | 'monthly';
|
||||
session_id: string;
|
||||
notified: boolean;
|
||||
}
|
||||
|
||||
/** 创建提醒请求 */
|
||||
export interface CreateReminderRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
remind_at: string; // ISO 8601
|
||||
repeat_type?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
/** 更新提醒请求 */
|
||||
export interface UpdateReminderRequest {
|
||||
title?: string;
|
||||
description?: string;
|
||||
remind_at?: string;
|
||||
status?: 'pending' | 'completed' | 'cancelled';
|
||||
repeat_type?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
/** 提醒列表响应 */
|
||||
export interface ReminderListResponse {
|
||||
reminders: Reminder[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的提醒列表
|
||||
* @param userId 用户 ID (可选,不传则用当前登录用户)
|
||||
* @param status 状态筛选 (pending/completed/cancelled)
|
||||
* @param limit 分页大小
|
||||
*/
|
||||
export async function listReminders(
|
||||
userId?: string,
|
||||
status?: string,
|
||||
limit = 50
|
||||
): Promise<ApiResponse<ReminderListResponse>> {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set('user_id', userId);
|
||||
if (status) params.set('status', status);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
return request<ReminderListResponse>(`/reminders?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新提醒
|
||||
*/
|
||||
export async function createReminder(data: CreateReminderRequest): Promise<ApiResponse<{ success: boolean; reminder: Reminder }>> {
|
||||
return request<{ success: boolean; reminder: Reminder }>('/reminders', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新提醒
|
||||
*/
|
||||
export async function updateReminder(
|
||||
id: string,
|
||||
data: UpdateReminderRequest
|
||||
): Promise<ApiResponse<{ success: boolean; reminder: Reminder }>> {
|
||||
return request<{ success: boolean; reminder: Reminder }>(`/reminders/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除提醒
|
||||
*/
|
||||
export async function deleteReminder(id: string): Promise<ApiResponse<{ success: boolean }>> {
|
||||
return request<{ success: boolean }>(`/reminders/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消提醒 (等同于更新状态为 cancelled)
|
||||
*/
|
||||
export async function cancelReminder(id: string): Promise<ApiResponse<{ success: boolean; reminder: Reminder }>> {
|
||||
return updateReminder(id, { status: 'cancelled' });
|
||||
}
|
||||
@@ -108,3 +108,97 @@ export async function clearSessionMessages(sessionId: string): Promise<boolean>
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== 消息搜索 ==========
|
||||
|
||||
/** 单条搜索结果 */
|
||||
export interface SearchResult {
|
||||
message_id: number;
|
||||
session_id: string;
|
||||
session_title: string;
|
||||
role: string;
|
||||
content: string;
|
||||
created_at: number; // UnixMilli
|
||||
}
|
||||
|
||||
/** 搜索响应 */
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
total: number;
|
||||
query: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全文搜索消息
|
||||
* GET /api/v1/messages/search?q={query}&user_id={userId}&limit={limit}&offset={offset}
|
||||
*/
|
||||
export async function searchMessages(
|
||||
query: string,
|
||||
userId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<SearchResponse> {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
user_id: userId,
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
const resp = await request<SearchResponse>(
|
||||
`/messages/search?${params.toString()}`
|
||||
);
|
||||
if (resp.error) {
|
||||
console.error('[sessions] 搜索消息失败:', resp.error);
|
||||
return { results: [], total: 0, query, limit, offset };
|
||||
}
|
||||
return (resp.data as SearchResponse) || { results: [], total: 0, query, limit, offset };
|
||||
}
|
||||
|
||||
// ========== 导出 ==========
|
||||
|
||||
export type ExportFormat = 'json' | 'markdown' | 'txt';
|
||||
|
||||
/**
|
||||
* 导出会话为指定格式,触发浏览器下载
|
||||
* GET /api/v1/sessions/{sessionId}/export?format={format}
|
||||
*/
|
||||
export async function exportSession(
|
||||
sessionId: string,
|
||||
format: ExportFormat = 'json'
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem('token');
|
||||
const resp = await fetch(
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'}/sessions/${encodeURIComponent(sessionId)}/export?format=${format}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: '导出失败' }));
|
||||
throw new Error(err.error || `导出失败 (${resp.status})`);
|
||||
}
|
||||
|
||||
// 从 Content-Disposition 获取文件名,或生成默认名
|
||||
const disposition = resp.headers.get('Content-Disposition');
|
||||
let filename = `session_${sessionId}.${format}`;
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";\n]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
|
||||
// 触发浏览器下载
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// 语音识别 + TTS API
|
||||
import { request, type ApiResponse } from './client';
|
||||
|
||||
interface TranscribeResult {
|
||||
success: boolean;
|
||||
text: string;
|
||||
language: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
interface STTStatus {
|
||||
service: string;
|
||||
stt: {
|
||||
available: boolean;
|
||||
binary_available: boolean;
|
||||
model_loaded: boolean;
|
||||
binary_path: string;
|
||||
model_path: string;
|
||||
model_name: string;
|
||||
default_language: string;
|
||||
supported_languages: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TTSVoice {
|
||||
name: string;
|
||||
display_name: string;
|
||||
gender: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface TTSSynthesizeRequest {
|
||||
text: string;
|
||||
voice?: string;
|
||||
rate?: string;
|
||||
}
|
||||
|
||||
interface TTSStatus {
|
||||
service: string;
|
||||
tts: {
|
||||
available: boolean;
|
||||
edge_tts: boolean;
|
||||
espeak_ng: boolean;
|
||||
engine: string;
|
||||
default_voice: string;
|
||||
builtin_voices: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface VoiceFullStatus {
|
||||
service: string;
|
||||
stt: STTStatus['stt'];
|
||||
tts: TTSStatus['tts'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频文件转文字
|
||||
* @param audioBlob 音频文件 Blob
|
||||
* @param language 语言代码 (zh, en, ja, ko, auto),默认 zh
|
||||
*/
|
||||
async function transcribeAudio(audioBlob: Blob, language?: string): Promise<ApiResponse<TranscribeResult>> {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'audio.wav');
|
||||
if (language) {
|
||||
formData.append('language', language);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
// 不设置 Content-Type,让浏览器自动处理 multipart boundary
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8080/api/v1/voice/transcribe', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data?.error || `请求失败 (${response.status})`,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
return { data: data as TranscribeResult, status: response.status };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : '网络错误',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端 TTS 合成(返回 audio blob URL)
|
||||
* @returns 音频文件的 Object URL
|
||||
*/
|
||||
async function synthesizeSpeech(req: TTSSynthesizeRequest): Promise<string> {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch('http://localhost:8080/api/v1/voice/tts', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.error || `TTS 合成失败 (${response.status})`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用 TTS 语音列表
|
||||
*/
|
||||
async function getTTSVoices(): Promise<TTSVoice[]> {
|
||||
const response = await request<{ voices: TTSVoice[]; count: number }>('/voice/tts/voices');
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data?.voices ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 TTS 服务状态
|
||||
*/
|
||||
async function getTTSStatus(): Promise<TTSStatus> {
|
||||
const response = await request<TTSStatus>('/voice/tts/status');
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 STT 服务状态
|
||||
*/
|
||||
async function getSTTStatus(): Promise<ApiResponse<STTStatus>> {
|
||||
return request<STTStatus>('/voice/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音服务完整状态(STT + TTS)
|
||||
*/
|
||||
async function getVoiceFullStatus(): Promise<VoiceFullStatus> {
|
||||
const response = await request<VoiceFullStatus>('/voice/status');
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.data!;
|
||||
}
|
||||
|
||||
export { transcribeAudio, synthesizeSpeech, getSTTStatus, getTTSStatus, getTTSVoices, getVoiceFullStatus };
|
||||
export type { TranscribeResult, STTStatus, TTSVoice, TTSSynthesizeRequest, TTSStatus, VoiceFullStatus };
|
||||
@@ -1,28 +1,111 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import type { ChatMode } from '@/types/chat';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import type { ChatMode, MessageAttachment } from '@/types/chat';
|
||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition';
|
||||
import { uploadFile } from '@/api/files';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string, mode: ChatMode) => void;
|
||||
onSend: (content: string, mode: ChatMode, attachments?: MessageAttachment[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface PendingImage {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
id: string; // 临时 ID
|
||||
}
|
||||
|
||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const SUPPORTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'];
|
||||
const MAX_IMAGES = 5;
|
||||
|
||||
export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [mode, setMode] = useState<ChatMode>('text');
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const {
|
||||
isListening,
|
||||
isSupported,
|
||||
interimText,
|
||||
finalText,
|
||||
error,
|
||||
startListening,
|
||||
stopListening,
|
||||
resetText,
|
||||
} = useSpeechRecognition();
|
||||
|
||||
// 当 finalText 更新时,追加到输入框
|
||||
useEffect(() => {
|
||||
if (finalText) {
|
||||
setContent((prev) => {
|
||||
const trimmed = prev.trimEnd();
|
||||
return (trimmed ? trimmed + ' ' : '') + finalText;
|
||||
});
|
||||
resetText();
|
||||
}
|
||||
}, [finalText, resetText]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
const hasImages = pendingImages.length > 0;
|
||||
if ((!trimmed && !hasImages) || disabled || uploading) return;
|
||||
|
||||
onSend(trimmed, mode);
|
||||
let attachments: MessageAttachment[] | undefined;
|
||||
|
||||
if (hasImages) {
|
||||
setUploading(true);
|
||||
setUploadError('');
|
||||
|
||||
try {
|
||||
const uploadedAttachments: MessageAttachment[] = [];
|
||||
|
||||
for (const img of pendingImages) {
|
||||
try {
|
||||
const result = await uploadFile(img.file);
|
||||
uploadedAttachments.push({
|
||||
type: 'image',
|
||||
url: result.url,
|
||||
thumbnail_url: result.thumbnail_url,
|
||||
filename: result.filename,
|
||||
size: result.size,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ChatInput] 图片上传失败:', img.file.name, err);
|
||||
// 使用 data URL 作为降级
|
||||
uploadedAttachments.push({
|
||||
type: 'image',
|
||||
url: img.previewUrl,
|
||||
filename: img.file.name,
|
||||
size: img.file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedAttachments.length > 0) {
|
||||
attachments = uploadedAttachments;
|
||||
}
|
||||
} catch (err) {
|
||||
setUploadError('图片上传失败,请重试');
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
onSend(trimmed, mode, attachments);
|
||||
setContent('');
|
||||
setPendingImages([]);
|
||||
|
||||
// 重置文本框高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}, [content, mode, disabled, onSend]);
|
||||
}, [content, mode, disabled, onSend, pendingImages, uploading]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -30,8 +113,92 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
// Ctrl+Shift+V 触发语音输入
|
||||
if (e.key === 'V' && e.ctrlKey && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (isListening) {
|
||||
stopListening();
|
||||
} else {
|
||||
startListening();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
[handleSend, isListening, startListening, stopListening]
|
||||
);
|
||||
|
||||
// 粘贴图片
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
addImageFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 添加图片文件
|
||||
const addImageFile = useCallback(
|
||||
(file: File) => {
|
||||
setUploadError('');
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
setUploadError(`图片 "${file.name}" 超过 10MB 限制`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查文件类型
|
||||
if (!SUPPORTED_IMAGE_TYPES.includes(file.type)) {
|
||||
setUploadError(`不支持的图片格式: ${file.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查数量限制
|
||||
setPendingImages((prev) => {
|
||||
if (prev.length >= MAX_IMAGES) {
|
||||
setUploadError(`最多同时上传 ${MAX_IMAGES} 张图片`);
|
||||
return prev;
|
||||
}
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
return [...prev, { file, previewUrl, id: `img_${Date.now()}_${Math.random().toString(36).slice(2)}` }];
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 移除待上传图片
|
||||
const removeImage = useCallback((id: string) => {
|
||||
setPendingImages((prev) => {
|
||||
const img = prev.find((p) => p.id === id);
|
||||
if (img) {
|
||||
URL.revokeObjectURL(img.previewUrl);
|
||||
}
|
||||
return prev.filter((p) => p.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 文件选择
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
addImageFile(files[i]);
|
||||
}
|
||||
// 重置 input 以便再次选择相同文件
|
||||
e.target.value = '';
|
||||
},
|
||||
[addImageFile]
|
||||
);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
@@ -42,70 +209,231 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVoiceToggle = useCallback(() => {
|
||||
if (isListening) {
|
||||
stopListening();
|
||||
} else {
|
||||
startListening();
|
||||
}
|
||||
}, [isListening, startListening, stopListening]);
|
||||
|
||||
return (
|
||||
<div className="border-t border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm px-4 py-3">
|
||||
<div className="flex items-end gap-2 max-w-3xl mx-auto">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setMode('text')}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
mode === 'text'
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title="文字模式"
|
||||
<div className="flex flex-col gap-2 max-w-3xl mx-auto">
|
||||
{/* 实时识别文本提示 */}
|
||||
{isListening && interimText && (
|
||||
<div
|
||||
className="interim-text text-sm text-pink-500 dark:text-pink-400 italic px-1"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
💬
|
||||
{interimText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div
|
||||
className="text-xs text-red-500 dark:text-red-400 px-1"
|
||||
role="alert"
|
||||
>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传错误提示 */}
|
||||
{uploadError && (
|
||||
<div
|
||||
className="text-xs text-red-500 dark:text-red-400 px-1"
|
||||
role="alert"
|
||||
>
|
||||
⚠️ {uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片预览区 */}
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap px-1">
|
||||
{pendingImages.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="relative group w-16 h-16 rounded-lg overflow-hidden border border-pink-200 dark:border-pink-800 flex-shrink-0"
|
||||
>
|
||||
<img
|
||||
src={img.previewUrl}
|
||||
alt={img.file.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{!uploading && (
|
||||
<button
|
||||
onClick={() => removeImage(img.id)}
|
||||
className="absolute top-0.5 right-0.5 w-5 h-5 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
aria-label="移除图片"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-3.5 h-3.5">
|
||||
<path fillRule="evenodd" d="M5.47 5.47a.75.75 0 011.06 0L12 10.94l5.47-5.47a.75.75 0 111.06 1.06L13.06 12l5.47 5.47a.75.75 0 11-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 01-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 010-1.06z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
{/* 模式切换 */}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setMode('text')}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
mode === 'text'
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title="文字模式"
|
||||
>
|
||||
💬
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('voice_msg')}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
mode === 'voice_msg'
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title="语音消息"
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 图片上传按钮 */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || uploading}
|
||||
className="p-2 rounded-lg text-xs transition-colors text-gray-400 hover:text-pink-500 hover:bg-pink-50 dark:hover:bg-pink-900/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="上传图片"
|
||||
aria-label="上传图片"
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* 输入框 */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onInput={handleInput}
|
||||
placeholder="和昔涟说点什么吧... 支持粘贴图片"
|
||||
disabled={disabled || uploading}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{/* 语音输入按钮 (仅浏览器支持时显示) */}
|
||||
{isSupported && (
|
||||
<button
|
||||
onClick={handleVoiceToggle}
|
||||
disabled={disabled || uploading}
|
||||
aria-label={isListening ? '停止语音输入' : '开始语音输入'}
|
||||
aria-pressed={isListening}
|
||||
title={isListening ? '停止聆听 (Ctrl+Shift+V)' : '语音输入 (Ctrl+Shift+V)'}
|
||||
className={`p-2 rounded-xl transition-all flex-shrink-0 border-2 ${
|
||||
isListening
|
||||
? 'voice-btn-active bg-red-500 border-red-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-300'
|
||||
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||
<path d="M7.5 11a4.5 4.5 0 0 0 9 0h1.5a6 6 0 0 1-5.25 5.95V20h3.75v1.5h-9v-1.5h3.75v-2.05A6 6 0 0 1 6 11h1.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 不支持时显示禁用按钮 */}
|
||||
{!isSupported && (
|
||||
<button
|
||||
disabled
|
||||
title="您的浏览器不支持语音识别"
|
||||
className="p-2 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-300 dark:text-gray-600 flex-shrink-0 cursor-not-allowed"
|
||||
aria-label="语音输入不可用"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||
<path d="M7.5 11a4.5 4.5 0 0 0 9 0h1.5a6 6 0 0 1-5.25 5.95V20h3.75v1.5h-9v-1.5h3.75v-2.05A6 6 0 0 1 6 11h1.5Z" />
|
||||
<line x1="4" y1="4" x2="20" y2="20" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={() => setMode('voice_msg')}
|
||||
className={`p-2 rounded-lg text-xs transition-colors ${
|
||||
mode === 'voice_msg'
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title="语音消息"
|
||||
onClick={handleSend}
|
||||
disabled={disabled || uploading || (!content.trim() && pendingImages.length === 0)}
|
||||
className="p-2 rounded-xl bg-pink-400 text-white hover:bg-pink-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"
|
||||
>
|
||||
🎤
|
||||
{uploading ? (
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 输入框 */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder="和昔涟说点什么吧..."
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
{/* 语音输入状态提示 */}
|
||||
{isListening && (
|
||||
<p className="text-xs text-red-400 text-center animate-pulse">
|
||||
🎤 正在聆听...
|
||||
<span className="text-gray-400 ml-2">(Ctrl+Shift+V 停止)</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !content.trim()}
|
||||
className="p-2 rounded-xl bg-pink-400 text-white hover:bg-pink-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
|
||||
</svg>
|
||||
</button>
|
||||
{mode !== 'text' && !isListening && (
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
{mode === 'voice_msg' ? '语音消息功能即将上线 ♪' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode !== 'text' && (
|
||||
<p className="text-xs text-gray-400 text-center mt-2">
|
||||
{mode === 'voice_msg' ? '语音消息功能即将上线 ♪' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import type { MessageAttachment } from '@/types/chat';
|
||||
|
||||
interface ImageLightboxProps {
|
||||
attachments: MessageAttachment[];
|
||||
currentIndex: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImageLightbox({ attachments, currentIndex, onClose }: ImageLightboxProps) {
|
||||
const [index, setIndex] = useState(currentIndex);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const current = attachments[index];
|
||||
const hasMultiple = attachments.length > 1;
|
||||
|
||||
// 键盘导航
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (hasMultiple) {
|
||||
setIndex((prev) => (prev - 1 + attachments.length) % attachments.length);
|
||||
setImgLoaded(false);
|
||||
setImgError(false);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (hasMultiple) {
|
||||
setIndex((prev) => (prev + 1) % attachments.length);
|
||||
setImgLoaded(false);
|
||||
setImgError(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onClose, hasMultiple, attachments.length]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// 点击背景关闭
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// 下载当前图片
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!current?.url) return;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const resp = await fetch(current.url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!resp.ok) throw new Error('Download failed');
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = current.filename || 'image';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('[ImageLightbox] 下载失败:', err);
|
||||
// 降级:在新窗口打开
|
||||
window.open(current.url, '_blank');
|
||||
}
|
||||
}, [current]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="图片预览"
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 左箭头 */}
|
||||
{hasMultiple && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIndex((prev) => (prev - 1 + attachments.length) % attachments.length);
|
||||
setImgLoaded(false);
|
||||
setImgError(false);
|
||||
}}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
aria-label="上一张"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 右箭头 */}
|
||||
{hasMultiple && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIndex((prev) => (prev + 1) % attachments.length);
|
||||
setImgLoaded(false);
|
||||
setImgError(false);
|
||||
}}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
aria-label="下一张"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 图片容器 */}
|
||||
<div className="flex flex-col items-center max-w-[90vw] max-h-[90vh] gap-4">
|
||||
{/* 加载中状态 */}
|
||||
{!imgLoaded && !imgError && (
|
||||
<div className="flex items-center justify-center w-64 h-64 text-white/60">
|
||||
<svg className="animate-spin h-10 w-10" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载失败 */}
|
||||
{imgError && (
|
||||
<div className="flex flex-col items-center gap-2 text-white/60">
|
||||
<span className="text-4xl">🖼️</span>
|
||||
<span className="text-sm">图片加载失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
<img
|
||||
src={current.url}
|
||||
alt={current.filename || '图片'}
|
||||
className={`max-w-[85vw] max-h-[70vh] object-contain rounded-lg shadow-2xl ${imgLoaded ? 'block' : 'hidden'}`}
|
||||
onLoad={() => setImgLoaded(true)}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
|
||||
{/* 底部信息栏 */}
|
||||
<div className="flex flex-col items-center gap-1 text-white/80 text-sm">
|
||||
{/* 计数器 */}
|
||||
{hasMultiple && (
|
||||
<span className="text-white/50 text-xs">
|
||||
{index + 1} / {attachments.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 文件名 */}
|
||||
{current.filename && (
|
||||
<span className="text-white/70 text-xs">{current.filename}</span>
|
||||
)}
|
||||
|
||||
{/* 尺寸信息 */}
|
||||
{current.width && current.height && (
|
||||
<span className="text-white/50 text-xs">
|
||||
{current.width} × {current.height}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* AI 描述 */}
|
||||
{current.description && (
|
||||
<p className="text-white/80 text-xs text-center max-w-lg mt-1 px-4">
|
||||
{current.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-white text-xs transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4">
|
||||
<path fillRule="evenodd" d="M12 2.25a.75.75 0 01.75.75v11.69l3.22-3.22a.75.75 0 111.06 1.06l-4.5 4.5a.75.75 0 01-1.06 0l-4.5-4.5a.75.75 0 111.06-1.06l3.22 3.22V3a.75.75 0 01.75-.75zm-9 13.5a.75.75 0 01.75.75v2.25a1.5 1.5 0 001.5 1.5h13.5a1.5 1.5 0 001.5-1.5V16.5a.75.75 0 011.5 0v2.25a3 3 0 01-3 3H5.25a3 3 0 01-3-3V16.5a.75.75 0 01.75-.75z" clipRule="evenodd" />
|
||||
</svg>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
|
||||
import type { MessageAttachment } from '@/types/chat';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
attachments?: MessageAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,8 +76,70 @@ function useTypewriter(content: string, isStreaming: boolean): string {
|
||||
return displayed;
|
||||
}
|
||||
|
||||
export function MessageBubble({ role, content, timestamp, isStreaming }: MessageBubbleProps) {
|
||||
/**
|
||||
* AI 消息的操作栏 — 包含 TTS 朗读按钮
|
||||
*/
|
||||
function AIMessageActions({ content }: { content: string }) {
|
||||
const { isSpeaking, isSupported, speak, stop } = useSpeechSynthesis();
|
||||
const [isThisSpeaking, setIsThisSpeaking] = useState(false);
|
||||
const contentRef = useRef(content);
|
||||
|
||||
// 当全局 TTS 正在朗读且对应的是本条消息时,设置 isThisSpeaking
|
||||
useEffect(() => {
|
||||
if (!isSpeaking) {
|
||||
setIsThisSpeaking(false);
|
||||
}
|
||||
}, [isSpeaking]);
|
||||
|
||||
const handleToggleTTS = useCallback(() => {
|
||||
if (isThisSpeaking) {
|
||||
stop();
|
||||
setIsThisSpeaking(false);
|
||||
} else {
|
||||
setIsThisSpeaking(true);
|
||||
speak(content, { lang: 'zh-CN' });
|
||||
// 朗读结束后重置
|
||||
const checkEnd = setInterval(() => {
|
||||
if (!window.speechSynthesis.speaking) {
|
||||
setIsThisSpeaking(false);
|
||||
clearInterval(checkEnd);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, [content, isThisSpeaking, speak, stop]);
|
||||
|
||||
if (!isSupported) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1.5">
|
||||
<button
|
||||
onClick={handleToggleTTS}
|
||||
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-all duration-200 ${
|
||||
isThisSpeaking
|
||||
? 'bg-pink-100 text-pink-600 tts-playing'
|
||||
: 'text-gray-400 hover:text-pink-500 hover:bg-pink-50'
|
||||
}`}
|
||||
title={isThisSpeaking ? '停止朗读' : '朗读此消息'}
|
||||
>
|
||||
{isThisSpeaking ? (
|
||||
<>
|
||||
<span className="text-sm">⏹</span>
|
||||
<span>停止</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm">🔊</span>
|
||||
<span>朗读</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ role, content, timestamp, isStreaming, attachments }: MessageBubbleProps) {
|
||||
const isUser = role === 'user';
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
@@ -84,6 +150,9 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
|
||||
// 判断是否还有未显示完的字符
|
||||
const hasMoreChars = isStreaming && displayedContent.length < content.length;
|
||||
|
||||
// 图片附件
|
||||
const imageAttachments = attachments?.filter((a) => a.type === 'image') ?? [];
|
||||
|
||||
return (
|
||||
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
{/* 头像 */}
|
||||
@@ -110,23 +179,114 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
|
||||
<span className="animate-streaming-cursor" />
|
||||
)}
|
||||
</p>
|
||||
{!isStreaming && (
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
isUser ? 'text-pink-100' : 'text-gray-400'
|
||||
|
||||
{/* 图片附件网格 */}
|
||||
{!isStreaming && imageAttachments.length > 0 && (
|
||||
<div
|
||||
className={`grid gap-1.5 mt-2 ${
|
||||
imageAttachments.length === 1
|
||||
? 'grid-cols-1'
|
||||
: imageAttachments.length === 2
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-3'
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
</p>
|
||||
{imageAttachments.map((att, idx) => (
|
||||
<ImageThumbnail
|
||||
key={idx}
|
||||
attachment={att}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isStreaming && (
|
||||
<>
|
||||
{/* AI 消息操作栏(朗读按钮) */}
|
||||
{!isUser && <AIMessageActions content={content} />}
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
isUser ? 'text-pink-100' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 用户头像 */}
|
||||
{isUser && <UserAvatar />}
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
attachments={imageAttachments}
|
||||
currentIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 图片缩略图组件 */
|
||||
function ImageThumbnail({
|
||||
attachment,
|
||||
onClick,
|
||||
}: {
|
||||
attachment: MessageAttachment;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const url = attachment.thumbnail_url || attachment.url;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="relative w-full aspect-square rounded-lg overflow-hidden border border-pink-100 dark:border-pink-800 bg-gray-100 dark:bg-gray-700 hover:ring-2 hover:ring-pink-400 transition-all cursor-pointer group"
|
||||
aria-label={`查看图片: ${attachment.filename || '未命名图片'}`}
|
||||
>
|
||||
{/* 加载中 */}
|
||||
{!loaded && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载失败 */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
<span className="text-2xl">🖼️</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
<img
|
||||
src={url}
|
||||
alt={attachment.filename || '图片'}
|
||||
className={`w-full h-full object-cover transition-transform group-hover:scale-105 ${loaded ? 'block' : 'hidden'}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
|
||||
{/* 悬停遮罩 */}
|
||||
{loaded && (
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<path fillRule="evenodd" d="M15 3.75a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0V5.56l-3.97 3.97a.75.75 0 11-1.06-1.06l3.97-3.97h-3.69a.75.75 0 010-1.5h4.5zM5.25 6.75a3 3 0 00-3 3v7.5a3 3 0 003 3h13.5a3 3 0 003-3v-7.5a3 3 0 00-3-3H15a.75.75 0 000 1.5h3.75a1.5 1.5 0 011.5 1.5v7.5a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-7.5a1.5 1.5 0 011.5-1.5H9a.75.75 0 000-1.5H5.25z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户头像组件:管理员使用 Admin_Avatar.jpg,普通用户使用 Default_Avatar.png */
|
||||
function UserAvatar() {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
@@ -39,6 +39,7 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
content={msg.content}
|
||||
timestamp={msg.timestamp}
|
||||
isStreaming={msg.isStreaming}
|
||||
attachments={msg.attachments}
|
||||
/>
|
||||
))}
|
||||
{isTyping && !messages.some((m) => m.isStreaming) && <TypingIndicator />}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { SearchModal } from './SearchModal';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -9,6 +10,7 @@ interface AppLayoutProps {
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
return (
|
||||
@@ -36,9 +38,17 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{isLoggedIn && <Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />}
|
||||
{isLoggedIn && (
|
||||
<Header
|
||||
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
onSearchClick={() => setSearchOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<main className="flex-1 min-h-0 overflow-hidden">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* 搜索弹窗 */}
|
||||
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
listRules,
|
||||
listScenes,
|
||||
createRule,
|
||||
createScene,
|
||||
updateRule,
|
||||
updateScene,
|
||||
deleteRule,
|
||||
deleteScene,
|
||||
triggerRule,
|
||||
executeScene,
|
||||
toggleRule,
|
||||
type AutomationRule,
|
||||
type AutomationScene,
|
||||
} from '@/api/automation';
|
||||
|
||||
interface AutomationPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 触发类型中文映射 */
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
schedule: '⏰ 定时',
|
||||
device_state: '📡 设备状态',
|
||||
manual: '🖐️ 手动',
|
||||
};
|
||||
|
||||
/** 触发类型颜色 */
|
||||
const TRIGGER_COLORS: Record<string, string> = {
|
||||
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
device_state: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
manual: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
};
|
||||
|
||||
/** JSON 安全格式化 */
|
||||
function safeJSON(raw: unknown, fallback = '-'): string {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
if (typeof raw === 'string') return raw;
|
||||
return JSON.stringify(raw, null, 1);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认动作模板 */
|
||||
const DEFAULT_ACTIONS = [
|
||||
{
|
||||
type: 'set_device',
|
||||
device_id: '',
|
||||
property: '',
|
||||
value: '',
|
||||
},
|
||||
];
|
||||
|
||||
/** 默认定时触发配置模板 */
|
||||
const DEFAULT_SCHEDULE_TRIGGER = {
|
||||
time: '08:00',
|
||||
days: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||
};
|
||||
|
||||
export function AutomationPanel({ onClose }: AutomationPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<'rules' | 'scenes'>('rules');
|
||||
const [viewMode, setViewMode] = useState<'list' | 'create' | 'edit'>('list');
|
||||
|
||||
// 数据
|
||||
const [rules, setRules] = useState<AutomationRule[]>([]);
|
||||
const [scenes, setScenes] = useState<AutomationScene[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
|
||||
// 规则表单
|
||||
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formTriggerType, setFormTriggerType] = useState('schedule');
|
||||
const [formTriggerConfig, setFormTriggerConfig] = useState(JSON.stringify(DEFAULT_SCHEDULE_TRIGGER, null, 2));
|
||||
const [formConditions, setFormConditions] = useState('');
|
||||
const [formActions, setFormActions] = useState(JSON.stringify(DEFAULT_ACTIONS, null, 2));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 场景表单
|
||||
const [editingSceneId, setEditingSceneId] = useState<string | null>(null);
|
||||
const [sceneFormName, setSceneFormName] = useState('');
|
||||
const [sceneFormIcon, setSceneFormIcon] = useState('🏠');
|
||||
const [sceneFormRuleIds, setSceneFormRuleIds] = useState('');
|
||||
const [sceneSubmitting, setSceneSubmitting] = useState(false);
|
||||
|
||||
// 展开的规则详情
|
||||
const [expandedRuleId, setExpandedRuleId] = useState<string | null>(null);
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccessMsg(msg);
|
||||
setTimeout(() => setSuccessMsg(''), 2000);
|
||||
};
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const [rulesResp, scenesResp] = await Promise.all([listRules(), listScenes()]);
|
||||
if (rulesResp.error) {
|
||||
setError(rulesResp.error);
|
||||
} else {
|
||||
setRules(rulesResp.data?.rules ?? []);
|
||||
}
|
||||
if (scenesResp.error) {
|
||||
// 不影响规则列表显示
|
||||
} else {
|
||||
setScenes(scenesResp.data?.scenes ?? []);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 重置规则表单
|
||||
const resetRuleForm = () => {
|
||||
setEditingRuleId(null);
|
||||
setFormName('');
|
||||
setFormDesc('');
|
||||
setFormTriggerType('schedule');
|
||||
setFormTriggerConfig(JSON.stringify(DEFAULT_SCHEDULE_TRIGGER, null, 2));
|
||||
setFormConditions('');
|
||||
setFormActions(JSON.stringify(DEFAULT_ACTIONS, null, 2));
|
||||
};
|
||||
|
||||
// 打开规则编辑
|
||||
const openRuleEdit = (rule: AutomationRule) => {
|
||||
setEditingRuleId(rule.id);
|
||||
setFormName(rule.name);
|
||||
setFormDesc(rule.description || '');
|
||||
setFormTriggerType(rule.trigger_type);
|
||||
setFormTriggerConfig(safeJSON(rule.trigger_config, '{}'));
|
||||
setFormConditions(safeJSON(rule.conditions, ''));
|
||||
setFormActions(safeJSON(rule.actions, '[]'));
|
||||
setViewMode('edit');
|
||||
};
|
||||
|
||||
// 提交规则表单
|
||||
const handleRuleSubmit = async () => {
|
||||
if (!formName.trim()) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
let triggerConfig: unknown = undefined;
|
||||
let conditions: unknown = undefined;
|
||||
let actions: unknown = undefined;
|
||||
|
||||
try {
|
||||
if (formTriggerConfig.trim()) triggerConfig = JSON.parse(formTriggerConfig);
|
||||
} catch {
|
||||
setError('触发配置 JSON 格式错误');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (formConditions.trim()) conditions = JSON.parse(formConditions);
|
||||
} catch {
|
||||
setError('条件配置 JSON 格式错误');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
actions = JSON.parse(formActions);
|
||||
} catch {
|
||||
setError('动作配置 JSON 格式错误');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingRuleId) {
|
||||
const resp = await updateRule(editingRuleId, {
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
trigger_type: formTriggerType,
|
||||
trigger_config: triggerConfig,
|
||||
conditions,
|
||||
actions,
|
||||
});
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('规则已更新');
|
||||
resetRuleForm();
|
||||
setViewMode('list');
|
||||
loadData();
|
||||
}
|
||||
} else {
|
||||
const resp = await createRule({
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
trigger_type: formTriggerType,
|
||||
trigger_config: triggerConfig,
|
||||
conditions,
|
||||
actions,
|
||||
});
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('规则已创建');
|
||||
resetRuleForm();
|
||||
setViewMode('list');
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
// 删除规则
|
||||
const handleDeleteRule = async (id: string) => {
|
||||
if (!confirm('确定要删除这条规则吗?')) return;
|
||||
const resp = await deleteRule(id);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('规则已删除');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// 手动触发规则
|
||||
const handleTriggerRule = async (id: string) => {
|
||||
const resp = await triggerRule(id);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('规则已触发');
|
||||
}
|
||||
};
|
||||
|
||||
// 切换规则启用状态
|
||||
const handleToggleRule = async (rule: AutomationRule) => {
|
||||
const resp = await toggleRule(rule.id, !rule.enabled);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess(rule.enabled ? '规则已禁用' : '规则已启用');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// 重置场景表单
|
||||
const resetSceneForm = () => {
|
||||
setEditingSceneId(null);
|
||||
setSceneFormName('');
|
||||
setSceneFormIcon('🏠');
|
||||
setSceneFormRuleIds('');
|
||||
};
|
||||
|
||||
// 打开场景编辑
|
||||
const openSceneEdit = (scene: AutomationScene) => {
|
||||
setEditingSceneId(scene.id);
|
||||
setSceneFormName(scene.name);
|
||||
setSceneFormIcon(scene.icon || '🏠');
|
||||
setSceneFormRuleIds(safeJSON(scene.rule_ids, ''));
|
||||
setViewMode('edit');
|
||||
};
|
||||
|
||||
// 提交场景表单
|
||||
const handleSceneSubmit = async () => {
|
||||
if (!sceneFormName.trim()) return;
|
||||
setSceneSubmitting(true);
|
||||
setError('');
|
||||
|
||||
let ruleIds: string[] | undefined = undefined;
|
||||
try {
|
||||
if (sceneFormRuleIds.trim()) {
|
||||
ruleIds = JSON.parse(sceneFormRuleIds);
|
||||
}
|
||||
} catch {
|
||||
setError('规则 ID 列表 JSON 格式错误');
|
||||
setSceneSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingSceneId) {
|
||||
const resp = await updateScene(editingSceneId, {
|
||||
name: sceneFormName.trim(),
|
||||
icon: sceneFormIcon,
|
||||
rule_ids: ruleIds,
|
||||
});
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('场景已更新');
|
||||
resetSceneForm();
|
||||
setViewMode('list');
|
||||
loadData();
|
||||
}
|
||||
} else {
|
||||
const resp = await createScene({
|
||||
name: sceneFormName.trim(),
|
||||
icon: sceneFormIcon,
|
||||
rule_ids: ruleIds,
|
||||
});
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('场景已创建');
|
||||
resetSceneForm();
|
||||
setViewMode('list');
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
setSceneSubmitting(false);
|
||||
};
|
||||
|
||||
// 删除场景
|
||||
const handleDeleteScene = async (id: string) => {
|
||||
if (!confirm('确定要删除这个场景吗?')) return;
|
||||
const resp = await deleteScene(id);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('场景已删除');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// 执行场景
|
||||
const handleExecuteScene = async (id: string) => {
|
||||
const resp = await executeScene(id);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('场景已执行');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取规则名称映射
|
||||
const ruleNameMap: Record<string, string> = {};
|
||||
rules.forEach((r) => { ruleNameMap[r.id] = r.name; });
|
||||
|
||||
// 解析场景中的规则列表
|
||||
const getSceneRuleNames = (scene: AutomationScene): string[] => {
|
||||
if (!scene.rule_ids) return [];
|
||||
try {
|
||||
const ids: string[] = typeof scene.rule_ids === 'string'
|
||||
? JSON.parse(scene.rule_ids)
|
||||
: (scene.rule_ids as string[]);
|
||||
return ids.map((id) => ruleNameMap[id] || id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-h-full flex flex-col">
|
||||
{/* 顶部 Tab 切换 */}
|
||||
<div className="flex items-center border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => { setActiveTab('rules'); setViewMode('list'); }}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === 'rules'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
规则
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('scenes'); setViewMode('list'); }}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === 'scenes'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
场景
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{successMsg && (
|
||||
<div className="px-3 py-2 text-xs text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400">
|
||||
✅ {successMsg}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-xs text-red-500 bg-red-50 dark:bg-red-900/20">
|
||||
⚠️ {error}
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
className="ml-2 underline hover:no-underline"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 规则面板 ========== */}
|
||||
{activeTab === 'rules' && (
|
||||
<>
|
||||
{/* 列表模式 */}
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-50 dark:border-gray-700">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{rules.length} 条规则
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { resetRuleForm(); setViewMode('create'); }}
|
||||
className="px-2.5 py-1 text-[11px] bg-pink-500 text-white rounded-full hover:bg-pink-600 transition-colors"
|
||||
>
|
||||
+ 新建规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-72">
|
||||
{loading ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
⏳ 加载中...
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
⚡ 暂无自动化规则,点击「+ 新建规则」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleRule(rule)}
|
||||
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors flex-shrink-0 ${
|
||||
rule.enabled ? 'bg-pink-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full bg-white transition-transform ${
|
||||
rule.enabled ? 'translate-x-[18px]' : 'translate-x-[2px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className={`text-sm truncate ${rule.enabled ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400'}`}>
|
||||
{rule.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${TRIGGER_COLORS[rule.trigger_type] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{TRIGGER_LABELS[rule.trigger_type] || rule.trigger_type}
|
||||
</span>
|
||||
{rule.last_triggered_at && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
上次: {formatTime(rule.last_triggered_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{rule.trigger_type === 'manual' && (
|
||||
<button
|
||||
onClick={() => handleTriggerRule(rule.id)}
|
||||
title="手动触发"
|
||||
className="p-1 text-gray-300 hover:text-green-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openRuleEdit(rule)}
|
||||
title="编辑"
|
||||
className="p-1 text-gray-300 hover:text-blue-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
title="删除"
|
||||
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开详情 */}
|
||||
{expandedRuleId === rule.id && (
|
||||
<div className="mt-2 pl-8 space-y-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{rule.description && (
|
||||
<p>📝 {rule.description}</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap font-mono text-[10px] bg-gray-50 dark:bg-gray-750 p-1 rounded">
|
||||
触发: {safeJSON(rule.trigger_config)}
|
||||
</p>
|
||||
{rule.conditions != null && (
|
||||
<p className="whitespace-pre-wrap font-mono text-[10px] bg-gray-50 dark:bg-gray-750 p-1 rounded">
|
||||
条件: {safeJSON(rule.conditions)}
|
||||
</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap font-mono text-[10px] bg-gray-50 dark:bg-gray-750 p-1 rounded">
|
||||
动作: {safeJSON(rule.actions)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedRuleId(expandedRuleId === rule.id ? null : rule.id)}
|
||||
className="mt-1 ml-8 text-[10px] text-pink-400 hover:text-pink-500"
|
||||
>
|
||||
{expandedRuleId === rule.id ? '收起 ▲' : '展开 ▼'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑规则表单 */}
|
||||
{(viewMode === 'create' || viewMode === 'edit') && (
|
||||
<div className="p-3 space-y-3 overflow-y-auto max-h-80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{editingRuleId ? '编辑规则' : '新建规则'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setViewMode('list'); resetRuleForm(); }}
|
||||
className="text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="例如:夜间自动关灯"
|
||||
maxLength={200}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">描述</label>
|
||||
<textarea
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
placeholder="规则的用途说明"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">触发类型</label>
|
||||
<select
|
||||
value={formTriggerType}
|
||||
onChange={(e) => setFormTriggerType(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
>
|
||||
<option value="schedule">定时 (schedule)</option>
|
||||
<option value="device_state">设备状态 (device_state)</option>
|
||||
<option value="manual">手动 (manual)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">触发配置 (JSON)</label>
|
||||
<textarea
|
||||
value={formTriggerConfig}
|
||||
onChange={(e) => setFormTriggerConfig(e.target.value)}
|
||||
placeholder='{"time":"08:00","days":["mon","tue"]}'
|
||||
rows={3}
|
||||
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">条件 (JSON, 可选)</label>
|
||||
<textarea
|
||||
value={formConditions}
|
||||
onChange={(e) => setFormConditions(e.target.value)}
|
||||
placeholder='[{"type":"time_range","start":"22:00","end":"06:00"}]'
|
||||
rows={3}
|
||||
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">动作 (JSON) *</label>
|
||||
<textarea
|
||||
value={formActions}
|
||||
onChange={(e) => setFormActions(e.target.value)}
|
||||
placeholder='[{"type":"set_device","device_id":"","property":"","value":""}]'
|
||||
rows={4}
|
||||
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setViewMode('list'); resetRuleForm(); }}
|
||||
className="flex-1 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRuleSubmit}
|
||||
disabled={submitting || !formName.trim() || !formActions.trim()}
|
||||
className="flex-1 py-1.5 text-xs bg-pink-500 text-white rounded-lg hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting ? '保存中...' : editingRuleId ? '更新规则' : '创建规则'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ========== 场景面板 ========== */}
|
||||
{activeTab === 'scenes' && (
|
||||
<>
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-50 dark:border-gray-700">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{scenes.length} 个场景
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { resetSceneForm(); setViewMode('create'); }}
|
||||
className="px-2.5 py-1 text-[11px] bg-pink-500 text-white rounded-full hover:bg-pink-600 transition-colors"
|
||||
>
|
||||
+ 新建场景
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-72">
|
||||
{loading ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
⏳ 加载中...
|
||||
</div>
|
||||
) : scenes.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🎬 暂无场景,点击「+ 新建场景」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{scenes.map((scene) => {
|
||||
const ruleNames = getSceneRuleNames(scene);
|
||||
return (
|
||||
<div key={scene.id} className="px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{scene.icon || '🏠'}</span>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
|
||||
{scene.name}
|
||||
</span>
|
||||
</div>
|
||||
{ruleNames.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1 ml-8">
|
||||
{ruleNames.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-pink-50 dark:bg-pink-900/20 text-pink-600 dark:text-pink-400 rounded-full"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400 ml-8 mt-1 block">
|
||||
创建于 {formatTime(scene.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleExecuteScene(scene.id)}
|
||||
title="执行场景"
|
||||
className="p-1 text-gray-300 hover:text-green-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openSceneEdit(scene)}
|
||||
title="编辑"
|
||||
className="p-1 text-gray-300 hover:text-blue-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteScene(scene.id)}
|
||||
title="删除"
|
||||
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑场景表单 */}
|
||||
{(viewMode === 'create' || viewMode === 'edit') && (
|
||||
<div className="p-3 space-y-3 overflow-y-auto max-h-80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{editingSceneId ? '编辑场景' : '新建场景'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setViewMode('list'); resetSceneForm(); }}
|
||||
className="text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sceneFormName}
|
||||
onChange={(e) => setSceneFormName(e.target.value)}
|
||||
placeholder="例如:回家模式"
|
||||
maxLength={200}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">图标</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sceneFormIcon}
|
||||
onChange={(e) => setSceneFormIcon(e.target.value)}
|
||||
placeholder="🏠"
|
||||
maxLength={10}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
规则 ID 列表 (JSON 数组)
|
||||
</label>
|
||||
<textarea
|
||||
value={sceneFormRuleIds}
|
||||
onChange={(e) => setSceneFormRuleIds(e.target.value)}
|
||||
placeholder='["rule_id_1","rule_id_2"]'
|
||||
rows={3}
|
||||
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
{rules.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<span className="text-[10px] text-gray-400">可用规则:</span>
|
||||
{rules.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => {
|
||||
try {
|
||||
const ids: string[] = sceneFormRuleIds.trim()
|
||||
? JSON.parse(sceneFormRuleIds)
|
||||
: [];
|
||||
if (!ids.includes(r.id)) {
|
||||
setSceneFormRuleIds(JSON.stringify([...ids, r.id]));
|
||||
}
|
||||
} catch {
|
||||
setSceneFormRuleIds(JSON.stringify([r.id]));
|
||||
}
|
||||
}}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded hover:bg-pink-100 dark:hover:bg-pink-900/30 transition-colors"
|
||||
title={r.id}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setViewMode('list'); resetSceneForm(); }}
|
||||
className="flex-1 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSceneSubmit}
|
||||
disabled={sceneSubmitting || !sceneFormName.trim()}
|
||||
className="flex-1 py-1.5 text-xs bg-pink-500 text-white rounded-lg hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{sceneSubmitting ? '保存中...' : editingSceneId ? '更新场景' : '创建场景'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getBriefing, getLatestBriefings, generateBriefing, formatBriefingDate, type Briefing } from '@/api/briefings';
|
||||
|
||||
interface BriefingPanelProps {
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 天气 emoji 映射 */
|
||||
function weatherIcon(icon: string): string {
|
||||
return icon || '🌤️';
|
||||
}
|
||||
|
||||
/** 格式化提醒时间 */
|
||||
function formatRemindTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取今天的日期字符串 */
|
||||
function todayStr(): string {
|
||||
const d = new Date();
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function BriefingPanel({ userId, onClose }: BriefingPanelProps) {
|
||||
const [briefing, setBriefing] = useState<Briefing | null>(null);
|
||||
const [history, setHistory] = useState<Briefing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedDate, setSelectedDate] = useState(todayStr());
|
||||
const [viewMode, setViewMode] = useState<'today' | 'history'>('today');
|
||||
|
||||
// 加载今日简报
|
||||
useEffect(() => {
|
||||
loadBriefing(todayStr());
|
||||
loadHistory();
|
||||
}, [userId]);
|
||||
|
||||
const loadBriefing = async (date: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await getBriefing(userId, date);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
setBriefing(null);
|
||||
} else if (resp.data?.briefing) {
|
||||
setBriefing(resp.data.briefing);
|
||||
} else {
|
||||
setBriefing(null);
|
||||
}
|
||||
} catch {
|
||||
setError('获取简报失败');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const resp = await getLatestBriefings(userId, 7);
|
||||
if (resp.data?.briefings) {
|
||||
setHistory(resp.data.briefings);
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await generateBriefing(userId);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else if (resp.data?.success && resp.data.briefing) {
|
||||
setBriefing(resp.data.briefing);
|
||||
// 刷新历史
|
||||
loadHistory();
|
||||
} else {
|
||||
setError(resp.data?.error || '生成简报失败');
|
||||
}
|
||||
} catch {
|
||||
setError('生成简报请求失败');
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const handleDateChange = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
loadBriefing(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full">
|
||||
{/* 标签页切换 */}
|
||||
<div className="flex border-b border-gray-100 dark:border-gray-700 shrink-0">
|
||||
<button
|
||||
onClick={() => setViewMode('today')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
viewMode === 'today'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📋 今日简报
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('history')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
viewMode === 'history'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📅 历史简报
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 今日简报视图 */}
|
||||
{viewMode === 'today' && (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-pink-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-red-400 mb-2">{error}</p>
|
||||
<button
|
||||
onClick={loadBriefing.bind(null, todayStr())}
|
||||
className="text-xs text-pink-500 hover:text-pink-600"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : briefing ? (
|
||||
<BriefingCard briefing={briefing} />
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-400 text-sm mb-3">今日简报尚未生成</p>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="px-4 py-2 bg-pink-500 hover:bg-pink-600 disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
{generating ? '生成中...' : '✨ 生成今日简报'}
|
||||
</button>
|
||||
<p className="text-[10px] text-gray-400 mt-2">
|
||||
简报会在每天早上自动生成
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已生成时可手动重新生成 */}
|
||||
{briefing && (
|
||||
<div className="pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="w-full py-1.5 text-xs text-pink-500 hover:text-pink-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{generating ? '生成中...' : '🔄 重新生成简报'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 历史简报视图 */}
|
||||
{viewMode === 'history' && (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
暂无历史简报
|
||||
</div>
|
||||
) : (
|
||||
history.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
onClick={() => {
|
||||
handleDateChange(b.date);
|
||||
setViewMode('today');
|
||||
}}
|
||||
className="w-full text-left p-3 rounded-lg bg-gray-50 dark:bg-gray-750 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{formatBriefingDate(b.date)}
|
||||
</span>
|
||||
{b.weather && (
|
||||
<span className="text-lg">{weatherIcon(b.weather.icon)}</span>
|
||||
)}
|
||||
</div>
|
||||
{b.weather && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{b.weather.condition} · {b.weather.temp.toFixed(0)}°C
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
{b.summary || '暂无摘要'}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 简报卡片展示组件 */
|
||||
function BriefingCard({ briefing }: { briefing: Briefing }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 日期 */}
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 text-center">
|
||||
{formatBriefingDate(briefing.date)}
|
||||
</h3>
|
||||
|
||||
{/* 天气卡片 */}
|
||||
{briefing.weather && briefing.weather.condition && (
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 border border-blue-100 dark:border-blue-800/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">{weatherIcon(briefing.weather.icon)}</span>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||
{briefing.weather.temp.toFixed(0)}°C
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{briefing.weather.location} · {briefing.weather.condition}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 待办提醒 */}
|
||||
{briefing.reminders.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-pink-50 dark:bg-pink-900/10 border border-pink-100 dark:border-pink-800/30">
|
||||
<h4 className="text-xs font-semibold text-pink-600 dark:text-pink-400 mb-2">
|
||||
📋 今日待办 ({briefing.reminders.length})
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{briefing.reminders.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-pink-400 flex-shrink-0" />
|
||||
<span className="flex-1 truncate">{r.title}</span>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0">
|
||||
{formatRemindTime(r.remind_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新闻列表 */}
|
||||
{briefing.news.length > 0 && briefing.news[0].title !== '未能获取今日新闻' && (
|
||||
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30">
|
||||
<h4 className="text-xs font-semibold text-green-600 dark:text-green-400 mb-2">
|
||||
📰 今日新闻
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{briefing.news.map((n, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
{n.url ? (
|
||||
<a
|
||||
href={n.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-gray-700 dark:text-gray-300 hover:text-pink-500 transition-colors"
|
||||
>
|
||||
{n.title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{n.title}
|
||||
</span>
|
||||
)}
|
||||
{n.summary && (
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
|
||||
{n.summary}
|
||||
</p>
|
||||
)}
|
||||
{n.source && (
|
||||
<span className="text-[10px] text-gray-400">{n.source}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI 摘要 */}
|
||||
{briefing.summary && (
|
||||
<div className="p-3 rounded-lg bg-purple-50 dark:bg-purple-900/10 border border-purple-100 dark:border-purple-800/30">
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<span className="text-sm">🌸</span>
|
||||
<h4 className="text-xs font-semibold text-purple-600 dark:text-purple-400">
|
||||
昔涟的每日问候
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap">
|
||||
{briefing.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
listFiles,
|
||||
deleteFile,
|
||||
uploadFile,
|
||||
downloadFile,
|
||||
getFileThumbnailUrl,
|
||||
getFileDownloadUrl,
|
||||
type FileInfo,
|
||||
} from '@/api/files';
|
||||
|
||||
interface FilePanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** MIME 类型分类 */
|
||||
function getCategory(mimeType: string): 'image' | 'audio' | 'video' | 'document' | 'other' {
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType.startsWith('audio/')) return 'audio';
|
||||
if (mimeType.startsWith('video/')) return 'video';
|
||||
if (mimeType.startsWith('text/') || mimeType === 'application/pdf' || mimeType.includes('word')) return 'document';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** 文件类型图标 */
|
||||
function getFileIcon(mimeType: string): string {
|
||||
const cat = getCategory(mimeType);
|
||||
switch (cat) {
|
||||
case 'image': return '🖼️';
|
||||
case 'audio': return '🎵';
|
||||
case 'video': return '🎬';
|
||||
case 'document': return '📄';
|
||||
default: return '📎';
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(Number(ts));
|
||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
/** 允许的文件扩展名 */
|
||||
const ALLOWED_EXTS = new Set([
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg',
|
||||
'.pdf', '.txt', '.md', '.doc', '.docx',
|
||||
'.mp3', '.wav', '.ogg',
|
||||
'.mp4', '.webm',
|
||||
]);
|
||||
|
||||
/** 允许的 MIME 类型前缀 */
|
||||
const ALLOWED_MIME_PREFIXES = [
|
||||
'image/', 'audio/', 'video/',
|
||||
'application/pdf', 'text/', 'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml',
|
||||
];
|
||||
|
||||
export function FilePanel({ onClose }: FilePanelProps) {
|
||||
// 双视图:list / upload
|
||||
const [view, setView] = useState<'list' | 'upload'>('list');
|
||||
|
||||
// 文件列表
|
||||
const [files, setFiles] = useState<FileInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 筛选
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
|
||||
// 上传
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploadErr, setUploadErr] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Lightbox 预览
|
||||
const [previewFile, setPreviewFile] = useState<FileInfo | null>(null);
|
||||
|
||||
// 上下文菜单
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: FileInfo } | null>(null);
|
||||
|
||||
const limit = 20;
|
||||
|
||||
// 加载文件列表
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { files: f, total: t } = await listFiles(page, limit);
|
||||
setFiles(f);
|
||||
setTotal(t);
|
||||
} catch (e) {
|
||||
setError('加载文件列表失败');
|
||||
console.error('[FilePanel] 加载文件列表失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// 验证文件
|
||||
function validateFile(file: File): string | null {
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
return `文件 "${file.name}" 超过 20MB 限制`;
|
||||
}
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
const mimeOk = ALLOWED_MIME_PREFIXES.some(p => file.type.startsWith(p));
|
||||
const extOk = ALLOWED_EXTS.has(ext);
|
||||
if (!mimeOk && !extOk) {
|
||||
return `不支持的文件类型: ${file.type || ext}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理上传
|
||||
async function handleUpload(fileList: FileList | File[]) {
|
||||
const filesToUpload = Array.from(fileList);
|
||||
if (filesToUpload.length === 0) return;
|
||||
|
||||
// 验证所有文件
|
||||
for (const f of filesToUpload) {
|
||||
const err = validateFile(f);
|
||||
if (err) {
|
||||
setUploadErr(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadErr('');
|
||||
setUploadProgress(0);
|
||||
|
||||
let successCount = 0;
|
||||
for (let i = 0; i < filesToUpload.length; i++) {
|
||||
try {
|
||||
await uploadFile(filesToUpload[i]);
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
console.error('[FilePanel] 上传失败:', e);
|
||||
}
|
||||
setUploadProgress(Math.round(((i + 1) / filesToUpload.length) * 100));
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
|
||||
if (successCount > 0) {
|
||||
setView('list');
|
||||
setPage(1);
|
||||
await loadFiles();
|
||||
}
|
||||
if (successCount < filesToUpload.length) {
|
||||
setUploadErr(`${filesToUpload.length - successCount} 个文件上传失败`);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
async function handleDelete(file: FileInfo) {
|
||||
setContextMenu(null);
|
||||
if (!confirm(`确定删除 "${file.filename}" 吗?此操作不可恢复。`)) return;
|
||||
try {
|
||||
const ok = await deleteFile(file.id);
|
||||
if (ok) {
|
||||
setFiles(prev => prev.filter(f => f.id !== file.id));
|
||||
setTotal(prev => prev - 1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FilePanel] 删除失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
function handleDownload(file: FileInfo) {
|
||||
setContextMenu(null);
|
||||
downloadFile(file.id, file.filename).catch(e => {
|
||||
console.error('[FilePanel] 下载失败:', e);
|
||||
});
|
||||
}
|
||||
|
||||
// 过滤文件
|
||||
const filteredFiles = files.filter(f => {
|
||||
if (search && !f.filename.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (typeFilter !== 'all' && getCategory(f.mime_type) !== typeFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Lightbox 关闭
|
||||
useEffect(() => {
|
||||
if (!previewFile) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setPreviewFile(null);
|
||||
}
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [previewFile]);
|
||||
|
||||
// 关闭上下文菜单
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
function handleClick() { setContextMenu(null); }
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, [contextMenu]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 视图切换标签 */}
|
||||
<div className="flex border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
view === 'list'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📋 文件列表
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setView('upload'); setUploadErr(''); }}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
view === 'upload'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📤 上传
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文件列表视图 */}
|
||||
{view === 'list' && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="px-3 py-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="🔍 搜索文件名..."
|
||||
className="flex-1 px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:border-pink-300"
|
||||
/>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none"
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="image">🖼️ 图片</option>
|
||||
<option value="document">📄 文档</option>
|
||||
<option value="audio">🎵 音频</option>
|
||||
<option value="video">🎬 视频</option>
|
||||
<option value="other">📎 其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<div className="flex-1 overflow-y-auto px-3">
|
||||
{loading && files.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-gray-400">
|
||||
<div className="animate-spin mr-2">⏳</div> 加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="py-4 text-center text-sm text-red-500">
|
||||
{error}
|
||||
<button onClick={loadFiles} className="ml-2 underline">重试</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filteredFiles.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-4xl mb-3">📁</span>
|
||||
<p className="text-sm">暂无文件</p>
|
||||
<button
|
||||
onClick={() => setView('upload')}
|
||||
className="mt-3 px-4 py-1.5 text-xs text-pink-500 border border-pink-200 dark:border-pink-800 rounded-lg hover:bg-pink-50 dark:hover:bg-pink-900/20 transition-colors"
|
||||
>
|
||||
📤 上传文件
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredFiles.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 py-2">
|
||||
{filteredFiles.map(f => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="group relative flex flex-col items-center p-2 border border-gray-100 dark:border-gray-700 rounded-lg hover:border-pink-200 dark:hover:border-pink-800 hover:bg-pink-50/50 dark:hover:bg-pink-900/10 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
if (getCategory(f.mime_type) === 'image') {
|
||||
setPreviewFile(f);
|
||||
} else {
|
||||
handleDownload(f);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, file: f });
|
||||
}}
|
||||
>
|
||||
{/* 缩略图或图标 */}
|
||||
<div className="w-16 h-16 flex items-center justify-center rounded bg-gray-50 dark:bg-gray-700 mb-1 overflow-hidden">
|
||||
{getCategory(f.mime_type) === 'image' ? (
|
||||
<img
|
||||
src={getFileThumbnailUrl(f.id)}
|
||||
alt={f.filename}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={`text-2xl ${getCategory(f.mime_type) === 'image' ? 'hidden' : ''}`}>
|
||||
{getFileIcon(f.mime_type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文件名 */}
|
||||
<p className="text-xs text-gray-700 dark:text-gray-300 text-center truncate w-full" title={f.filename}>
|
||||
{f.filename}
|
||||
</p>
|
||||
|
||||
{/* 大小和时间 */}
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{formatSize(f.size)} · {formatTime(f.created_at)}
|
||||
</p>
|
||||
|
||||
{/* 操作按钮 (悬停显示) */}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDownload(f); }}
|
||||
className="p-1 text-gray-400 hover:text-pink-500 bg-white dark:bg-gray-800 rounded shadow-sm"
|
||||
title="下载"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDelete(f); }}
|
||||
className="p-1 text-gray-400 hover:text-red-500 bg-white dark:bg-gray-800 rounded shadow-sm"
|
||||
title="删除"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
共 {total} 个文件
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-2 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 disabled:opacity-30 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="px-2 py-0.5 text-xs text-gray-500">{page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-2 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 disabled:opacity-30 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传视图 */}
|
||||
{view === 'upload' && (
|
||||
<div className="flex-1 flex flex-col p-4">
|
||||
{/* 拖放区域 */}
|
||||
<div
|
||||
className={`flex-1 flex flex-col items-center justify-center border-2 border-dashed rounded-xl transition-colors ${
|
||||
dragOver
|
||||
? 'border-pink-400 bg-pink-50 dark:bg-pink-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-pink-300'
|
||||
}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleUpload(e.dataTransfer.files);
|
||||
}
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin text-3xl">⏳</div>
|
||||
<p className="text-sm text-gray-500">上传中...</p>
|
||||
<div className="w-48 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-pink-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">{uploadProgress}%</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 pointer-events-none">
|
||||
<span className="text-4xl">📤</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{dragOver ? '释放以上传文件' : '拖放文件到此处或点击选择'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
支持图片、文档、音频、视频 · 最大 20MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.txt,.md,.doc,.docx,.mp3,.wav,.ogg,.mp4,.webm"
|
||||
onChange={e => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleUpload(e.target.files);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 上传错误 */}
|
||||
{uploadErr && (
|
||||
<div className="mt-3 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600 dark:text-red-400">
|
||||
{uploadErr}
|
||||
<button onClick={() => setUploadErr('')} className="ml-2 underline">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 支持的文件类型 */}
|
||||
<div className="mt-3 text-[10px] text-gray-400 space-y-1">
|
||||
<p>🖼️ 图片: JPG, PNG, GIF, WebP, SVG</p>
|
||||
<p>📄 文档: PDF, TXT, MD, DOC, DOCX</p>
|
||||
<p>🎵 音频: MP3, WAV, OGG</p>
|
||||
<p>🎬 视频: MP4, WebM</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed z-[100] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 min-w-[120px]"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDownload(contextMenu.file)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
{getCategory(contextMenu.file.mime_type) === 'image' && (
|
||||
<button
|
||||
onClick={() => { setPreviewFile(contextMenu.file); setContextMenu(null); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
🔍 预览
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(contextMenu.file)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
|
||||
>
|
||||
🗑️ 删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lightbox 图片预览 */}
|
||||
{previewFile && (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] bg-black/80 flex items-center justify-center p-4"
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setPreviewFile(null)}
|
||||
className="absolute top-4 right-4 text-white/80 hover:text-white text-2xl z-10"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="flex flex-col items-center max-w-full max-h-full" onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={getFileDownloadUrl(previewFile.id)}
|
||||
alt={previewFile.filename}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
<div className="mt-3 text-center">
|
||||
<p className="text-white text-sm font-medium">{previewFile.filename}</p>
|
||||
<p className="text-white/60 text-xs mt-1">{formatSize(previewFile.size)}</p>
|
||||
<div className="flex gap-3 mt-3 justify-center">
|
||||
<button
|
||||
onClick={() => handleDownload(previewFile)}
|
||||
className="px-4 py-1.5 text-xs text-white bg-pink-500 hover:bg-pink-600 rounded-lg transition-colors"
|
||||
>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,91 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { MoodIndicator } from '@/components/persona/MoodIndicator';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { usePWA } from '@/hooks/usePWA';
|
||||
import { useNotificationStore } from '@/store/notificationStore';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { ReminderPanel } from '@/components/layout/ReminderPanel';
|
||||
import { BriefingPanel } from '@/components/layout/BriefingPanel';
|
||||
import { AutomationPanel } from '@/components/layout/AutomationPanel';
|
||||
import { FilePanel } from '@/components/layout/FilePanel';
|
||||
import { KnowledgePanel } from '@/components/layout/KnowledgePanel';
|
||||
import type { AppNotification } from '@/types/chat';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
onSearchClick: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onMenuClick }: HeaderProps) {
|
||||
/** 通知类型对应的图标和颜色 */
|
||||
const NOTIF_STYLES: Record<string, { icon: string; bg: string; text: string }> = {
|
||||
info: { icon: 'ℹ️', bg: 'bg-blue-50 dark:bg-blue-900/30', text: 'text-blue-600 dark:text-blue-400' },
|
||||
warning: { icon: '⚠️', bg: 'bg-yellow-50 dark:bg-yellow-900/30', text: 'text-yellow-600 dark:text-yellow-400' },
|
||||
success: { icon: '✅', bg: 'bg-green-50 dark:bg-green-900/30', text: 'text-green-600 dark:text-green-400' },
|
||||
thinking: { icon: '💭', bg: 'bg-purple-50 dark:bg-purple-900/30', text: 'text-purple-600 dark:text-purple-400' },
|
||||
reminder: { icon: '🔔', bg: 'bg-pink-50 dark:bg-pink-900/30', text: 'text-pink-600 dark:text-pink-400' },
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return '刚刚';
|
||||
if (diffMin < 60) return `${diffMin}分钟前`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour}小时前`;
|
||||
return d.toLocaleDateString('zh-CN');
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
export function Header({ onMenuClick, onSearchClick }: HeaderProps) {
|
||||
const { logout } = useAuth();
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
setOpen,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
} = useNotificationStore();
|
||||
const setCurrentSessionId = useSessionStore((s) => s.setCurrentSessionId);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// PWA Hook
|
||||
const { isInstallable, isInstalled, hasUpdate, install, update } = usePWA();
|
||||
|
||||
// 下拉面板标签页切换:通知 / 提醒 / 简报
|
||||
const [dropdownTab, setDropdownTab] = useState<'notifications' | 'reminders' | 'briefing' | 'automation' | 'files' | 'knowledge'>('notifications');
|
||||
|
||||
// 获取当前用户 ID
|
||||
const userId = localStorage.getItem('user_id') || '';
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, setOpen]);
|
||||
|
||||
const handleNotifClick = (n: AppNotification) => {
|
||||
markAsRead(n.id);
|
||||
if (n.data?.session_id) {
|
||||
setCurrentSessionId(n.data.session_id as string);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-2 border-b border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
@@ -31,7 +109,227 @@ export function Header({ onMenuClick }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* PWA 安装按钮 */}
|
||||
{isInstallable && !isInstalled && (
|
||||
<button
|
||||
onClick={install}
|
||||
className="p-1.5 text-gray-400 hover:text-pink-500 transition-colors rounded-lg"
|
||||
title="安装应用到桌面"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* PWA 更新按钮 */}
|
||||
{hasUpdate && (
|
||||
<button
|
||||
onClick={update}
|
||||
className="px-2 py-1 text-xs font-medium text-white bg-pink-500 hover:bg-pink-600 rounded-full transition-colors"
|
||||
title="有新版本可用,点击更新"
|
||||
>
|
||||
更新
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 通知铃铛 */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
className="relative p-1.5 text-gray-400 hover:text-pink-500 transition-colors rounded-lg"
|
||||
title="通知"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center w-4 h-4 text-[10px] font-bold text-white bg-red-500 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 通知下拉列表 */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 overflow-hidden">
|
||||
{/* 标签页切换:通知 / 提醒 */}
|
||||
<div className="flex border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setDropdownTab('notifications')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'notifications'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
通知
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-1 text-[10px] bg-red-500 text-white px-1 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('reminders')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'reminders'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
提醒 ⏰
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('briefing')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'briefing'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
简报 📋
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('automation')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'automation'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
⚡ 自动化
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('files')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'files'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📁 文件
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('knowledge')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'knowledge'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📚 知识库
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 通知面板 */}
|
||||
{dropdownTab === 'notifications' && (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-50 dark:border-gray-700">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
最近通知
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markAllAsRead()}
|
||||
className="text-[10px] text-pink-500 hover:text-pink-600 transition-colors"
|
||||
>
|
||||
全部已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🔔 暂无通知
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{notifications.slice(0, 10).map((n) => {
|
||||
const style = NOTIF_STYLES[n.type] || NOTIF_STYLES.info;
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => handleNotifClick(n)}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors flex items-start gap-3 ${
|
||||
!n.read ? 'bg-pink-50/50 dark:bg-pink-900/10' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg mt-0.5 flex-shrink-0">{style.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-medium ${style.text}`}>
|
||||
{n.title}
|
||||
</span>
|
||||
{!n.read && (
|
||||
<span className="w-2 h-2 bg-pink-500 rounded-full flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{n.body}
|
||||
</p>
|
||||
<span className="text-[10px] text-gray-400 mt-1 block">
|
||||
{formatTime(n.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提醒面板 */}
|
||||
{dropdownTab === 'reminders' && (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<ReminderPanel userId={userId} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 简报面板 */}
|
||||
{dropdownTab === 'briefing' && (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<BriefingPanel userId={userId} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自动化面板 */}
|
||||
{dropdownTab === 'automation' && (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<AutomationPanel onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件面板 */}
|
||||
{dropdownTab === 'files' && (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<FilePanel onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 知识库面板 */}
|
||||
{dropdownTab === 'knowledge' && (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<KnowledgePanel onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索按钮 */}
|
||||
<button
|
||||
onClick={onSearchClick}
|
||||
className="p-1.5 text-gray-400 hover:text-pink-500 transition-colors rounded-lg"
|
||||
title="搜索消息 (Ctrl+K)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-gray-400 hidden sm:block">🌸 永远在你身边</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { KnowledgeBase, KnowledgeDocument, SearchChunkResult } from '@/api/knowledge';
|
||||
import {
|
||||
createKB,
|
||||
listKBs,
|
||||
updateKB,
|
||||
deleteKB,
|
||||
addDocument,
|
||||
listDocuments,
|
||||
deleteDocument,
|
||||
searchKnowledge,
|
||||
} from '@/api/knowledge';
|
||||
|
||||
interface KnowledgePanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN');
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
export function KnowledgePanel({ onClose }: KnowledgePanelProps) {
|
||||
// 双标签页:知识库管理 / 搜索
|
||||
const [tab, setTab] = useState<'bases' | 'search'>('bases');
|
||||
|
||||
// ========== 知识库管理状态 ==========
|
||||
const [bases, setBases] = useState<KnowledgeBase[]>([]);
|
||||
const [loadingBases, setLoadingBases] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 创建/编辑知识库
|
||||
const [showKBForm, setShowKBForm] = useState(false);
|
||||
const [editingKB, setEditingKB] = useState<KnowledgeBase | null>(null);
|
||||
const [kbName, setKbName] = useState('');
|
||||
const [kbDesc, setKbDesc] = useState('');
|
||||
const [savingKB, setSavingKB] = useState(false);
|
||||
|
||||
// 选中知识库查看文档
|
||||
const [selectedKB, setSelectedKB] = useState<KnowledgeBase | null>(null);
|
||||
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
|
||||
const [loadingDocs, setLoadingDocs] = useState(false);
|
||||
|
||||
// 添加文档
|
||||
const [showDocForm, setShowDocForm] = useState(false);
|
||||
const [docTitle, setDocTitle] = useState('');
|
||||
const [docContent, setDocContent] = useState('');
|
||||
const [savingDoc, setSavingDoc] = useState(false);
|
||||
|
||||
// ========== 搜索状态 ==========
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchChunkResult[]>([]);
|
||||
const [searchTotal, setSearchTotal] = useState(0);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchErr, setSearchErr] = useState('');
|
||||
|
||||
// ========== 加载知识库列表 ==========
|
||||
const loadBases = useCallback(async () => {
|
||||
setLoadingBases(true);
|
||||
setError('');
|
||||
try {
|
||||
const list = await listKBs();
|
||||
setBases(list);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载知识库失败');
|
||||
} finally {
|
||||
setLoadingBases(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadBases();
|
||||
}, [loadBases]);
|
||||
|
||||
// ========== 创建/更新知识库 ==========
|
||||
const handleSaveKB = async () => {
|
||||
if (!kbName.trim()) return;
|
||||
setSavingKB(true);
|
||||
setError('');
|
||||
try {
|
||||
if (editingKB) {
|
||||
await updateKB(editingKB.id, kbName.trim(), kbDesc.trim() || undefined);
|
||||
} else {
|
||||
await createKB(kbName.trim(), kbDesc.trim() || undefined);
|
||||
}
|
||||
setShowKBForm(false);
|
||||
setEditingKB(null);
|
||||
setKbName('');
|
||||
setKbDesc('');
|
||||
await loadBases();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '保存知识库失败');
|
||||
} finally {
|
||||
setSavingKB(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditKB = (kb: KnowledgeBase) => {
|
||||
setEditingKB(kb);
|
||||
setKbName(kb.name);
|
||||
setKbDesc(kb.description || '');
|
||||
setShowKBForm(true);
|
||||
};
|
||||
|
||||
const handleDeleteKB = async (id: string) => {
|
||||
if (!confirm('确定要删除此知识库?所有文档将被永久删除。')) return;
|
||||
try {
|
||||
await deleteKB(id);
|
||||
if (selectedKB?.id === id) {
|
||||
setSelectedKB(null);
|
||||
setDocuments([]);
|
||||
}
|
||||
await loadBases();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '删除知识库失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 查看文档 ==========
|
||||
const handleSelectKB = async (kb: KnowledgeBase) => {
|
||||
setSelectedKB(kb);
|
||||
setLoadingDocs(true);
|
||||
try {
|
||||
const docs = await listDocuments(kb.id);
|
||||
setDocuments(docs);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载文档失败');
|
||||
} finally {
|
||||
setLoadingDocs(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 添加文档 ==========
|
||||
const handleAddDoc = async () => {
|
||||
if (!docTitle.trim() || !docContent.trim() || !selectedKB) return;
|
||||
setSavingDoc(true);
|
||||
setError('');
|
||||
try {
|
||||
await addDocument(selectedKB.id, docTitle.trim(), docContent.trim());
|
||||
setShowDocForm(false);
|
||||
setDocTitle('');
|
||||
setDocContent('');
|
||||
// 刷新文档列表
|
||||
const docs = await listDocuments(selectedKB.id);
|
||||
setDocuments(docs);
|
||||
// 刷新知识库列表 (更新文档计数)
|
||||
await loadBases();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '添加文档失败');
|
||||
} finally {
|
||||
setSavingDoc(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDoc = async (id: string) => {
|
||||
if (!confirm('确定要删除此文档?')) return;
|
||||
try {
|
||||
await deleteDocument(id);
|
||||
if (selectedKB) {
|
||||
const docs = await listDocuments(selectedKB.id);
|
||||
setDocuments(docs);
|
||||
}
|
||||
await loadBases();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '删除文档失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 搜索 ==========
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
setSearching(true);
|
||||
setSearchErr('');
|
||||
try {
|
||||
const res = await searchKnowledge(searchQuery.trim());
|
||||
setSearchResults(res.results);
|
||||
setSearchTotal(res.total);
|
||||
} catch (e: any) {
|
||||
setSearchErr(e.message || '搜索失败');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 标签页切换 */}
|
||||
<div className="flex border-b border-gray-100 dark:border-gray-700 shrink-0">
|
||||
<button
|
||||
onClick={() => setTab('bases')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
tab === 'bases'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📚 知识库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('search')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
tab === 'search'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
🔍 搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mx-3 mt-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg shrink-0">
|
||||
{error}
|
||||
<button className="ml-2 underline" onClick={() => setError('')}>关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 知识库管理 ========== */}
|
||||
{tab === 'bases' && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!selectedKB ? (
|
||||
/* 知识库列表 */
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
我的知识库
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingKB(null);
|
||||
setKbName('');
|
||||
setKbDesc('');
|
||||
setShowKBForm(true);
|
||||
}}
|
||||
className="text-xs text-pink-500 hover:text-pink-600 transition-colors"
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showKBForm && (
|
||||
<div className="mx-3 mb-2 p-3 bg-gray-50 dark:bg-gray-750 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="知识库名称"
|
||||
value={kbName}
|
||||
onChange={(e) => setKbName(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300"
|
||||
autoFocus
|
||||
/>
|
||||
<textarea
|
||||
placeholder="描述 (可选)"
|
||||
value={kbDesc}
|
||||
onChange={(e) => setKbDesc(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveKB}
|
||||
disabled={savingKB || !kbName.trim()}
|
||||
className="px-3 py-1 text-xs bg-pink-500 text-white rounded hover:bg-pink-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{savingKB ? '保存中...' : editingKB ? '更新' : '创建'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowKBForm(false);
|
||||
setEditingKB(null);
|
||||
}}
|
||||
className="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingBases ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">加载中...</div>
|
||||
) : bases.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
📚 暂无知识库,点击「+ 新建」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{bases.map((kb) => (
|
||||
<div
|
||||
key={kb.id}
|
||||
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => handleSelectKB(kb)}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
📚 {kb.name}
|
||||
</div>
|
||||
{kb.description && (
|
||||
<div className="text-[10px] text-gray-400 mt-0.5 truncate">
|
||||
{kb.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">
|
||||
{kb.document_count || 0} 篇文档 · {formatTime(kb.created_at)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditKB(kb);
|
||||
}}
|
||||
className="text-[10px] text-gray-400 hover:text-pink-500 px-1"
|
||||
title="编辑"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteKB(kb.id);
|
||||
}}
|
||||
className="text-[10px] text-gray-400 hover:text-red-500 px-1"
|
||||
title="删除"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* 文档列表 */
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedKB(null);
|
||||
setDocuments([]);
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-pink-500 transition-colors"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 flex-1">
|
||||
📚 {selectedKB.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowDocForm(true)}
|
||||
className="text-xs text-pink-500 hover:text-pink-600 transition-colors"
|
||||
>
|
||||
+ 添加文档
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDocForm && (
|
||||
<div className="mx-3 my-2 p-3 bg-gray-50 dark:bg-gray-750 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="文档标题"
|
||||
value={docTitle}
|
||||
onChange={(e) => setDocTitle(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300"
|
||||
autoFocus
|
||||
/>
|
||||
<textarea
|
||||
placeholder="文档内容"
|
||||
value={docContent}
|
||||
onChange={(e) => setDocContent(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300 resize-none"
|
||||
rows={5}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAddDoc}
|
||||
disabled={savingDoc || !docTitle.trim() || !docContent.trim()}
|
||||
className="px-3 py-1 text-xs bg-pink-500 text-white rounded hover:bg-pink-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{savingDoc ? '保存中...' : '添加'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDocForm(false)}
|
||||
className="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingDocs ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">加载中...</div>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
📄 暂无文档,点击「+ 添加文档」添加
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
📄 {doc.title}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">
|
||||
{doc.source_type === 'text'
|
||||
? '📝 文本'
|
||||
: doc.source_type === 'file'
|
||||
? '📎 文件'
|
||||
: '🔗 URL'}{' '}
|
||||
· {doc.chunk_count || 0} 个片段 · {formatTime(doc.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteDoc(doc.id)}
|
||||
className="text-[10px] text-gray-400 hover:text-red-500 px-1 shrink-0"
|
||||
title="删除"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 搜索 ========== */}
|
||||
{tab === 'search' && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-3 py-2 flex gap-2 shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索知识库内容..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="flex-1 px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:border-pink-300"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !searchQuery.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-pink-500 text-white rounded hover:bg-pink-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{searching ? '搜索中...' : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchErr && (
|
||||
<div className="mx-3 mb-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
{searchErr}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{searchResults.length === 0 && !searching && searchQuery && (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🔍 未找到匹配结果
|
||||
</div>
|
||||
)}
|
||||
{searchResults.length === 0 && !searching && !searchQuery && (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🔍 输入关键词搜索你的知识库
|
||||
</div>
|
||||
)}
|
||||
{searching && (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">搜索中...</div>
|
||||
)}
|
||||
{searchResults.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-[10px] text-gray-400">
|
||||
共 {searchTotal} 条结果
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{searchResults.map((r) => (
|
||||
<div
|
||||
key={r.chunk_id}
|
||||
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="text-[10px] text-pink-500 mb-0.5">
|
||||
📚 {r.kb_name} {'>'} 📄 {r.doc_title}
|
||||
</div>
|
||||
{r.headline && (
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{r.headline}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed line-clamp-3">
|
||||
{r.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
listReminders,
|
||||
createReminder,
|
||||
cancelReminder,
|
||||
deleteReminder,
|
||||
type Reminder,
|
||||
} from '@/api/reminders';
|
||||
|
||||
interface ReminderPanelProps {
|
||||
/** 从 Header 传入用户 ID */
|
||||
userId: string;
|
||||
/** 关闭面板 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 状态标签颜色映射 */
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
completed: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
|
||||
};
|
||||
|
||||
/** 状态中文映射 */
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '待提醒',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
/** 重复类型中文映射 */
|
||||
const REPEAT_LABELS: Record<string, string> = {
|
||||
none: '不重复',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatDateTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
/** 转换为 datetime-local 输入框的格式 */
|
||||
function toDatetimeLocal(isoStr: string): string {
|
||||
try {
|
||||
const d = new Date(isoStr);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function ReminderPanel({ userId, onClose }: ReminderPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<'list' | 'create'>('list');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [reminders, setReminders] = useState<Reminder[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 创建表单
|
||||
const [formTitle, setFormTitle] = useState('');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formTime, setFormTime] = useState('');
|
||||
const [formRepeat, setFormRepeat] = useState('none');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetchReminders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const resp = await listReminders(userId, statusFilter || undefined, 50);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
setReminders([]);
|
||||
} else {
|
||||
setReminders(resp.data?.reminders ?? []);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [userId, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReminders();
|
||||
}, [fetchReminders]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formTitle.trim()) return;
|
||||
if (!formTime) return;
|
||||
|
||||
setSubmitting(true);
|
||||
const remindAt = new Date(formTime).toISOString();
|
||||
const resp = await createReminder({
|
||||
title: formTitle.trim(),
|
||||
description: formDesc.trim(),
|
||||
remind_at: remindAt,
|
||||
repeat_type: formRepeat,
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
setFormTitle('');
|
||||
setFormDesc('');
|
||||
setFormTime('');
|
||||
setFormRepeat('none');
|
||||
setActiveTab('list');
|
||||
fetchReminders();
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
await cancelReminder(id);
|
||||
fetchReminders();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这条提醒吗?')) return;
|
||||
await deleteReminder(id);
|
||||
fetchReminders();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-h-full flex flex-col">
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex items-center border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('list')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === 'list'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
我的提醒
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('create')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === 'create'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-xs text-red-500 bg-red-50 dark:bg-red-900/20">
|
||||
⚠️ {error}
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
className="ml-2 underline hover:no-underline"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建表单 */}
|
||||
{activeTab === 'create' && (
|
||||
<div className="p-3 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
标题 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="例如:喝水提醒"
|
||||
maxLength={200}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
placeholder="提醒详情 (可选)"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
提醒时间 *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formTime}
|
||||
onChange={(e) => setFormTime(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
重复
|
||||
</label>
|
||||
<select
|
||||
value={formRepeat}
|
||||
onChange={(e) => setFormRepeat(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
>
|
||||
<option value="none">不重复</option>
|
||||
<option value="daily">每天</option>
|
||||
<option value="weekly">每周</option>
|
||||
<option value="monthly">每月</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('list');
|
||||
setError('');
|
||||
}}
|
||||
className="flex-1 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={submitting || !formTitle.trim() || !formTime}
|
||||
className="flex-1 py-1.5 text-xs bg-pink-500 text-white rounded-lg hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting ? '创建中...' : '创建提醒'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提醒列表 */}
|
||||
{activeTab === 'list' && (
|
||||
<>
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex gap-1 px-3 py-2 border-b border-gray-50 dark:border-gray-700">
|
||||
{['', 'pending', 'completed', 'cancelled'].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded-full transition-colors ${
|
||||
statusFilter === s
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900/40 dark:text-pink-400'
|
||||
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{s === '' ? '全部' : STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 列表内容 */}
|
||||
<div className="overflow-y-auto max-h-72">
|
||||
{loading ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
⏳ 加载中...
|
||||
</div>
|
||||
) : reminders.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🔔 暂无提醒,点击「+ 新建」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{reminders.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
|
||||
{r.title}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0 ${
|
||||
STATUS_STYLES[r.status] || STATUS_STYLES.pending
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[r.status] || r.status}
|
||||
</span>
|
||||
</div>
|
||||
{r.description && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 truncate mt-0.5">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
⏰ {formatDateTime(r.remind_at)}
|
||||
</span>
|
||||
{r.repeat_type && r.repeat_type !== 'none' && (
|
||||
<span className="text-[10px] text-pink-400">
|
||||
🔄 {REPEAT_LABELS[r.repeat_type] || r.repeat_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{r.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleCancel(r.id)}
|
||||
title="取消提醒"
|
||||
className="p-0.5 text-gray-300 hover:text-yellow-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(r.id)}
|
||||
title="删除"
|
||||
className="p-0.5 text-gray-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { searchMessages, type SearchResult } from '@/api/sessions';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 高亮文本中的关键词 */
|
||||
function highlightText(text: string, keyword: string): React.ReactNode {
|
||||
if (!keyword.trim()) return text;
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escaped})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-yellow-200 dark:bg-yellow-700 text-inherit rounded px-0.5">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 截取围绕关键词的上下文片段 */
|
||||
function snippetAroundKeyword(text: string, keyword: string, contextLen: number = 40): string {
|
||||
if (!keyword.trim()) {
|
||||
return text.length > 100 ? text.slice(0, 100) + '...' : text;
|
||||
}
|
||||
const idx = text.toLowerCase().indexOf(keyword.toLowerCase());
|
||||
if (idx === -1) return text.length > 100 ? text.slice(0, 100) + '...' : text;
|
||||
|
||||
const start = Math.max(0, idx - contextLen);
|
||||
const end = Math.min(text.length, idx + keyword.length + contextLen);
|
||||
let snippet = text.slice(start, end);
|
||||
if (start > 0) snippet = '…' + snippet;
|
||||
if (end < text.length) snippet = snippet + '…';
|
||||
return snippet;
|
||||
}
|
||||
|
||||
/** 格式化时间戳 */
|
||||
function formatTime(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return '刚刚';
|
||||
if (diffMin < 60) return `${diffMin}分钟前`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour}小时前`;
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const setCurrentSessionId = useSessionStore((s) => s.setCurrentSessionId);
|
||||
const loadMessagesFromServer = useSessionStore((s) => s.loadMessagesFromServer);
|
||||
|
||||
const userId = localStorage.getItem('user_id') || '';
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setSearched(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
const resp = await searchMessages(q.trim(), userId, 50, 0);
|
||||
setResults(resp.results);
|
||||
setTotal(resp.total);
|
||||
setLoading(false);
|
||||
},
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 防抖输入
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setQuery(val);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
doSearch(val);
|
||||
}, 300);
|
||||
},
|
||||
[doSearch]
|
||||
);
|
||||
|
||||
// 打开时聚焦输入框
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setSearched(false);
|
||||
setLoading(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 点击结果跳转到对应会话
|
||||
const handleResultClick = useCallback(
|
||||
async (result: SearchResult) => {
|
||||
onClose();
|
||||
setCurrentSessionId(result.session_id);
|
||||
await loadMessagesFromServer(result.session_id);
|
||||
},
|
||||
[onClose, setCurrentSessionId, loadMessagesFromServer]
|
||||
);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
|
||||
{/* 背景遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* 搜索面板 */}
|
||||
<div className="relative w-full max-w-lg mx-4 bg-white dark:bg-gray-850 rounded-2xl shadow-2xl border border-pink-100 dark:border-pink-800 flex flex-col max-h-[60vh]">
|
||||
{/* 搜索输入框 */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-pink-100 dark:border-pink-800">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 text-gray-400 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
placeholder="搜索历史消息…"
|
||||
className="flex-1 bg-transparent border-none outline-none text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-pink-400 border-t-transparent" />
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!searched && query.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-gray-400">
|
||||
输入关键词搜索你的历史消息
|
||||
</div>
|
||||
)}
|
||||
{searched && loading && results.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-gray-400">
|
||||
搜索中…
|
||||
</div>
|
||||
)}
|
||||
{searched && !loading && results.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-gray-400">
|
||||
未找到匹配的消息
|
||||
</div>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 text-xs text-gray-400 border-b border-pink-50 dark:border-pink-900">
|
||||
找到 {total} 条结果
|
||||
</div>
|
||||
{results.map((result) => (
|
||||
<button
|
||||
key={result.message_id}
|
||||
onClick={() => handleResultClick(result)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-pink-50 dark:hover:bg-pink-900/20 transition-colors border-b border-pink-50 dark:border-pink-900/50 last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-pink-500 dark:text-pink-400 truncate max-w-[60%]">
|
||||
{result.session_title || '新的对话'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-auto shrink-0">
|
||||
{formatTime(result.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
|
||||
{highlightText(snippetAroundKeyword(result.content, query), query)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="px-4 py-2 border-t border-pink-100 dark:border-pink-800 text-xs text-gray-400 text-center">
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">ESC</kbd> 关闭
|
||||
{' · '}
|
||||
点击结果跳转到对应会话
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { exportSession, type ExportFormat } from '@/api/sessions';
|
||||
import type { Session } from '@/types/session';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -26,6 +27,24 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
sessionId?: string;
|
||||
} | null>(null);
|
||||
|
||||
// 导出下拉状态
|
||||
const [exportMenuId, setExportMenuId] = useState<string | null>(null);
|
||||
const [exportingId, setExportingId] = useState<string | null>(null);
|
||||
|
||||
/** 执行导出 */
|
||||
const handleExport = useCallback(async (sessionId: string, format: ExportFormat) => {
|
||||
setExportMenuId(null);
|
||||
setExportingId(sessionId);
|
||||
try {
|
||||
await exportSession(sessionId, format);
|
||||
} catch (err) {
|
||||
console.error('[Sidebar] 导出失败:', err);
|
||||
alert(err instanceof Error ? err.message : '导出失败');
|
||||
} finally {
|
||||
setExportingId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 按 updated_at 降序排列
|
||||
const displaySessions = [...sessions].sort((a, b) => {
|
||||
const ta = typeof a.updated_at === 'string' ? parseInt(a.updated_at, 10) : (a.updated_at as unknown as number);
|
||||
@@ -174,28 +193,84 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteClick(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
|
||||
title="删除会话"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<div className="flex items-center gap-0.5 relative">
|
||||
{/* 导出按钮 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExportMenuId(exportMenuId === session.id ? null : session.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-blue-400 transition-all"
|
||||
title="导出会话"
|
||||
disabled={exportingId === session.id}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{exportingId === session.id ? (
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* 格式选择下拉菜单 */}
|
||||
{exportMenuId === session.id && (
|
||||
<div className="absolute right-0 top-full mt-1 z-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[120px]">
|
||||
{(['json', 'markdown', 'txt'] as ExportFormat[]).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExport(session.id, fmt);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{fmt === 'json' && '📦 JSON'}
|
||||
{fmt === 'markdown' && '📝 Markdown'}
|
||||
{fmt === 'txt' && '📄 TXT'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 删除按钮 */}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteClick(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
|
||||
title="删除会话"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
import type { ChatMode } from '@/types/chat';
|
||||
import type { ChatMode, MessageAttachment } from '@/types/chat';
|
||||
|
||||
export function useChat() {
|
||||
const {
|
||||
@@ -15,7 +15,7 @@ export function useChat() {
|
||||
const { sendMessage, isConnected } = useWebSocket();
|
||||
|
||||
const send = useCallback(
|
||||
(content: string, mode: ChatMode = 'text') => {
|
||||
(content: string, mode: ChatMode = 'text', attachments?: MessageAttachment[]) => {
|
||||
const userMsgId = `user_${Date.now()}`;
|
||||
|
||||
// 添加用户消息
|
||||
@@ -23,6 +23,7 @@ export function useChat() {
|
||||
id: userMsgId,
|
||||
role: 'user',
|
||||
content,
|
||||
attachments,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
@@ -34,6 +35,7 @@ export function useChat() {
|
||||
type: 'message',
|
||||
content,
|
||||
mode,
|
||||
attachments,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface UsePWAReturn {
|
||||
isInstallable: boolean;
|
||||
isInstalled: boolean;
|
||||
isOffline: boolean;
|
||||
hasUpdate: boolean;
|
||||
install: () => Promise<void>;
|
||||
update: () => void;
|
||||
}
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
readonly platforms: string[];
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
|
||||
}
|
||||
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
let swRegistration: ServiceWorkerRegistration | null = null;
|
||||
let updateCallback: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* 注册 Service Worker 并监听其更新
|
||||
*/
|
||||
export function registerServiceWorker(): void {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').then((reg) => {
|
||||
swRegistration = reg;
|
||||
console.log('SW registered:', reg.scope);
|
||||
|
||||
// 监听 SW 更新
|
||||
reg.addEventListener('updatefound', () => {
|
||||
const newWorker = reg.installing;
|
||||
if (!newWorker) return;
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// 有新 SW 等待激活
|
||||
updateCallback?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.log('SW registration failed:', err);
|
||||
});
|
||||
|
||||
// 定期检查更新 (每 60 分钟)
|
||||
setInterval(() => {
|
||||
if (swRegistration) {
|
||||
swRegistration.update().catch(() => {});
|
||||
}
|
||||
}, 60 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* usePWA Hook - 管理 PWA 安装和更新
|
||||
*/
|
||||
export function usePWA(): UsePWAReturn {
|
||||
const [isInstallable, setIsInstallable] = useState(false);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
|
||||
// 检测是否已安装为 PWA
|
||||
useEffect(() => {
|
||||
const checkInstalled = () => {
|
||||
const standalone = window.matchMedia('(display-mode: standalone)').matches;
|
||||
const safariStandalone = (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
setIsInstalled(standalone || safariStandalone);
|
||||
};
|
||||
|
||||
checkInstalled();
|
||||
|
||||
const mediaQuery = window.matchMedia('(display-mode: standalone)');
|
||||
mediaQuery.addEventListener('change', checkInstalled);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', checkInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听 beforeinstallprompt 事件
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
setIsInstallable(true);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听 online/offline 事件
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 注册 SW 更新回调
|
||||
useEffect(() => {
|
||||
updateCallback = () => setHasUpdate(true);
|
||||
return () => {
|
||||
updateCallback = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 安装 PWA
|
||||
const install = useCallback(async () => {
|
||||
if (!deferredPrompt) {
|
||||
console.log('PWA: beforeinstallprompt 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
console.log(`PWA: 用户 ${outcome === 'accepted' ? '接受' : '拒绝'} 安装`);
|
||||
deferredPrompt = null;
|
||||
setIsInstallable(false);
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
setIsInstalled(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('PWA install error:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 更新 SW
|
||||
const update = useCallback(() => {
|
||||
if (!swRegistration || !swRegistration.waiting) {
|
||||
// 尝试触发更新检查
|
||||
if (swRegistration) {
|
||||
swRegistration.update().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 通知等待中的 SW 跳过等待
|
||||
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
|
||||
swRegistration.waiting.addEventListener('statechange', (e) => {
|
||||
const worker = e.target as ServiceWorker;
|
||||
if (worker.state === 'activated') {
|
||||
setHasUpdate(false);
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isInstallable,
|
||||
isInstalled,
|
||||
isOffline,
|
||||
hasUpdate,
|
||||
install,
|
||||
update,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 浏览器 Speech Recognition API 的类型声明补充
|
||||
* 完整声明见 vite-env.d.ts
|
||||
*/
|
||||
|
||||
interface UseSpeechRecognitionReturn {
|
||||
isListening: boolean;
|
||||
isSupported: boolean;
|
||||
interimText: string;
|
||||
finalText: string;
|
||||
error: string | null;
|
||||
startListening: () => void;
|
||||
stopListening: () => void;
|
||||
resetText: () => void;
|
||||
}
|
||||
|
||||
type RecognitionError =
|
||||
| 'no-speech'
|
||||
| 'aborted'
|
||||
| 'audio-capture'
|
||||
| 'network'
|
||||
| 'not-allowed'
|
||||
| 'service-not-allowed'
|
||||
| 'bad-grammar'
|
||||
| 'language-not-supported';
|
||||
|
||||
const ERROR_MESSAGES: Record<RecognitionError, string> = {
|
||||
'no-speech': '未检测到语音,请再试一次',
|
||||
'aborted': '语音输入已中止',
|
||||
'audio-capture': '无法访问麦克风设备',
|
||||
'network': '网络错误,语音识别需要网络连接',
|
||||
'not-allowed': '麦克风权限被拒绝,请在浏览器设置中允许麦克风访问',
|
||||
'service-not-allowed': '语音识别服务不可用',
|
||||
'bad-grammar': '语法配置错误',
|
||||
'language-not-supported': '当前语言不支持语音识别',
|
||||
};
|
||||
|
||||
function getRecognitionError(error: string): string {
|
||||
return ERROR_MESSAGES[error as RecognitionError] || `语音识别错误: ${error}`;
|
||||
}
|
||||
|
||||
export function useSpeechRecognition(): UseSpeechRecognitionReturn {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [interimText, setInterimText] = useState('');
|
||||
const [finalText, setFinalText] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
const finalAccRef = useRef<string[]>([]);
|
||||
|
||||
const SpeechRecognitionAPI =
|
||||
typeof window !== 'undefined'
|
||||
? window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
: undefined;
|
||||
|
||||
const isSupported = SpeechRecognitionAPI != null;
|
||||
|
||||
const resetText = useCallback(() => {
|
||||
setInterimText('');
|
||||
setFinalText('');
|
||||
finalAccRef.current = [];
|
||||
}, []);
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
// 自动静默超时:若 3 秒内无任何结果,自动停止
|
||||
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const resetSilenceTimer = useCallback(() => {
|
||||
if (silenceTimerRef.current) {
|
||||
clearTimeout(silenceTimerRef.current);
|
||||
}
|
||||
silenceTimerRef.current = setTimeout(() => {
|
||||
stopListening();
|
||||
}, 3000);
|
||||
}, [stopListening]);
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
if (!SpeechRecognitionAPI) {
|
||||
setError('浏览器不支持语音识别');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已有实例则先停止
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.abort();
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setInterimText('');
|
||||
setFinalText('');
|
||||
finalAccRef.current = [];
|
||||
|
||||
const recognition = new SpeechRecognitionAPI();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = 'zh-CN';
|
||||
|
||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||
resetSilenceTimer();
|
||||
|
||||
let newInterim = '';
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i];
|
||||
if (result.isFinal) {
|
||||
finalAccRef.current.push(result[0].transcript);
|
||||
} else {
|
||||
newInterim += result[0].transcript;
|
||||
}
|
||||
}
|
||||
|
||||
setInterimText(newInterim);
|
||||
setFinalText(finalAccRef.current.join(''));
|
||||
};
|
||||
|
||||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||
const message = getRecognitionError(event.error);
|
||||
setError(message);
|
||||
|
||||
if (event.error === 'no-speech') {
|
||||
// 无语音不算致命错误,可以继续
|
||||
} else {
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
if (silenceTimerRef.current) {
|
||||
clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = null;
|
||||
}
|
||||
|
||||
// onend 时把已累积的 final 结果合并
|
||||
if (finalAccRef.current.length > 0) {
|
||||
setFinalText(finalAccRef.current.join(''));
|
||||
setInterimText('');
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onstart = () => {
|
||||
setIsListening(true);
|
||||
resetSilenceTimer();
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
}, [SpeechRecognitionAPI, resetSilenceTimer]);
|
||||
|
||||
// cleanup: 组件卸载时停止识别
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (silenceTimerRef.current) {
|
||||
clearTimeout(silenceTimerRef.current);
|
||||
}
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.abort();
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isListening,
|
||||
isSupported,
|
||||
interimText,
|
||||
finalText,
|
||||
error,
|
||||
startListening,
|
||||
stopListening,
|
||||
resetText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface SpeakOptions {
|
||||
rate?: number; // 语速 0.1-10, 默认 1
|
||||
pitch?: number; // 音调 0-2, 默认 1
|
||||
volume?: number; // 音量 0-1, 默认 1
|
||||
voice?: SpeechSynthesisVoice;
|
||||
lang?: string; // 默认 zh-CN
|
||||
}
|
||||
|
||||
export interface UseSpeechSynthesisReturn {
|
||||
isSpeaking: boolean;
|
||||
isSupported: boolean;
|
||||
isPaused: boolean;
|
||||
voices: SpeechSynthesisVoice[];
|
||||
currentVoice: SpeechSynthesisVoice | null;
|
||||
speak: (text: string, options?: SpeakOptions) => void;
|
||||
stop: () => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
setVoice: (voice: SpeechSynthesisVoice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的中文语音列表
|
||||
*/
|
||||
export function getChineseVoices(): SpeechSynthesisVoice[] {
|
||||
if (typeof window === 'undefined' || !window.speechSynthesis) {
|
||||
return [];
|
||||
}
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
return voices.filter(
|
||||
(v) =>
|
||||
v.lang.startsWith('zh-CN') ||
|
||||
v.lang.startsWith('zh-TW') ||
|
||||
v.lang.startsWith('zh-HK') ||
|
||||
v.lang.startsWith('zh-'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最佳中文语音 — 优先选择包含 "Xiaoxiao" 的 (自然度最高)
|
||||
*/
|
||||
export function getBestChineseVoice(): SpeechSynthesisVoice | null {
|
||||
const chineseVoices = getChineseVoices();
|
||||
if (chineseVoices.length === 0) return null;
|
||||
|
||||
// 优先匹配包含 "Xiaoxiao" 的语音
|
||||
const xiaoxiao = chineseVoices.find((v) => v.name.includes('Xiaoxiao'));
|
||||
if (xiaoxiao) return xiaoxiao;
|
||||
|
||||
// 其次匹配 "Yunxi"、"Xiaoyi"
|
||||
const yunxi = chineseVoices.find((v) => v.name.includes('Yunxi'));
|
||||
if (yunxi) return yunxi;
|
||||
|
||||
const xiaoyi = chineseVoices.find((v) => v.name.includes('Xiaoyi'));
|
||||
if (xiaoyi) return xiaoyi;
|
||||
|
||||
// fallback 到第一个中文语音
|
||||
return chineseVoices[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将长文本按句号、换行分割成段落,避免浏览器截断
|
||||
*/
|
||||
function splitTextIntoChunks(text: string, maxChunkLength: number = 200): string[] {
|
||||
const chunks: string[] = [];
|
||||
// 按句号、感叹号、问号、换行分割
|
||||
const sentences = text.split(/(?<=[。!?!?\n])/g);
|
||||
|
||||
let current = '';
|
||||
for (const sentence of sentences) {
|
||||
if (current.length + sentence.length > maxChunkLength && current.length > 0) {
|
||||
chunks.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
current += sentence;
|
||||
}
|
||||
if (current.trim()) {
|
||||
chunks.push(current.trim());
|
||||
}
|
||||
return chunks.length > 0 ? chunks : [text];
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器 Speech Synthesis TTS Hook
|
||||
*
|
||||
* 使用浏览器原生 Speech Synthesis API 进行文字转语音。
|
||||
* - 中文语音自动优选
|
||||
* - 长文本自动分段
|
||||
* - Chrome 暂停 bug 规避(定期 resume)
|
||||
*/
|
||||
export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
|
||||
const [currentVoice, setCurrentVoice] = useState<SpeechSynthesisVoice | null>(null);
|
||||
|
||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
||||
const chunksRef = useRef<string[]>([]);
|
||||
const chunkIndexRef = useRef(0);
|
||||
const optionsRef = useRef<SpeakOptions>({});
|
||||
const resumeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' && 'speechSynthesis' in window;
|
||||
|
||||
// 加载语音列表
|
||||
useEffect(() => {
|
||||
if (!isSupported) return;
|
||||
|
||||
const loadVoices = () => {
|
||||
const available = window.speechSynthesis.getVoices();
|
||||
if (available.length > 0) {
|
||||
setVoices(available);
|
||||
// 自动选择最佳中文语音
|
||||
const best = getBestChineseVoice();
|
||||
if (best) {
|
||||
setCurrentVoice(best);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadVoices();
|
||||
window.speechSynthesis.onvoiceschanged = loadVoices;
|
||||
|
||||
return () => {
|
||||
window.speechSynthesis.onvoiceschanged = null;
|
||||
};
|
||||
}, [isSupported]);
|
||||
|
||||
// Chrome bug 规避:定期 resume 避免长时间不调用后暂停
|
||||
useEffect(() => {
|
||||
if (isSpeaking && !isPaused) {
|
||||
resumeIntervalRef.current = setInterval(() => {
|
||||
if (window.speechSynthesis.speaking && window.speechSynthesis.paused) {
|
||||
window.speechSynthesis.resume();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resumeIntervalRef.current) {
|
||||
clearInterval(resumeIntervalRef.current);
|
||||
resumeIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isSpeaking, isPaused]);
|
||||
|
||||
/** 朗读下一段 */
|
||||
const speakNextChunk = useCallback(() => {
|
||||
const chunks = chunksRef.current;
|
||||
const idx = chunkIndexRef.current;
|
||||
|
||||
if (idx >= chunks.length) {
|
||||
// 全部读完
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(chunks[idx]);
|
||||
const opts = optionsRef.current;
|
||||
|
||||
utterance.rate = opts.rate ?? 1;
|
||||
utterance.pitch = opts.pitch ?? 1;
|
||||
utterance.volume = opts.volume ?? 1;
|
||||
utterance.lang = opts.lang ?? 'zh-CN';
|
||||
|
||||
if (opts.voice) {
|
||||
utterance.voice = opts.voice;
|
||||
} else if (currentVoice) {
|
||||
utterance.voice = currentVoice;
|
||||
}
|
||||
|
||||
utterance.onend = () => {
|
||||
chunkIndexRef.current++;
|
||||
speakNextChunk();
|
||||
};
|
||||
|
||||
utterance.onerror = (e) => {
|
||||
// 'canceled' 是正常取消,不报错
|
||||
if (e.error !== 'canceled' && e.error !== 'interrupted') {
|
||||
console.warn('[TTS] SpeechSynthesis error:', e.error);
|
||||
}
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
};
|
||||
|
||||
utterance.onpause = () => setIsPaused(true);
|
||||
utterance.onresume = () => setIsPaused(false);
|
||||
|
||||
utteranceRef.current = utterance;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}, [currentVoice]);
|
||||
|
||||
/** 开始朗读 */
|
||||
const speak = useCallback(
|
||||
(text: string, options?: SpeakOptions) => {
|
||||
if (!isSupported || !text.trim()) return;
|
||||
|
||||
// 先停止当前朗读
|
||||
window.speechSynthesis.cancel();
|
||||
|
||||
// 分段
|
||||
const chunks = splitTextIntoChunks(text);
|
||||
chunksRef.current = chunks;
|
||||
chunkIndexRef.current = 0;
|
||||
optionsRef.current = options ?? {};
|
||||
|
||||
setIsSpeaking(true);
|
||||
setIsPaused(false);
|
||||
|
||||
// 延迟一帧确保 cancel 生效
|
||||
setTimeout(() => speakNextChunk(), 50);
|
||||
},
|
||||
[isSupported, speakNextChunk],
|
||||
);
|
||||
|
||||
/** 停止朗读 */
|
||||
const stop = useCallback(() => {
|
||||
window.speechSynthesis.cancel();
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
chunksRef.current = [];
|
||||
chunkIndexRef.current = 0;
|
||||
}, []);
|
||||
|
||||
/** 暂停 */
|
||||
const pause = useCallback(() => {
|
||||
if (isSpeaking && !isPaused) {
|
||||
window.speechSynthesis.pause();
|
||||
setIsPaused(true);
|
||||
}
|
||||
}, [isSpeaking, isPaused]);
|
||||
|
||||
/** 恢复 */
|
||||
const resume = useCallback(() => {
|
||||
if (isPaused) {
|
||||
window.speechSynthesis.resume();
|
||||
setIsPaused(false);
|
||||
}
|
||||
}, [isPaused]);
|
||||
|
||||
/** 设置语音 */
|
||||
const setVoice = useCallback((voice: SpeechSynthesisVoice) => {
|
||||
setCurrentVoice(voice);
|
||||
}, []);
|
||||
|
||||
// 组件卸载时停止
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.speechSynthesis.cancel();
|
||||
if (resumeIntervalRef.current) {
|
||||
clearInterval(resumeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isSpeaking,
|
||||
isSupported,
|
||||
isPaused,
|
||||
voices,
|
||||
currentVoice,
|
||||
speak,
|
||||
stop,
|
||||
pause,
|
||||
resume,
|
||||
setVoice,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { useNotificationStore } from '@/store/notificationStore';
|
||||
import { getToken } from '@/api/client';
|
||||
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
|
||||
|
||||
@@ -193,6 +194,37 @@ function handleServerMessage(msg: WSServerMessage) {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'notification':
|
||||
if (msg.notification) {
|
||||
// 添加到通知 store
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification(msg.notification);
|
||||
|
||||
// 触发浏览器桌面通知
|
||||
if (typeof Notification !== 'undefined') {
|
||||
if (Notification.permission === 'granted') {
|
||||
const n = new Notification(msg.notification.title, {
|
||||
body: msg.notification.body,
|
||||
icon: '/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png',
|
||||
tag: msg.notification.id,
|
||||
data: msg.notification.data,
|
||||
});
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
if (msg.notification?.data?.session_id) {
|
||||
const { setCurrentSessionId } = useSessionStore.getState();
|
||||
const sid = msg.notification.data.session_id as string;
|
||||
if (sid) setCurrentSessionId(sid);
|
||||
}
|
||||
n.close();
|
||||
};
|
||||
} else if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[WS] 服务端错误:', msg.error);
|
||||
setTyping(false);
|
||||
|
||||
@@ -145,3 +145,39 @@
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 语音识别动画 ===== */
|
||||
@keyframes voice-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
50% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
.voice-btn-active {
|
||||
animation: voice-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 实时识别文本淡入效果 */
|
||||
@keyframes interim-fade {
|
||||
from { opacity: 0.5; }
|
||||
to { opacity: 0.9; }
|
||||
}
|
||||
|
||||
.interim-text {
|
||||
animation: interim-fade 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* ===== TTS 文字转语音动画 ===== */
|
||||
@keyframes tts-wave {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-playing {
|
||||
animation: tts-wave 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,17 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
// 注册 PWA Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').then((reg) => {
|
||||
console.log('SW registered:', reg.scope);
|
||||
}).catch((err) => {
|
||||
console.log('SW registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand';
|
||||
import type { AppNotification, NotificationData } from '@/types/chat';
|
||||
|
||||
const MAX_NOTIFICATIONS = 50;
|
||||
|
||||
interface NotificationStore {
|
||||
notifications: AppNotification[];
|
||||
unreadCount: number;
|
||||
isOpen: boolean;
|
||||
|
||||
addNotification: (data: NotificationData) => void;
|
||||
markAsRead: (id: string) => void;
|
||||
markAllAsRead: () => void;
|
||||
removeNotification: (id: string) => void;
|
||||
clearAll: () => void;
|
||||
toggleOpen: () => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationStore>((set) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
isOpen: false,
|
||||
|
||||
addNotification: (data: NotificationData) =>
|
||||
set((state) => {
|
||||
const existing = state.notifications.find((n) => n.id === data.id);
|
||||
if (existing) return state;
|
||||
|
||||
const newNotif: AppNotification = {
|
||||
...data,
|
||||
read: false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const notifications = [newNotif, ...state.notifications].slice(0, MAX_NOTIFICATIONS);
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
return { notifications, unreadCount };
|
||||
}),
|
||||
|
||||
markAsRead: (id: string) =>
|
||||
set((state) => {
|
||||
const notifications = state.notifications.map((n) =>
|
||||
n.id === id ? { ...n, read: true } : n
|
||||
);
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
return { notifications, unreadCount };
|
||||
}),
|
||||
|
||||
markAllAsRead: () =>
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) => ({ ...n, read: true })),
|
||||
unreadCount: 0,
|
||||
})),
|
||||
|
||||
removeNotification: (id: string) =>
|
||||
set((state) => {
|
||||
const notifications = state.notifications.filter((n) => n.id !== id);
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
return { notifications, unreadCount };
|
||||
}),
|
||||
|
||||
clearAll: () => set({ notifications: [], unreadCount: 0, isOpen: false }),
|
||||
|
||||
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
|
||||
setOpen: (open: boolean) => set({ isOpen: open }),
|
||||
}));
|
||||
@@ -17,6 +17,18 @@ export interface VoiceSegment {
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
/** 消息附件 (图片等) */
|
||||
export interface MessageAttachment {
|
||||
type: 'image';
|
||||
url: string; // 图片 URL 或 data URL
|
||||
thumbnail_url?: string;
|
||||
filename?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: number; // 文件大小 bytes
|
||||
description?: string; // AI 对图片的描述
|
||||
}
|
||||
|
||||
/** 单条消息 */
|
||||
export interface Message {
|
||||
id: string;
|
||||
@@ -24,6 +36,7 @@ export interface Message {
|
||||
content: string;
|
||||
audioUrl?: string;
|
||||
segments?: VoiceSegment[];
|
||||
attachments?: MessageAttachment[];
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
@@ -61,12 +74,32 @@ export interface WSClientMessage {
|
||||
mode?: ChatMode;
|
||||
content?: string;
|
||||
audio_data?: string; // base64
|
||||
attachments?: MessageAttachment[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** 通知类型 */
|
||||
export type NotificationType = 'info' | 'warning' | 'success' | 'thinking' | 'reminder';
|
||||
|
||||
/** WebSocket 通知数据 */
|
||||
export interface NotificationData {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
timestamp: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 站内通知 (store 中使用,扩展了已读状态) */
|
||||
export interface AppNotification extends NotificationData {
|
||||
read: boolean;
|
||||
createdAt: number; // 客户端收到时间
|
||||
}
|
||||
|
||||
/** WebSocket 服务端消息 */
|
||||
export interface WSServerMessage {
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking';
|
||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification';
|
||||
message_id?: string;
|
||||
text?: string;
|
||||
content?: string;
|
||||
@@ -80,6 +113,7 @@ export interface WSServerMessage {
|
||||
messages?: Message[];
|
||||
devices?: IoTDevice[];
|
||||
thinking_status?: BackgroundThinkingStatus;
|
||||
notification?: NotificationData;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+47
@@ -1 +1,48 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// ===== 浏览器 Speech Recognition API 类型声明 =====
|
||||
// Chrome/Edge 使用 window.SpeechRecognition,部分旧版使用 window.webkitSpeechRecognition
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
results: SpeechRecognitionResultList;
|
||||
resultIndex: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
length: number;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
isFinal: boolean;
|
||||
length: number;
|
||||
[index: number]: SpeechRecognitionAlternative;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionAlternative {
|
||||
transcript: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onstart: (() => void) | null;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
SpeechRecognition?: new () => SpeechRecognition;
|
||||
webkitSpeechRecognition?: new () => SpeechRecognition;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# 下载并编译 whisper.cpp,下载中文模型
|
||||
set -e
|
||||
WHISPER_DIR="/home/aska/Code/Cyrene/backend/voice-service/whisper.cpp"
|
||||
MODEL_DIR="$WHISPER_DIR/models"
|
||||
|
||||
if [ ! -f "$WHISPER_DIR/main" ]; then
|
||||
echo "[whisper] 克隆 whisper.cpp..."
|
||||
git clone https://github.com/ggerganov/whisper.cpp.git "$WHISPER_DIR"
|
||||
cd "$WHISPER_DIR"
|
||||
echo "[whisper] 编译 whisper.cpp..."
|
||||
make -j$(nproc)
|
||||
fi
|
||||
|
||||
# 下载 ggml-small.bin (中文支持好,~466MB)
|
||||
if [ ! -f "$MODEL_DIR/ggml-small.bin" ]; then
|
||||
echo "[whisper] 下载 small 模型..."
|
||||
cd "$MODEL_DIR"
|
||||
bash ../models/download-ggml-model.sh small
|
||||
fi
|
||||
|
||||
echo "whisper.cpp 安装完成: $WHISPER_DIR/main"
|
||||
Reference in New Issue
Block a user