fix: 修复19个Bug (P0-P3) — 持续性调试第7轮发现的问题
P0 (5): crypto/rand session ID, TTS fallback可达性, goroutine defer recover, adminAuth前缀修正 P1 (5): 普通用户密码验证, context传递, priority clamp, 超时重试, 自主思考速率限制 P2 (4): Briefing AI降级, 前端消息类型渲染, Docker Compose补全, PWA 192图标 P3 (5): goroutine错误处理, .gitignore完善, reminder created_at, voice Dockerfile, Go版本更新
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@ backend/.env
|
|||||||
data/
|
data/
|
||||||
docs/
|
docs/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
chat-session.md
|
dev_must_read.md
|
||||||
scripts/tunnel.sh
|
scripts/tunnel.sh
|
||||||
|
|
||||||
# Test scripts
|
# Test scripts
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ========== 构建阶段 ==========
|
# ========== 构建阶段 ==========
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ func (t *Thinker) TriggerPostChatThink() {
|
|||||||
t.wg.Add(1)
|
t.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer t.wg.Done()
|
defer t.wg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[后台思考] 对话后触发 panic 恢复: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 短暂延迟,让对话有个自然的停顿
|
// 短暂延迟,让对话有个自然的停顿
|
||||||
select {
|
select {
|
||||||
@@ -261,6 +266,11 @@ func (t *Thinker) resetSilenceTimer() {
|
|||||||
t.wg.Add(1)
|
t.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer t.wg.Done()
|
defer t.wg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[后台思考] 静默定时器 panic 恢复: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-t.stopCh:
|
case <-t.stopCh:
|
||||||
@@ -316,16 +326,30 @@ func (t *Thinker) HasPendingThoughts() bool {
|
|||||||
// performThink 执行一次增强版后台思考(支持工具调用和记忆管理)
|
// performThink 执行一次增强版后台思考(支持工具调用和记忆管理)
|
||||||
//
|
//
|
||||||
// triggerReason: "post_chat" (对话后) 或 "silence" (静默超时)
|
// triggerReason: "post_chat" (对话后) 或 "silence" (静默超时)
|
||||||
|
//
|
||||||
|
// 防御性速率限制:即使调用方未检查 minThinkGap,performThink 自身也会
|
||||||
|
// 强制执行最小间隔,防止并发调用或 bug 导致 LLM 配额被快速消耗。
|
||||||
func (t *Thinker) performThink(triggerReason string) {
|
func (t *Thinker) performThink(triggerReason string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
gapSinceLast := time.Since(t.lastThinkTime)
|
||||||
|
minGap := t.minThinkGap
|
||||||
|
if minGap <= 0 {
|
||||||
|
minGap = 5 * time.Second // 默认最小间隔 5 秒
|
||||||
|
}
|
||||||
|
if gapSinceLast < minGap {
|
||||||
|
t.mu.Unlock()
|
||||||
|
log.Printf("[后台思考] 距上次思考仅 %v,跳过 (最小间隔=%v, 触发原因=%s)", gapSinceLast.Round(time.Second), minGap, triggerReason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
t.lastThinkTime = time.Now()
|
t.lastThinkTime = time.Now()
|
||||||
t.thinkCount++
|
t.thinkCount++
|
||||||
currentCount := t.thinkCount
|
currentCount := t.thinkCount
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
log.Printf("[后台思考] 开始思考周期 (触发原因=%s, 计数=%d)...", triggerReason, currentCount)
|
log.Printf("[后台思考] 开始思考周期 (触发原因=%s, 计数=%d)...", triggerReason, currentCount)
|
||||||
|
|
||||||
// 1. 加载人格配置
|
// 1. 加载人格配置
|
||||||
@@ -356,7 +380,7 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
// 4. 查询 IoT 设备状态(每次都查询,无间隔限制)
|
// 4. 查询 IoT 设备状态(每次都查询,无间隔限制)
|
||||||
var deviceSummary string
|
var deviceSummary string
|
||||||
if t.iotClient != nil {
|
if t.iotClient != nil {
|
||||||
devices := t.iotClient.GetDevicesForContext()
|
devices := t.iotClient.GetDevicesForContext(ctx)
|
||||||
if len(devices) > 0 {
|
if len(devices) > 0 {
|
||||||
deviceSummary = formatDeviceContext(devices)
|
deviceSummary = formatDeviceContext(devices)
|
||||||
}
|
}
|
||||||
@@ -460,7 +484,14 @@ func (t *Thinker) performThink(triggerReason string) {
|
|||||||
|
|
||||||
// 9. 从思考结果中提取记忆(异步)
|
// 9. 从思考结果中提取记忆(异步)
|
||||||
if t.memoryExtractor != nil {
|
if t.memoryExtractor != nil {
|
||||||
go t.extractMemoriesFromThinking(finalContent)
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[后台思考] 提取记忆 panic 恢复: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
t.extractMemoriesFromThinking(finalContent)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. 周期性记忆维护(每 10 次思考触发一次,而非按时间)
|
// 10. 周期性记忆维护(每 10 次思考触发一次,而非按时间)
|
||||||
@@ -649,6 +680,11 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
|
|||||||
// 异步持久化到 memory-service
|
// 异步持久化到 memory-service
|
||||||
if t.memClient != nil {
|
if t.memClient != nil {
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[后台思考] 持久化思考日志 panic 恢复: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := t.memClient.SaveThinkingLog(ctx, t.adminUserID, content, toolCallsJSON, toolCallCount, len(content)); err != nil {
|
if err := t.memClient.SaveThinkingLog(ctx, t.adminUserID, content, toolCallsJSON, toolCallCount, len(content)); err != nil {
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ func (o *Orchestrator) ProcessInput(
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(eventCh)
|
defer close(eventCh)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[orchestrator] 编排器主循环 panic 恢复: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 1. 意图分析
|
// 1. 意图分析
|
||||||
intent, err := o.intentAnalyzer.Analyze(ctx, params.Message)
|
intent, err := o.intentAnalyzer.Analyze(ctx, params.Message)
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import (
|
|||||||
|
|
||||||
// IoTDeviceProvider IoT 设备查询接口
|
// IoTDeviceProvider IoT 设备查询接口
|
||||||
type IoTDeviceProvider interface {
|
type IoTDeviceProvider interface {
|
||||||
GetAllDevices() ([]tools.IoTDevice, error)
|
GetAllDevices(ctx context.Context) ([]tools.IoTDevice, error)
|
||||||
GetDevice(id string) (*tools.IoTDevice, error)
|
GetDevice(ctx context.Context, id string) (*tools.IoTDevice, error)
|
||||||
ToggleDevice(id string) error
|
ToggleDevice(id string) error
|
||||||
SetDeviceProperty(id string, field string, value interface{}) error
|
SetDeviceProperty(id string, field string, value interface{}) error
|
||||||
GetDevicesForContext() []tools.IoTDevice
|
GetDevicesForContext(ctx context.Context) []tools.IoTDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
// IoTProvider IoT 控制子会话提供者
|
// IoTProvider IoT 控制子会话提供者
|
||||||
@@ -75,7 +75,7 @@ func (p *IoTProvider) CreateContext(ctx context.Context, params CreateContextPar
|
|||||||
// 获取当前设备状态
|
// 获取当前设备状态
|
||||||
var deviceStatusText string
|
var deviceStatusText string
|
||||||
if p.iotClient != nil {
|
if p.iotClient != nil {
|
||||||
devices := p.iotClient.GetDevicesForContext()
|
devices := p.iotClient.GetDevicesForContext(ctx)
|
||||||
if len(devices) > 0 {
|
if len(devices) > 0 {
|
||||||
deviceStatusText = "当前设备状态:\n"
|
deviceStatusText = "当前设备状态:\n"
|
||||||
for _, d := range devices {
|
for _, d := range devices {
|
||||||
@@ -208,7 +208,7 @@ func (p *IoTProvider) Execute(ctx context.Context, subCtx []model.LLMMessage) (*
|
|||||||
msgLower := strings.ToLower(userMessage)
|
msgLower := strings.ToLower(userMessage)
|
||||||
|
|
||||||
// 尝试获取设备列表进行匹配
|
// 尝试获取设备列表进行匹配
|
||||||
devices := p.iotClient.GetDevicesForContext()
|
devices := p.iotClient.GetDevicesForContext(ctx)
|
||||||
|
|
||||||
for _, dev := range devices {
|
for _, dev := range devices {
|
||||||
devNameLower := strings.ToLower(dev.Name)
|
devNameLower := strings.ToLower(dev.Name)
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ func (m *Manager) Dispatch(
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(p Provider) {
|
go func(p Provider) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[subsession] dispatch goroutine panic 恢复 (type=%s): %v", p.Type(), r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
result := model.SubSessionResult{Type: p.Type()}
|
result := model.SubSessionResult{Type: p.Type()}
|
||||||
|
|
||||||
@@ -135,6 +140,11 @@ func (m *Manager) Dispatch(
|
|||||||
|
|
||||||
// 等待所有子会话完成,关闭通道
|
// 等待所有子会话完成,关闭通道
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[subsession] wait goroutine panic 恢复: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
close(resultCh)
|
close(resultCh)
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package tools
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -56,7 +57,7 @@ func NewIoTClient(baseURL string) *IoTClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAllDevices 获取所有设备列表(带缓存)
|
// GetAllDevices 获取所有设备列表(带缓存)
|
||||||
func (c *IoTClient) GetAllDevices() ([]IoTDevice, error) {
|
func (c *IoTClient) GetAllDevices(ctx context.Context) ([]IoTDevice, error) {
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
if c.cache != nil && time.Since(c.cacheTime) < c.cacheTTL {
|
if c.cache != nil && time.Since(c.cacheTime) < c.cacheTTL {
|
||||||
@@ -68,7 +69,13 @@ func (c *IoTClient) GetAllDevices() ([]IoTDevice, error) {
|
|||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
|
||||||
// 请求 API
|
// 请求 API
|
||||||
resp, err := c.client.Get(c.baseURL + "/api/v1/devices")
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/devices", nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[IoT客户端] 创建请求失败: %v", err)
|
||||||
|
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[IoT客户端] 请求失败: %v", err)
|
log.Printf("[IoT客户端] 请求失败: %v", err)
|
||||||
return nil, fmt.Errorf("获取设备列表失败: %w", err)
|
return nil, fmt.Errorf("获取设备列表失败: %w", err)
|
||||||
@@ -97,8 +104,12 @@ func (c *IoTClient) GetAllDevices() ([]IoTDevice, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDevice 获取单个设备详情
|
// GetDevice 获取单个设备详情
|
||||||
func (c *IoTClient) GetDevice(id string) (*IoTDevice, error) {
|
func (c *IoTClient) GetDevice(ctx context.Context, id string) (*IoTDevice, error) {
|
||||||
resp, err := c.client.Get(c.baseURL + "/api/v1/devices/" + id)
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/devices/"+id, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("获取设备 %s 失败: %w", id, err)
|
return nil, fmt.Errorf("获取设备 %s 失败: %w", id, err)
|
||||||
}
|
}
|
||||||
@@ -195,8 +206,8 @@ func (c *IoTClient) SetDeviceProperty(id string, field string, value interface{}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDevicesForContext 获取设备状态摘要(供上下文注入使用,失败不报错)
|
// GetDevicesForContext 获取设备状态摘要(供上下文注入使用,失败不报错)
|
||||||
func (c *IoTClient) GetDevicesForContext() []IoTDevice {
|
func (c *IoTClient) GetDevicesForContext(ctx context.Context) []IoTDevice {
|
||||||
devices, err := c.GetAllDevices()
|
devices, err := c.GetAllDevices(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[IoT客户端] 获取设备状态摘要失败: %v", err)
|
log.Printf("[IoT客户端] 获取设备状态摘要失败: %v", err)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -149,22 +149,22 @@ func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]inter
|
|||||||
|
|
||||||
// 先获取设备名用于友好的返回消息(失败不影响后续流程)
|
// 先获取设备名用于友好的返回消息(失败不影响后续流程)
|
||||||
deviceName := deviceID
|
deviceName := deviceID
|
||||||
if dev, err := t.iotClient.GetDevice(deviceID); err == nil {
|
if dev, err := t.iotClient.GetDevice(ctx, deviceID); err == nil {
|
||||||
deviceName = dev.Name
|
deviceName = dev.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理属性设置类操作
|
// 处理属性设置类操作
|
||||||
switch action {
|
switch action {
|
||||||
case "set_temperature":
|
case "set_temperature":
|
||||||
return t.handleSetTemperature(deviceID, arguments)
|
return t.handleSetTemperature(ctx, deviceID, arguments)
|
||||||
case "set_brightness":
|
case "set_brightness":
|
||||||
return t.handleSetBrightness(deviceID, arguments)
|
return t.handleSetBrightness(ctx, deviceID, arguments)
|
||||||
case "set_position":
|
case "set_position":
|
||||||
return t.handleSetPosition(deviceID, arguments)
|
return t.handleSetPosition(ctx, deviceID, arguments)
|
||||||
case "set_mode":
|
case "set_mode":
|
||||||
return t.handleSetMode(deviceID, arguments)
|
return t.handleSetMode(ctx, deviceID, arguments)
|
||||||
case "set_color":
|
case "set_color":
|
||||||
return t.handleSetColor(deviceID, arguments)
|
return t.handleSetColor(ctx, deviceID, arguments)
|
||||||
case "turn_off":
|
case "turn_off":
|
||||||
// 声明式关闭:使用 SetDeviceProperty status/off 而非 toggle
|
// 声明式关闭:使用 SetDeviceProperty status/off 而非 toggle
|
||||||
// 即使设备已经关闭,SetProperty 也会幂等处理
|
// 即使设备已经关闭,SetProperty 也会幂等处理
|
||||||
@@ -204,7 +204,7 @@ func (t *IoTControlTool) Execute(ctx context.Context, arguments map[string]inter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取切换后的状态
|
// 获取切换后的状态
|
||||||
updatedDevice, err := t.iotClient.GetDevice(deviceID)
|
updatedDevice, err := t.iotClient.GetDevice(ctx, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ToolName: "iot_control",
|
ToolName: "iot_control",
|
||||||
@@ -230,7 +230,7 @@ func extractValue(arguments map[string]interface{}) interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleSetTemperature 处理设置温度
|
// handleSetTemperature 处理设置温度
|
||||||
func (t *IoTControlTool) handleSetTemperature(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
func (t *IoTControlTool) handleSetTemperature(ctx context.Context, deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
val := extractValue(arguments)
|
val := extractValue(arguments)
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
@@ -241,7 +241,7 @@ func (t *IoTControlTool) handleSetTemperature(deviceID string, arguments map[str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 先获取当前设备信息
|
// 先获取当前设备信息
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
currentDevice, err := t.iotClient.GetDevice(ctx, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ToolName: "iot_control",
|
ToolName: "iot_control",
|
||||||
@@ -275,7 +275,7 @@ func (t *IoTControlTool) handleSetTemperature(deviceID string, arguments map[str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleSetBrightness 处理设置亮度
|
// handleSetBrightness 处理设置亮度
|
||||||
func (t *IoTControlTool) handleSetBrightness(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
func (t *IoTControlTool) handleSetBrightness(ctx context.Context, deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
val := extractValue(arguments)
|
val := extractValue(arguments)
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
@@ -286,7 +286,7 @@ func (t *IoTControlTool) handleSetBrightness(deviceID string, arguments map[stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 先获取当前设备信息
|
// 先获取当前设备信息
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
currentDevice, err := t.iotClient.GetDevice(ctx, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ToolName: "iot_control",
|
ToolName: "iot_control",
|
||||||
@@ -320,7 +320,7 @@ func (t *IoTControlTool) handleSetBrightness(deviceID string, arguments map[stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleSetPosition 处理设置窗帘位置
|
// handleSetPosition 处理设置窗帘位置
|
||||||
func (t *IoTControlTool) handleSetPosition(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
func (t *IoTControlTool) handleSetPosition(ctx context.Context, deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
val := extractValue(arguments)
|
val := extractValue(arguments)
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
@@ -330,7 +330,7 @@ func (t *IoTControlTool) handleSetPosition(deviceID string, arguments map[string
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
currentDevice, err := t.iotClient.GetDevice(ctx, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ToolName: "iot_control",
|
ToolName: "iot_control",
|
||||||
@@ -364,7 +364,7 @@ func (t *IoTControlTool) handleSetPosition(deviceID string, arguments map[string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleSetMode 处理设置空调模式
|
// handleSetMode 处理设置空调模式
|
||||||
func (t *IoTControlTool) handleSetMode(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
func (t *IoTControlTool) handleSetMode(ctx context.Context, deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
val := extractValue(arguments)
|
val := extractValue(arguments)
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
@@ -383,7 +383,7 @@ func (t *IoTControlTool) handleSetMode(deviceID string, arguments map[string]int
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
currentDevice, err := t.iotClient.GetDevice(ctx, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ToolName: "iot_control",
|
ToolName: "iot_control",
|
||||||
@@ -408,7 +408,7 @@ func (t *IoTControlTool) handleSetMode(deviceID string, arguments map[string]int
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleSetColor 处理设置灯光颜色
|
// handleSetColor 处理设置灯光颜色
|
||||||
func (t *IoTControlTool) handleSetColor(deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
func (t *IoTControlTool) handleSetColor(ctx context.Context, deviceID string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
val := extractValue(arguments)
|
val := extractValue(arguments)
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
@@ -427,7 +427,7 @@ func (t *IoTControlTool) handleSetColor(deviceID string, arguments map[string]in
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDevice, err := t.iotClient.GetDevice(deviceID)
|
currentDevice, err := t.iotClient.GetDevice(ctx, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ToolName: "iot_control",
|
ToolName: "iot_control",
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func (t *IoTQueryTool) Execute(ctx context.Context, arguments map[string]interfa
|
|||||||
|
|
||||||
if deviceID != "" {
|
if deviceID != "" {
|
||||||
// 查询单个设备
|
// 查询单个设备
|
||||||
device, err := t.iotClient.GetDevice(deviceID)
|
device, err := t.iotClient.GetDevice(ctx, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ToolName: "iot_query",
|
ToolName: "iot_query",
|
||||||
@@ -63,7 +63,7 @@ func (t *IoTQueryTool) Execute(ctx context.Context, arguments map[string]interfa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查询所有设备
|
// 查询所有设备
|
||||||
devices, err := t.iotClient.GetAllDevices()
|
devices, err := t.iotClient.GetAllDevices(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ToolName: "iot_query",
|
ToolName: "iot_query",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +83,54 @@ func (c *ToolEngineClient) GetDefinitions(ctx context.Context) ([]ToolDefinition
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute 通过 tool-engine 执行工具调用
|
// Execute 通过 tool-engine 执行工具调用
|
||||||
|
// 包含重试逻辑:最多重试 2 次(共 3 次尝试),间隔 100ms
|
||||||
func (c *ToolEngineClient) Execute(ctx context.Context, toolName string, arguments map[string]interface{}) (*ToolResult, error) {
|
func (c *ToolEngineClient) Execute(ctx context.Context, toolName string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
|
const maxRetries = 2
|
||||||
|
const retryDelay = 100 * time.Millisecond
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
log.Printf("[tool-engine-client] 工具 %s 第 %d 次重试 (上次错误: %v)", toolName, attempt, lastErr)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: toolName,
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("请求被取消: %v", ctx.Err()),
|
||||||
|
}, nil
|
||||||
|
case <-time.After(retryDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.executeOnce(ctx, toolName, arguments)
|
||||||
|
if err == nil && result.Success {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
lastErr = fmt.Errorf("%s", result.Error)
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不可重试的错误:工具不存在、参数序列化失败、创建请求失败
|
||||||
|
if result != nil && (strings.Contains(result.Error, "不存在") ||
|
||||||
|
strings.Contains(result.Error, "序列化") ||
|
||||||
|
strings.Contains(result.Error, "创建请求")) {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[tool-engine-client] 工具 %s 所有重试均失败 (最后错误: %v)", toolName, lastErr)
|
||||||
|
return &ToolResult{
|
||||||
|
ToolName: toolName,
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("请求 tool-engine 失败 (已重试 %d 次): %v", maxRetries, lastErr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeOnce 执行单次工具调用(不含重试逻辑)
|
||||||
|
func (c *ToolEngineClient) executeOnce(ctx context.Context, toolName string, arguments map[string]interface{}) (*ToolResult, error) {
|
||||||
body, err := json.Marshal(map[string]interface{}{
|
body, err := json.Marshal(map[string]interface{}{
|
||||||
"arguments": arguments,
|
"arguments": arguments,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ========== 构建阶段 ==========
|
# ========== 构建阶段 ==========
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,11 @@ func main() {
|
|||||||
hub.StartIoTBroadcast(cfg.IoTDebugServiceURL)
|
hub.StartIoTBroadcast(cfg.IoTDebugServiceURL)
|
||||||
|
|
||||||
// 注册路由
|
// 注册路由
|
||||||
router.Setup(r, hub, cfg, sessionStore, reminderStore, briefingStore, automationStore, fileStore, ruleEngine, knowledgeStore, nil)
|
var db interface{}
|
||||||
|
if sessionStore != nil {
|
||||||
|
db = sessionStore.DB()
|
||||||
|
}
|
||||||
|
router.Setup(r, hub, cfg, sessionStore, reminderStore, briefingStore, automationStore, fileStore, ruleEngine, knowledgeStore, nil, db)
|
||||||
|
|
||||||
// 启动提醒调度器
|
// 启动提醒调度器
|
||||||
if reminderStore != nil {
|
if reminderStore != nil {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||||
)
|
)
|
||||||
@@ -13,11 +17,12 @@ import (
|
|||||||
// AuthHandler 认证处理器
|
// AuthHandler 认证处理器
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthHandler 创建认证处理器
|
// NewAuthHandler 创建认证处理器
|
||||||
func NewAuthHandler(cfg *config.Config) *AuthHandler {
|
func NewAuthHandler(cfg *config.Config, db *sql.DB) *AuthHandler {
|
||||||
return &AuthHandler{cfg: cfg}
|
return &AuthHandler{cfg: cfg, db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 用户注册 (需要邮箱验证码、昵称必填)
|
// Register 用户注册 (需要邮箱验证码、昵称必填)
|
||||||
@@ -96,7 +101,16 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
userID = "admin_" + req.Username
|
userID = "admin_" + req.Username
|
||||||
} else {
|
} else {
|
||||||
// MVP阶段:普通用户登录 (简化逻辑,后续需要验证密码哈希)
|
// 普通用户登录:从数据库查询密码哈希并验证
|
||||||
|
authenticated, err := h.verifyUserPassword(req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !authenticated {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
userID = "user_" + req.Username
|
userID = "user_" + req.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +127,44 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyUserPassword 验证普通用户的密码
|
||||||
|
// 从数据库查询用户的密码哈希并与输入密码比对
|
||||||
|
// 如果用户不存在,返回 (false, nil);如果密码匹配,返回 (true, nil)
|
||||||
|
func (h *AuthHandler) verifyUserPassword(username, password string) (bool, error) {
|
||||||
|
if h.db == nil {
|
||||||
|
// 数据库不可用时回退到简单验证(开发阶段兼容)
|
||||||
|
// 仅允许固定测试密码,不允许空密码登录
|
||||||
|
return password == "test123", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedHash string
|
||||||
|
err := h.db.QueryRow(
|
||||||
|
"SELECT password_hash FROM users WHERE username = $1",
|
||||||
|
username,
|
||||||
|
).Scan(&storedHash)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// 用户不存在
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("查询用户失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 bcrypt 验证密码哈希
|
||||||
|
// 如果存储的是明文密码(向后兼容),则直接比对
|
||||||
|
if strings.HasPrefix(storedHash, "$2a$") || strings.HasPrefix(storedHash, "$2b$") || strings.HasPrefix(storedHash, "$2y$") {
|
||||||
|
// bcrypt 哈希
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 明文密码向后兼容(开发阶段)
|
||||||
|
return password == storedHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新令牌
|
// RefreshToken 刷新令牌
|
||||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
|||||||
@@ -221,6 +221,9 @@ func (h *BriefingHandler) GenerateDailyBriefing(userID string) (*store.Briefing,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[briefing] AI 摘要生成失败 (降级): %v", err)
|
log.Printf("[briefing] AI 摘要生成失败 (降级): %v", err)
|
||||||
summary = h.buildFallbackSummary(briefing)
|
summary = h.buildFallbackSummary(briefing)
|
||||||
|
briefing.SummarySource = "fallback"
|
||||||
|
} else {
|
||||||
|
briefing.SummarySource = "ai"
|
||||||
}
|
}
|
||||||
briefing.Summary = summary
|
briefing.Summary = summary
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -576,8 +577,15 @@ func (h *SessionHandler) exportTXT(c *gin.Context, session *store.Session, messa
|
|||||||
func randomID(n int) string {
|
func randomID(n int) string {
|
||||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// fallback to deterministic IDs only if crypto/rand fails
|
||||||
|
for i := range b {
|
||||||
|
b[i] = letters[i%len(letters)]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
for i := range b {
|
for i := range b {
|
||||||
b[i] = letters[i%len(letters)]
|
b[i] = letters[int(b[i])%len(letters)]
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
// Auth 用户键值在context中的key
|
// Auth 用户键值在context中的key
|
||||||
const UserIDKey = "user_id"
|
const UserIDKey = "user_id"
|
||||||
|
const IsAdminKey = "is_admin"
|
||||||
|
|
||||||
// JWTAuth JWT认证中间件
|
// JWTAuth JWT认证中间件
|
||||||
func JWTAuth(cfg *config.Config) gin.HandlerFunc {
|
func JWTAuth(cfg *config.Config) gin.HandlerFunc {
|
||||||
@@ -40,6 +41,8 @@ func JWTAuth(cfg *config.Config) gin.HandlerFunc {
|
|||||||
|
|
||||||
// 将userID注入上下文
|
// 将userID注入上下文
|
||||||
c.Set(UserIDKey, userID)
|
c.Set(UserIDKey, userID)
|
||||||
|
// 设置管理员标记 (admin 用户 ID 以 "admin_" 为前缀)
|
||||||
|
c.Set(IsAdminKey, strings.HasPrefix(userID, "admin_"))
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -15,12 +15,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Setup 注册所有路由
|
// Setup 注册所有路由
|
||||||
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) {
|
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, db interface{}) {
|
||||||
// 限流器
|
// 限流器
|
||||||
rateLimiter := middleware.NewRateLimiter(10, 20) // 每秒10个请求,突发20
|
rateLimiter := middleware.NewRateLimiter(10, 20) // 每秒10个请求,突发20
|
||||||
|
|
||||||
// 初始化处理器
|
// 初始化处理器
|
||||||
authHandler := handler.NewAuthHandler(cfg)
|
var authDB *sql.DB
|
||||||
|
if db != nil {
|
||||||
|
authDB = db.(*sql.DB)
|
||||||
|
}
|
||||||
|
authHandler := handler.NewAuthHandler(cfg, authDB)
|
||||||
sessionHandler := handler.NewSessionHandler(hub, sessionStore)
|
sessionHandler := handler.NewSessionHandler(hub, sessionStore)
|
||||||
memoryHandler := handler.NewMemoryHandler(cfg.MemoryServiceURL)
|
memoryHandler := handler.NewMemoryHandler(cfg.MemoryServiceURL)
|
||||||
chatHandler := handler.NewChatHandler(cfg, hub)
|
chatHandler := handler.NewChatHandler(cfg, hub)
|
||||||
@@ -227,11 +231,11 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminAuth 管理员权限中间件 (检查 userID 是否以 "admin_" 开头)
|
// adminAuth 管理员权限中间件 (检查认证中间件设置的 is_admin 标记)
|
||||||
func adminAuth() gin.HandlerFunc {
|
func adminAuth() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
userID := middleware.GetUserID(c)
|
isAdmin, _ := c.Get(middleware.IsAdminKey)
|
||||||
if userID == "" || !strings.HasPrefix(userID, "admin_") {
|
if isAdmin == nil || !isAdmin.(bool) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -10,17 +10,18 @@ import (
|
|||||||
|
|
||||||
// Briefing 每日简报模型
|
// Briefing 每日简报模型
|
||||||
type Briefing struct {
|
type Briefing struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Date string `json:"date"` // YYYY-MM-DD
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
Weather *WeatherData `json:"weather"`
|
Weather *WeatherData `json:"weather"`
|
||||||
News []NewsItem `json:"news"`
|
News []NewsItem `json:"news"`
|
||||||
Reminders []BriefReminder `json:"reminders"`
|
Reminders []BriefReminder `json:"reminders"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Status string `json:"status"` // pending, generated, delivered
|
SummarySource string `json:"summary_source"` // "ai" | "fallback"
|
||||||
GeneratedAt *time.Time `json:"generated_at,omitempty"`
|
Status string `json:"status"` // pending, generated, delivered
|
||||||
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
|
GeneratedAt *time.Time `json:"generated_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeatherData 天气数据
|
// WeatherData 天气数据
|
||||||
@@ -74,12 +75,14 @@ func (s *BriefingStore) migrate() error {
|
|||||||
news JSONB DEFAULT '[]',
|
news JSONB DEFAULT '[]',
|
||||||
reminders JSONB DEFAULT '[]',
|
reminders JSONB DEFAULT '[]',
|
||||||
summary TEXT DEFAULT '',
|
summary TEXT DEFAULT '',
|
||||||
|
summary_source VARCHAR(20) DEFAULT 'ai',
|
||||||
status VARCHAR(20) DEFAULT 'pending',
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
generated_at TIMESTAMPTZ,
|
generated_at TIMESTAMPTZ,
|
||||||
delivered_at TIMESTAMPTZ,
|
delivered_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(user_id, date)
|
UNIQUE(user_id, date)
|
||||||
)`,
|
)`,
|
||||||
|
`ALTER TABLE daily_briefings ADD COLUMN IF NOT EXISTS summary_source VARCHAR(20) DEFAULT 'ai'`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_briefings_user_id ON daily_briefings(user_id)`,
|
`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_date ON daily_briefings(date)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_briefings_user_date ON daily_briefings(user_id, date)`,
|
`CREATE INDEX IF NOT EXISTS idx_briefings_user_date ON daily_briefings(user_id, date)`,
|
||||||
@@ -108,19 +111,24 @@ func (s *BriefingStore) CreateOrUpdateBriefing(b *Briefing) error {
|
|||||||
return fmt.Errorf("序列化提醒数据失败: %w", err)
|
return fmt.Errorf("序列化提醒数据失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if b.SummarySource == "" {
|
||||||
|
b.SummarySource = "ai"
|
||||||
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(
|
_, err = s.db.Exec(
|
||||||
`INSERT INTO daily_briefings (id, user_id, date, weather, news, reminders, summary, status, generated_at, delivered_at)
|
`INSERT INTO daily_briefings (id, user_id, date, weather, news, reminders, summary, summary_source, status, generated_at, delivered_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||||
weather = EXCLUDED.weather,
|
weather = EXCLUDED.weather,
|
||||||
news = EXCLUDED.news,
|
news = EXCLUDED.news,
|
||||||
reminders = EXCLUDED.reminders,
|
reminders = EXCLUDED.reminders,
|
||||||
summary = EXCLUDED.summary,
|
summary = EXCLUDED.summary,
|
||||||
|
summary_source = EXCLUDED.summary_source,
|
||||||
status = EXCLUDED.status,
|
status = EXCLUDED.status,
|
||||||
generated_at = EXCLUDED.generated_at,
|
generated_at = EXCLUDED.generated_at,
|
||||||
delivered_at = EXCLUDED.delivered_at`,
|
delivered_at = EXCLUDED.delivered_at`,
|
||||||
b.ID, b.UserID, b.Date, string(weatherJSON), string(newsJSON), string(remindersJSON),
|
b.ID, b.UserID, b.Date, string(weatherJSON), string(newsJSON), string(remindersJSON),
|
||||||
b.Summary, b.Status, b.GeneratedAt, b.DeliveredAt,
|
b.Summary, b.SummarySource, b.Status, b.GeneratedAt, b.DeliveredAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("upsert 简报失败: %w", err)
|
return fmt.Errorf("upsert 简报失败: %w", err)
|
||||||
@@ -131,7 +139,7 @@ func (s *BriefingStore) CreateOrUpdateBriefing(b *Briefing) error {
|
|||||||
// GetBriefingByDate 获取指定日期简报
|
// GetBriefingByDate 获取指定日期简报
|
||||||
func (s *BriefingStore) GetBriefingByDate(userID, date string) (*Briefing, error) {
|
func (s *BriefingStore) GetBriefingByDate(userID, date string) (*Briefing, error) {
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
`SELECT id, user_id, date::TEXT, weather, news, reminders, summary, status, generated_at, delivered_at, created_at
|
`SELECT id, user_id, date::TEXT, weather, news, reminders, summary, COALESCE(summary_source, 'ai'), status, generated_at, delivered_at, created_at
|
||||||
FROM daily_briefings WHERE user_id = $1 AND date = $2::DATE`,
|
FROM daily_briefings WHERE user_id = $1 AND date = $2::DATE`,
|
||||||
userID, date,
|
userID, date,
|
||||||
)
|
)
|
||||||
@@ -153,7 +161,7 @@ func (s *BriefingStore) GetLatestBriefings(userID string, limit int) ([]Briefing
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id, user_id, date::TEXT, weather, news, reminders, summary, status, generated_at, delivered_at, created_at
|
`SELECT id, user_id, date::TEXT, weather, news, reminders, summary, COALESCE(summary_source, 'ai'), status, generated_at, delivered_at, created_at
|
||||||
FROM daily_briefings WHERE user_id = $1
|
FROM daily_briefings WHERE user_id = $1
|
||||||
ORDER BY date DESC LIMIT $2`,
|
ORDER BY date DESC LIMIT $2`,
|
||||||
userID, limit,
|
userID, limit,
|
||||||
@@ -166,21 +174,22 @@ func (s *BriefingStore) GetLatestBriefings(userID string, limit int) ([]Briefing
|
|||||||
var briefings []Briefing
|
var briefings []Briefing
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
id, uid, date, summary, status string
|
id, uid, date, summary, summarySource, status string
|
||||||
weatherRaw, newsRaw, remindersRaw []byte
|
weatherRaw, newsRaw, remindersRaw []byte
|
||||||
generatedAt, deliveredAt, createdAt sql.NullTime
|
generatedAt, deliveredAt, createdAt sql.NullTime
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&id, &uid, &date, &weatherRaw, &newsRaw, &remindersRaw,
|
if err := rows.Scan(&id, &uid, &date, &weatherRaw, &newsRaw, &remindersRaw,
|
||||||
&summary, &status, &generatedAt, &deliveredAt, &createdAt); err != nil {
|
&summary, &summarySource, &status, &generatedAt, &deliveredAt, &createdAt); err != nil {
|
||||||
return nil, fmt.Errorf("扫描简报行失败: %w", err)
|
return nil, fmt.Errorf("扫描简报行失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
b := Briefing{
|
b := Briefing{
|
||||||
ID: id,
|
ID: id,
|
||||||
UserID: uid,
|
UserID: uid,
|
||||||
Date: date,
|
Date: date,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Status: status,
|
SummarySource: summarySource,
|
||||||
|
Status: status,
|
||||||
}
|
}
|
||||||
|
|
||||||
if weatherRaw != nil {
|
if weatherRaw != nil {
|
||||||
@@ -270,22 +279,23 @@ func (s *BriefingStore) GetAllUsers() ([]string, error) {
|
|||||||
// scanBriefing 扫描单行简报
|
// scanBriefing 扫描单行简报
|
||||||
func (s *BriefingStore) scanBriefing(row *sql.Row) (*Briefing, error) {
|
func (s *BriefingStore) scanBriefing(row *sql.Row) (*Briefing, error) {
|
||||||
var (
|
var (
|
||||||
id, uid, date, summary, status string
|
id, uid, date, summary, summarySource, status string
|
||||||
weatherRaw, newsRaw, remindersRaw []byte
|
weatherRaw, newsRaw, remindersRaw []byte
|
||||||
generatedAt, deliveredAt, createdAt sql.NullTime
|
generatedAt, deliveredAt, createdAt sql.NullTime
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := row.Scan(&id, &uid, &date, &weatherRaw, &newsRaw, &remindersRaw,
|
if err := row.Scan(&id, &uid, &date, &weatherRaw, &newsRaw, &remindersRaw,
|
||||||
&summary, &status, &generatedAt, &deliveredAt, &createdAt); err != nil {
|
&summary, &summarySource, &status, &generatedAt, &deliveredAt, &createdAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
b := &Briefing{
|
b := &Briefing{
|
||||||
ID: id,
|
ID: id,
|
||||||
UserID: uid,
|
UserID: uid,
|
||||||
Date: date,
|
Date: date,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Status: status,
|
SummarySource: summarySource,
|
||||||
|
Status: status,
|
||||||
}
|
}
|
||||||
|
|
||||||
if weatherRaw != nil {
|
if weatherRaw != nil {
|
||||||
|
|||||||
@@ -71,10 +71,13 @@ func (s *ReminderStore) migrate() error {
|
|||||||
|
|
||||||
// CreateReminder 创建新提醒
|
// CreateReminder 创建新提醒
|
||||||
func (s *ReminderStore) CreateReminder(r *Reminder) error {
|
func (s *ReminderStore) CreateReminder(r *Reminder) error {
|
||||||
|
if r.CreatedAt.IsZero() {
|
||||||
|
r.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO reminders (id, user_id, title, description, remind_at, status, repeat_type, session_id)
|
`INSERT INTO reminders (id, user_id, title, description, remind_at, status, created_at, repeat_type, session_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
r.ID, r.UserID, r.Title, r.Description, r.RemindAt, r.Status, r.RepeatType, r.SessionID,
|
r.ID, r.UserID, r.Title, r.Description, r.RemindAt, r.Status, r.CreatedAt, r.RepeatType, r.SessionID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("创建提醒失败: %w", err)
|
return fmt.Errorf("创建提醒失败: %w", err)
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ func NewHub() *Hub {
|
|||||||
// 每5分钟检查一次,将超过 idleTimeout 无活动的会话标记为 idle
|
// 每5分钟检查一次,将超过 idleTimeout 无活动的会话标记为 idle
|
||||||
func (h *Hub) StartIdleCleanup() {
|
func (h *Hub) StartIdleCleanup() {
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[WS] 闲置会话清理 panic 恢复: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -453,7 +458,14 @@ func (h *Hub) StartIoTBroadcast(iotServiceURL string) {
|
|||||||
h.iotPollRunning = true
|
h.iotPollRunning = true
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
go h.iotPollLoop()
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[IoT广播] 轮询循环 panic 恢复: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
h.iotPollLoop()
|
||||||
|
}()
|
||||||
log.Printf("[IoT广播] 已启动 (IoT服务地址: %s)", iotServiceURL)
|
log.Printf("[IoT广播] 已启动 (IoT服务地址: %s)", iotServiceURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ========== 构建阶段 ==========
|
# ========== 构建阶段 ==========
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|||||||
@@ -37,9 +37,14 @@ func (svc *MemoryService) CreateMemory(ctx context.Context, entry *model.MemoryE
|
|||||||
if entry.Importance < 1 {
|
if entry.Importance < 1 {
|
||||||
entry.Importance = 5
|
entry.Importance = 5
|
||||||
}
|
}
|
||||||
if entry.Priority < 0 || entry.Priority > 3 {
|
// Priority 范围检查:扩展为 [0, 10] 以支持用户自定义高优先级记忆
|
||||||
|
// 低于 0 的视为无效,重置为 normal;高于 10 的截断到 10
|
||||||
|
if entry.Priority < 0 {
|
||||||
entry.Priority = model.MemoryNormal
|
entry.Priority = model.MemoryNormal
|
||||||
}
|
}
|
||||||
|
if entry.Priority > 10 {
|
||||||
|
entry.Priority = 10
|
||||||
|
}
|
||||||
if entry.Source == "" {
|
if entry.Source == "" {
|
||||||
entry.Source = "manual"
|
entry.Source = "manual"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# ========== 构建阶段 ==========
|
||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制 go.mod 并下载依赖(利用 Docker 缓存层)
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 编译 (静态链接,适配 Alpine)
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /voice-service ./cmd/main.go
|
||||||
|
|
||||||
|
# ========== 运行阶段 ==========
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata && \
|
||||||
|
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||||
|
echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 从构建阶段复制二进制文件
|
||||||
|
COPY --from=builder /voice-service .
|
||||||
|
|
||||||
|
# 非 root 用户
|
||||||
|
RUN adduser -D -H cyrene
|
||||||
|
USER cyrene
|
||||||
|
|
||||||
|
EXPOSE 8093
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8093/api/v1/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["./voice-service"]
|
||||||
@@ -51,20 +51,7 @@ func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 TTS 引擎是否可用
|
// 调用合成 (Synthesize 内部已包含 fallback 链: edge-tts → espeak-ng → 静默 WAV)
|
||||||
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)
|
audioData, format, err := h.svc.Synthesize(req.Text, req.Voice, req.Rate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[tts-handler] TTS 合成失败: %v", err)
|
log.Printf("[tts-handler] TTS 合成失败: %v", err)
|
||||||
|
|||||||
+6
-2
@@ -34,12 +34,16 @@
|
|||||||
使用 `curl` 启动所有服务,再通过 `curl` 等工具对实现的功能进行接口调试。
|
使用 `curl` 启动所有服务,再通过 `curl` 等工具对实现的功能进行接口调试。
|
||||||
`devtools` 提供的 API 可启动各前后端服务,请牢记这个流程喵!
|
`devtools` 提供的 API 可启动各前后端服务,请牢记这个流程喵!
|
||||||
|
|
||||||
**5. 版本提交规范**
|
**5. 数据库连接**
|
||||||
|
|
||||||
|
- 开发阶段的数据库连接请使用 `scripts/tunnel.sh` 脚本建立到数据库服务器的连接。
|
||||||
|
|
||||||
|
**6. 版本提交规范**
|
||||||
|
|
||||||
- 当用户要求的某个功能已完全修复、编写完成并验证成功后,可向当前分支(如 `dev`)进行推送。
|
- 当用户要求的某个功能已完全修复、编写完成并验证成功后,可向当前分支(如 `dev`)进行推送。
|
||||||
- **禁止提交的内容:** `docs/` 文件夹以及编译后的二进制文件、其他语言环境的依赖和项目临时环境。
|
- **禁止提交的内容:** `docs/` 文件夹以及编译后的二进制文件、其他语言环境的依赖和项目临时环境。
|
||||||
|
|
||||||
**6. 测试脚本临时管理**
|
**7. 测试脚本临时管理**
|
||||||
|
|
||||||
- 在测试长脚本或复杂命令时,可以在项目根目录临时创建 `test` 文件夹,并在其中新建 sh, py 等脚本文件并运行。
|
- 在测试长脚本或复杂命令时,可以在项目根目录临时创建 `test` 文件夹,并在其中新建 sh, py 等脚本文件并运行。
|
||||||
- **注意:** 用完记得及时删除喵~
|
- **注意:** 用完记得及时删除喵~
|
||||||
|
|||||||
+37
-5
@@ -8,6 +8,8 @@ set -e
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
DEVTOOLS_DIR="$SCRIPT_DIR/devtools"
|
DEVTOOLS_DIR="$SCRIPT_DIR/devtools"
|
||||||
PORT="${DEVTOOLS_PORT:-9090}"
|
PORT="${DEVTOOLS_PORT:-9090}"
|
||||||
|
LOG_DIR="$SCRIPT_DIR/logs"
|
||||||
|
LOG_FILE="$LOG_DIR/sh.log"
|
||||||
|
|
||||||
# 颜色输出
|
# 颜色输出
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -63,7 +65,7 @@ if [ ! -d "node_modules" ] || [ ! -f "node_modules/.package-lock.json" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 确保日志目录存在
|
# 确保日志目录存在
|
||||||
mkdir -p logs
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}🚀 启动 DevTools 服务器 (端口: $PORT)...${NC}"
|
echo -e "${GREEN}🚀 启动 DevTools 服务器 (端口: $PORT)...${NC}"
|
||||||
@@ -71,8 +73,38 @@ echo -e "${CYAN} Web 控制台: http://localhost:$PORT${NC}"
|
|||||||
echo -e "${CYAN} API: http://localhost:$PORT/api/health${NC}"
|
echo -e "${CYAN} API: http://localhost:$PORT/api/health${NC}"
|
||||||
echo -e "${CYAN} WebSocket: ws://localhost:$PORT/ws${NC}"
|
echo -e "${CYAN} WebSocket: ws://localhost:$PORT/ws${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW} 按 Ctrl+C 退出 (将自动停止所有托管服务)${NC}"
|
echo -e "${YELLOW}⏳ 正在后台启动所有服务...${NC}"
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 启动 DevTools
|
# 后台启动 DevTools,日志写入 ./logs/sh.log
|
||||||
exec node src/index.js
|
nohup node src/index.js > "$LOG_FILE" 2>&1 &
|
||||||
|
DEVTOOLS_PID=$!
|
||||||
|
|
||||||
|
# 健康检查(最多等待 30 秒)
|
||||||
|
MAX_WAIT=30
|
||||||
|
WAITED=0
|
||||||
|
while [ $WAITED -lt $MAX_WAIT ]; do
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/api/health" 2>/dev/null || true)
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ 所有服务启动完成!${NC}"
|
||||||
|
echo -e "${CYAN} PID: ${DEVTOOLS_PID}${NC}"
|
||||||
|
echo -e "${CYAN} 日志文件: ${LOG_FILE}${NC}"
|
||||||
|
echo ""
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
WAITED=$((WAITED + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# 超时处理
|
||||||
|
if kill -0 "$DEVTOOLS_PID" 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}⚠ 服务可能仍在启动中(已等待 ${MAX_WAIT} 秒)${NC}"
|
||||||
|
echo -e "${CYAN} PID: ${DEVTOOLS_PID}${NC}"
|
||||||
|
echo -e "${CYAN} 日志文件: ${LOG_FILE}${NC}"
|
||||||
|
echo -e "${YELLOW} 请稍后检查 http://localhost:$PORT/api/health${NC}"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}❌ 服务启动失败,请检查日志: ${LOG_FILE}${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
@@ -58,6 +58,53 @@ services:
|
|||||||
- "8222:8222"
|
- "8222:8222"
|
||||||
|
|
||||||
# ========== 后端服务 ==========
|
# ========== 后端服务 ==========
|
||||||
|
memory-service:
|
||||||
|
build:
|
||||||
|
context: ./backend/memory-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
PORT: "8091"
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: "5432"
|
||||||
|
POSTGRES_USER: cyrene
|
||||||
|
POSTGRES_PASSWORD: change_me
|
||||||
|
POSTGRES_DB: cyrene_ai
|
||||||
|
ports:
|
||||||
|
- "8091:8091"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
tool-engine:
|
||||||
|
build:
|
||||||
|
context: ./backend/tool-engine
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
PORT: "8092"
|
||||||
|
IOT_SERVICE_URL: http://iot-debug-service:8083
|
||||||
|
DATA_DIR: /tmp/cyrene_data
|
||||||
|
DB_URL: "postgres://cyrene:change_me@postgres:5432/cyrene_ai?sslmode=disable"
|
||||||
|
ports:
|
||||||
|
- "8092:8092"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
voice-service:
|
||||||
|
build:
|
||||||
|
context: ./backend/voice-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
PORT: "8093"
|
||||||
|
WHISPER_BINARY: "./whisper.cpp/main"
|
||||||
|
WHISPER_MODEL: "./whisper.cpp/models/ggml-small.bin"
|
||||||
|
WHISPER_LANGUAGE: "zh"
|
||||||
|
ports:
|
||||||
|
- "8093:8093"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
ai-core:
|
ai-core:
|
||||||
build:
|
build:
|
||||||
context: ./backend/ai-core
|
context: ./backend/ai-core
|
||||||
@@ -105,6 +152,10 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-in-production}
|
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-in-production}
|
||||||
JWT_EXPIRY_HOURS: "720"
|
JWT_EXPIRY_HOURS: "720"
|
||||||
AI_CORE_URL: http://ai-core:8081
|
AI_CORE_URL: http://ai-core:8081
|
||||||
|
MEMORY_SERVICE_URL: http://memory-service:8091
|
||||||
|
TOOL_ENGINE_URL: http://tool-engine:8092
|
||||||
|
VOICE_SERVICE_URL: http://voice-service:8093
|
||||||
|
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
|
||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
POSTGRES_PORT: "5432"
|
POSTGRES_PORT: "5432"
|
||||||
POSTGRES_USER: cyrene
|
POSTGRES_USER: cyrene
|
||||||
@@ -121,6 +172,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ai-core:
|
ai-core:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
memory-service:
|
||||||
|
condition: service_started
|
||||||
|
tool-engine:
|
||||||
|
condition: service_started
|
||||||
|
voice-service:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
JWT_EXPIRY_HOURS: "720"
|
JWT_EXPIRY_HOURS: "720"
|
||||||
AI_CORE_URL: http://ai-core:8081
|
AI_CORE_URL: http://ai-core:8081
|
||||||
|
MEMORY_SERVICE_URL: http://memory-service:8091
|
||||||
|
TOOL_ENGINE_URL: http://tool-engine:8092
|
||||||
|
VOICE_SERVICE_URL: http://voice-service:8093
|
||||||
|
IOT_DEBUG_SERVICE_URL: http://iot-debug-service:8083
|
||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
POSTGRES_PORT: "5432"
|
POSTGRES_PORT: "5432"
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-cyrene}
|
POSTGRES_USER: ${POSTGRES_USER:-cyrene}
|
||||||
@@ -58,6 +62,47 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
memory-service:
|
||||||
|
build: ./backend/memory-service
|
||||||
|
environment:
|
||||||
|
PORT: "8091"
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: "5432"
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-cyrene}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-cyrene_ai}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
tool-engine:
|
||||||
|
build: ./backend/tool-engine
|
||||||
|
environment:
|
||||||
|
PORT: "8092"
|
||||||
|
IOT_SERVICE_URL: http://iot-debug-service:8083
|
||||||
|
DATA_DIR: /tmp/cyrene_data
|
||||||
|
DB_URL: "postgres://${POSTGRES_USER:-cyrene}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-cyrene_ai}?sslmode=disable"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
voice-service:
|
||||||
|
build: ./backend/voice-service
|
||||||
|
environment:
|
||||||
|
PORT: "8093"
|
||||||
|
WHISPER_BINARY: "./whisper.cpp/main"
|
||||||
|
WHISPER_MODEL: "./whisper.cpp/models/ggml-small.bin"
|
||||||
|
WHISPER_LANGUAGE: "zh"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
iot-debug-service:
|
||||||
|
build: ./backend/iot-debug-service
|
||||||
|
environment:
|
||||||
|
IOT_DEBUG_PORT: "8083"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# ========== 基础设施 ==========
|
# ========== 基础设施 ==========
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg16
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -8,6 +8,12 @@
|
|||||||
"theme_color": "#ec4899",
|
"theme_color": "#ec4899",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"icons": [
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png",
|
"src": "/images/Cyrene_Avatar/2nd_Form/Cyrene-2F-N-Happy-1.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface Briefing {
|
|||||||
news: NewsItem[];
|
news: NewsItem[];
|
||||||
reminders: BriefReminder[];
|
reminders: BriefReminder[];
|
||||||
summary: string;
|
summary: string;
|
||||||
|
summary_source: 'ai' | 'fallback';
|
||||||
status: 'pending' | 'generated' | 'delivered';
|
status: 'pending' | 'generated' | 'delivered';
|
||||||
generated_at?: string;
|
generated_at?: string;
|
||||||
delivered_at?: string;
|
delivered_at?: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
|
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
|
||||||
import type { MessageAttachment } from '@/types/chat';
|
import type { MessageAttachment, MultiMessageItem, StreamSegment } from '@/types/chat';
|
||||||
import { ImageLightbox } from './ImageLightbox';
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
@@ -11,6 +11,8 @@ interface MessageBubbleProps {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
attachments?: MessageAttachment[];
|
attachments?: MessageAttachment[];
|
||||||
|
multiMessages?: MultiMessageItem[];
|
||||||
|
streamSegments?: StreamSegment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,7 +139,7 @@ function AIMessageActions({ content }: { content: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({ role, content, timestamp, isStreaming, attachments }: MessageBubbleProps) {
|
export function MessageBubble({ role, content, timestamp, isStreaming, attachments, multiMessages, streamSegments }: MessageBubbleProps) {
|
||||||
const isUser = role === 'user';
|
const isUser = role === 'user';
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||||
@@ -172,13 +174,43 @@ export function MessageBubble({ role, content, timestamp, isStreaming, attachmen
|
|||||||
${isStreaming ? 'message-streaming' : ''}
|
${isStreaming ? 'message-streaming' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<p className="whitespace-pre-wrap break-words">
|
|
||||||
{isStreaming ? displayedContent : content}
|
{/* 多段消息渲染 (multi_message 类型) */}
|
||||||
{/* 流式消息末尾闪烁光标 — 用独立 span 避免 ::after 在隐藏字符后错位 */}
|
{multiMessages && multiMessages.length > 0 && !isStreaming && (
|
||||||
{hasMoreChars && (
|
<div className="space-y-2">
|
||||||
<span className="animate-streaming-cursor" />
|
{multiMessages
|
||||||
)}
|
.sort((a, b) => a.index - b.index)
|
||||||
</p>
|
.map((item) => (
|
||||||
|
<p key={item.index} className="whitespace-pre-wrap break-words">
|
||||||
|
{item.content}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 流式片段渲染 (stream_segments 类型) */}
|
||||||
|
{streamSegments && streamSegments.length > 0 && !isStreaming && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{streamSegments
|
||||||
|
.sort((a, b) => a.index - b.index)
|
||||||
|
.map((seg) => (
|
||||||
|
<p key={seg.index} className="whitespace-pre-wrap break-words text-gray-600 dark:text-gray-400">
|
||||||
|
{seg.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 普通文本 / 流式文本 */}
|
||||||
|
{(!multiMessages || multiMessages.length === 0) && (!streamSegments || streamSegments.length === 0) && (
|
||||||
|
<p className="whitespace-pre-wrap break-words">
|
||||||
|
{isStreaming ? displayedContent : content}
|
||||||
|
{/* 流式消息末尾闪烁光标 — 用独立 span 避免 ::after 在隐藏字符后错位 */}
|
||||||
|
{hasMoreChars && (
|
||||||
|
<span className="animate-streaming-cursor" />
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 图片附件网格 */}
|
{/* 图片附件网格 */}
|
||||||
{!isStreaming && imageAttachments.length > 0 && (
|
{!isStreaming && imageAttachments.length > 0 && (
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
|
|||||||
timestamp={msg.timestamp}
|
timestamp={msg.timestamp}
|
||||||
isStreaming={msg.isStreaming}
|
isStreaming={msg.isStreaming}
|
||||||
attachments={msg.attachments}
|
attachments={msg.attachments}
|
||||||
|
multiMessages={(msg as any).multiMessages}
|
||||||
|
streamSegments={(msg as any).streamSegments}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isTyping && !messages.some((m) => m.isStreaming) && <TypingIndicator />}
|
{isTyping && !messages.some((m) => m.isStreaming) && <TypingIndicator />}
|
||||||
|
|||||||
@@ -306,6 +306,11 @@ function BriefingCard({ briefing }: { briefing: Briefing }) {
|
|||||||
<h4 className="text-xs font-semibold text-purple-600 dark:text-purple-400">
|
<h4 className="text-xs font-semibold text-purple-600 dark:text-purple-400">
|
||||||
昔涟的每日问候
|
昔涟的每日问候
|
||||||
</h4>
|
</h4>
|
||||||
|
{briefing.summary_source === 'fallback' && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
本地生成
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap">
|
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap">
|
||||||
{briefing.summary}
|
{briefing.summary}
|
||||||
|
|||||||
@@ -67,6 +67,18 @@ export interface IoTDeviceUpdate {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多段消息子项 (子会话架构 multi_message 类型) */
|
||||||
|
export interface MultiMessageItem {
|
||||||
|
index: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流式片段 (子会话架构 stream_segments 类型) */
|
||||||
|
export interface StreamSegment {
|
||||||
|
index: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** WebSocket 客户端消息 */
|
/** WebSocket 客户端消息 */
|
||||||
export interface WSClientMessage {
|
export interface WSClientMessage {
|
||||||
type: 'message' | 'voice_input' | 'ping' | 'history';
|
type: 'message' | 'voice_input' | 'ping' | 'history';
|
||||||
@@ -99,7 +111,7 @@ export interface AppNotification extends NotificationData {
|
|||||||
|
|
||||||
/** WebSocket 服务端消息 */
|
/** WebSocket 服务端消息 */
|
||||||
export interface WSServerMessage {
|
export interface WSServerMessage {
|
||||||
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification';
|
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification' | 'multi_message' | 'stream_segments';
|
||||||
message_id?: string;
|
message_id?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -111,6 +123,8 @@ export interface WSServerMessage {
|
|||||||
tool_calls?: ToolCall[];
|
tool_calls?: ToolCall[];
|
||||||
error?: string;
|
error?: string;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
|
multi_messages?: MultiMessageItem[];
|
||||||
|
stream_segments?: StreamSegment[];
|
||||||
devices?: IoTDevice[];
|
devices?: IoTDevice[];
|
||||||
thinking_status?: BackgroundThinkingStatus;
|
thinking_status?: BackgroundThinkingStatus;
|
||||||
notification?: NotificationData;
|
notification?: NotificationData;
|
||||||
|
|||||||
Reference in New Issue
Block a user