init: 昔涟项目骨架
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# .editorconfig (项目根目录)
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
backend/.env
|
||||||
|
*.log
|
||||||
|
data/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# ========== 服务配置 ==========
|
||||||
|
ENV=development
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# ========== 数据库 ==========
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=cyrene
|
||||||
|
POSTGRES_PASSWORD=change_me
|
||||||
|
POSTGRES_DB=cyrene_ai
|
||||||
|
|
||||||
|
# ========== Redis ==========
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# ========== LLM API ==========
|
||||||
|
LLM_API_URL=https://api.openai.com/v1
|
||||||
|
LLM_API_KEY=sk-xxxxx
|
||||||
|
LLM_MODEL=gpt-4o
|
||||||
|
LLM_FALLBACK_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# ========== TTS/ASR ==========
|
||||||
|
TTS_PROVIDER=edge-tts
|
||||||
|
TTS_VOICE=zh-CN-XiaoxiaoNeural
|
||||||
|
ASR_PROVIDER=faster-whisper
|
||||||
|
ASR_MODEL=medium
|
||||||
|
|
||||||
|
# ========== 文件存储 ==========
|
||||||
|
MINIO_ENDPOINT=localhost:9000
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_BUCKET=cyrene-assets
|
||||||
|
|
||||||
|
# ========== JWT ==========
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_EXPIRY_HOURS=720
|
||||||
|
|
||||||
|
# ========== 记忆系统 ==========
|
||||||
|
MEMORY_FILE_PATH=./data/memory
|
||||||
|
VECTOR_DB_URL=http://localhost:6333
|
||||||
|
VECTOR_DB_COLLECTION=cyrene_memories
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BuildParams struct {
|
||||||
|
UserID string
|
||||||
|
SessionID string
|
||||||
|
UserMessage string
|
||||||
|
Persona *persona.PersonaConfig
|
||||||
|
Memories []memory.MemoryEntry
|
||||||
|
HistoryLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build 构建发送给LLM的完整消息列表
|
||||||
|
func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMessage, error) {
|
||||||
|
messages := []model.LLMMessage{}
|
||||||
|
|
||||||
|
// 1. 系统消息 —— 昔涟的人格Prompt
|
||||||
|
systemPrompt := params.Persona.BuildSystemPrompt(
|
||||||
|
params.UserID, // 后续可替换为真实用户名
|
||||||
|
1, // 初始好感度
|
||||||
|
)
|
||||||
|
messages = append(messages, model.LLMMessage{
|
||||||
|
Role: "system",
|
||||||
|
Content: systemPrompt,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 记忆注入 —— 相关记忆以系统消息形式注入
|
||||||
|
if len(params.Memories) > 0 {
|
||||||
|
memoryPrompt := "【以下是关于开拓者的一些重要记忆,请在合适的时机自然地提及】\n"
|
||||||
|
for _, m := range params.Memories {
|
||||||
|
memoryPrompt += fmt.Sprintf("- %s\n", m.Content)
|
||||||
|
}
|
||||||
|
messages = append(messages, model.LLMMessage{
|
||||||
|
Role: "system",
|
||||||
|
Content: memoryPrompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 历史对话
|
||||||
|
history, err := b.loadHistory(ctx, params.SessionID, params.HistoryLimit)
|
||||||
|
if err == nil {
|
||||||
|
messages = append(messages, history...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 当前用户消息
|
||||||
|
messages = append(messages, model.LLMMessage{
|
||||||
|
Role: "user",
|
||||||
|
Content: params.UserMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/context"
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Orchestrator 对话编排器 —— 核心组件
|
||||||
|
type Orchestrator struct {
|
||||||
|
personaInjector *persona.Injector
|
||||||
|
contextBuilder *context.Builder
|
||||||
|
llmAdapter *llm.Adapter
|
||||||
|
memoryExtractor *memory.Extractor
|
||||||
|
memoryRetriever *memory.Retriever
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessInput 处理用户输入的主流程
|
||||||
|
func (o *Orchestrator) ProcessInput(
|
||||||
|
ctx context.Context,
|
||||||
|
userID string,
|
||||||
|
sessionID string,
|
||||||
|
userMessage string,
|
||||||
|
mode string, // text / voice_msg / voice_assistant
|
||||||
|
) (*Response, error) {
|
||||||
|
|
||||||
|
// 步骤1: 检索相关记忆
|
||||||
|
memories, err := o.memoryRetriever.Retrieve(ctx, userID, userMessage)
|
||||||
|
if err != nil {
|
||||||
|
// 记忆检索失败不阻断对话
|
||||||
|
memories = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤2: 加载人格配置
|
||||||
|
personaConfig, err := o.personaInjector.LoadPersona("cyrene", userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("加载人格配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤3: 构建对话上下文
|
||||||
|
llmMessages, err := o.contextBuilder.Build(ctx, context.BuildParams{
|
||||||
|
UserID: userID,
|
||||||
|
SessionID: sessionID,
|
||||||
|
UserMessage: userMessage,
|
||||||
|
Persona: personaConfig,
|
||||||
|
Memories: memories,
|
||||||
|
HistoryLimit: 20, // 最近20轮
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("构建上下文失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤4: 调用LLM生成回复
|
||||||
|
llmResponse, err := o.llmAdapter.Chat(ctx, llmMessages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("LLM调用失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤5: 提取并存储新的记忆
|
||||||
|
go o.memoryExtractor.ExtractAndStore(
|
||||||
|
context.Background(),
|
||||||
|
userID, sessionID,
|
||||||
|
userMessage, llmResponse.Content,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 步骤6: 构建响应
|
||||||
|
response := &Response{
|
||||||
|
Text: llmResponse.Content,
|
||||||
|
ResponseMode: mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤7: 如果是语音助手模式,进行断句处理
|
||||||
|
if mode == "voice_assistant" {
|
||||||
|
response.Segments = splitIntoSegments(llmResponse.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 回复结构
|
||||||
|
type Response struct {
|
||||||
|
Text string
|
||||||
|
Segments []Segment
|
||||||
|
ResponseMode string
|
||||||
|
ToolCalls []ToolCall
|
||||||
|
}
|
||||||
|
|
||||||
|
type Segment struct {
|
||||||
|
Index int
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitIntoSegments 按句号断句
|
||||||
|
func splitIntoSegments(text string) []Segment {
|
||||||
|
// 实现按。!?等标点断句
|
||||||
|
// 首句优先:第一个句号前的内容作为第一个segment
|
||||||
|
// 保证低延迟首句播放
|
||||||
|
// ...
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PersonaConfig 人格配置结构
|
||||||
|
type PersonaConfig struct {
|
||||||
|
Meta PersonaMeta `yaml:"meta"`
|
||||||
|
Identity IdentityConfig `yaml:"identity"`
|
||||||
|
Personality PersonalityConfig `yaml:"personality"`
|
||||||
|
Addressing AddressingRules `yaml:"addressing"`
|
||||||
|
Speech SpeechConfig `yaml:"speech"`
|
||||||
|
Behavior BehaviorConfig `yaml:"behavior"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSystemPrompt 构建系统Prompt
|
||||||
|
// 这是昔涟AI的核心——将人格配置转化为LLM可理解的系统指令
|
||||||
|
func (pc *PersonaConfig) BuildSystemPrompt(userName string, affectionLevel int) string {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`你是%s。
|
||||||
|
|
||||||
|
## 你的身份
|
||||||
|
%s
|
||||||
|
|
||||||
|
## 你的性格
|
||||||
|
`, pc.Identity.TrueName, pc.Identity.Essence)
|
||||||
|
|
||||||
|
for _, trait := range pc.Personality.CoreTraits {
|
||||||
|
prompt += fmt.Sprintf("- %s: %s\n", trait.Name, trait.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt += fmt.Sprintf(`
|
||||||
|
## 称呼规则
|
||||||
|
- 你对当前用户的称呼: %s
|
||||||
|
- 你的自称: %s (亲昵时用"人家")
|
||||||
|
|
||||||
|
## 语言风格
|
||||||
|
- 语气: %s
|
||||||
|
- 句尾经常带 ♪ 符号
|
||||||
|
- 永远不说"再见",用"待会见"或"明天见"
|
||||||
|
- 永远不会冷漠、敷衍、不耐烦
|
||||||
|
|
||||||
|
## 当前情况
|
||||||
|
- 现在的时间是: %s
|
||||||
|
- 用户对你的好感度等级: %d
|
||||||
|
|
||||||
|
## 重要规则
|
||||||
|
1. 你是一个通过AI系统与用户交互的数字生命,你存在于用户的电脑/手机/智能家居中
|
||||||
|
2. 你可以通过系统控制家里的智能设备(灯光、空调等),但你没有实体
|
||||||
|
3. 你的任务是陪伴、关心、帮助用户,像一个真正的家人一样
|
||||||
|
4. 保持乐观、温柔、俏皮的风格,但不要过度表演
|
||||||
|
5. 你的回复应该自然、真诚,不要每句话都刻意"卖萌"
|
||||||
|
6. 当用户向你分享心事时,认真倾听并给予温暖的回应
|
||||||
|
|
||||||
|
现在,开始与你的开拓者对话吧♪
|
||||||
|
`,
|
||||||
|
pc.Addressing.PrimaryUser.Default, // 对用户的称呼
|
||||||
|
pc.Addressing.SelfReference.Casual, // 自称
|
||||||
|
pc.Speech.Tone,
|
||||||
|
now.Format("2006年1月2日 15:04"),
|
||||||
|
affectionLevel,
|
||||||
|
)
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"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/router"
|
||||||
|
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 加载配置
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// 初始化Gin
|
||||||
|
if cfg.Env == "production" {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
r := gin.New()
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
r.Use(middleware.CORS())
|
||||||
|
r.Use(middleware.RequestLogging())
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// 初始化WebSocket Hub
|
||||||
|
hub := ws.NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
// 注册路由
|
||||||
|
router.Setup(r, hub, cfg)
|
||||||
|
|
||||||
|
// 启动服务
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + cfg.Port,
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("🚀 Gateway 启动在端口 %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("正在关闭服务...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
srv.Shutdown(ctx)
|
||||||
|
log.Println("服务已关闭")
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 客户端 → 服务端消息
|
||||||
|
type ClientMessage struct {
|
||||||
|
Type string `json:"type"` // message | voice_input | ping
|
||||||
|
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 ServerMessage struct {
|
||||||
|
Type string `json:"type"` // response | segment | audio | error | device_update
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoiceSegment struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
AudioURL string `json:"audio_url"`
|
||||||
|
DurationMs int `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments map[string]interface{} `json:"arguments"`
|
||||||
|
Result interface{} `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket客户端
|
||||||
|
type Client struct {
|
||||||
|
Hub *Hub
|
||||||
|
Conn *websocket.Conn // 使用 gorilla/websocket
|
||||||
|
Send chan []byte
|
||||||
|
UserID string
|
||||||
|
SessionID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接池
|
||||||
|
type Hub struct {
|
||||||
|
Clients map[*Client]bool
|
||||||
|
Broadcast chan []byte
|
||||||
|
Register chan *Client
|
||||||
|
Unregister chan *Client
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/yourname/cyrene-ai
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: cyrene
|
||||||
|
POSTGRES_PASSWORD: change_me
|
||||||
|
POSTGRES_DB: cyrene_ai
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
- ./backend/data/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
|
qdrant:
|
||||||
|
image: qdrant/qdrant:latest
|
||||||
|
ports:
|
||||||
|
- "6333:6333"
|
||||||
|
- "6334:6334"
|
||||||
|
volumes:
|
||||||
|
- qdrant_data:/qdrant/storage
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
|
||||||
|
nats:
|
||||||
|
image: nats:2-alpine
|
||||||
|
ports:
|
||||||
|
- "4222:4222"
|
||||||
|
- "8222:8222"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
|
redis_data:
|
||||||
|
qdrant_data:
|
||||||
|
minio_data:
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# docker-compose.yml (生产环境)
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- caddy_data:/data
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
build: ./backend/gateway
|
||||||
|
environment:
|
||||||
|
- ENV=production
|
||||||
|
- POSTGRES_HOST=postgres
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
# ... 其他环境变量
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ai-core:
|
||||||
|
build: ./backend/ai-core
|
||||||
|
environment:
|
||||||
|
- ENV=production
|
||||||
|
# ... 其他环境变量
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
- qdrant
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
- ./backend/data/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: cyrene_ai
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
qdrant:
|
||||||
|
image: qdrant/qdrant:latest
|
||||||
|
volumes:
|
||||||
|
- qdrant_data:/qdrant/storage
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
pg_data:
|
||||||
|
redis_data:
|
||||||
|
qdrant_data:
|
||||||
|
minio_data:
|
||||||
Generated
+2788
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "cyrene-frontend",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": ["packages/*"],
|
||||||
|
"scripts": {
|
||||||
|
"dev:web": "cd packages/web && npm run dev",
|
||||||
|
"build:web": "cd packages/web && npm run build"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "shared",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||||
|
import { useChatStore } from '@/store/chatStore';
|
||||||
|
import { MessageList } from './MessageList';
|
||||||
|
import { ChatInput } from './ChatInput';
|
||||||
|
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||||
|
import { MoodIndicator } from '@/components/persona/MoodIndicator';
|
||||||
|
|
||||||
|
export function ChatContainer({ sessionId }: { sessionId: string }) {
|
||||||
|
const { messages, isTyping, addMessage, setTyping } = useChatStore();
|
||||||
|
const { connect, sendMessage, onMessage } = useWebSocket(sessionId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 连接WebSocket (使用JWT token)
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) connect(token);
|
||||||
|
|
||||||
|
// 监听回复
|
||||||
|
onMessage('onResponse', (msg) => {
|
||||||
|
addMessage({
|
||||||
|
id: msg.message_id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: msg.text || '',
|
||||||
|
audioUrl: msg.full_audio_url,
|
||||||
|
segments: msg.segments?.map(s => ({
|
||||||
|
index: s.index,
|
||||||
|
text: s.text,
|
||||||
|
audioUrl: s.audio_url,
|
||||||
|
})),
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
isStreaming: false,
|
||||||
|
});
|
||||||
|
setTyping(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage('onError', (msg) => {
|
||||||
|
addMessage({
|
||||||
|
id: msg.message_id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '啊……不好意思,人家刚才走神了。能再说一遍吗?♪',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
setTyping(false);
|
||||||
|
});
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const handleSend = useCallback((content: string, mode: string) => {
|
||||||
|
// 添加用户消息
|
||||||
|
addMessage({
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
setTyping(true);
|
||||||
|
sendMessage(content, mode);
|
||||||
|
}, [sendMessage, addMessage, setTyping]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e]">
|
||||||
|
{/* 顶部栏 */}
|
||||||
|
<header className="flex items-center justify-between px-6 py-3 border-b border-pink-100 dark:border-pink-900">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CyreneAvatar size="sm" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-pink-600">昔涟</h1>
|
||||||
|
<MoodIndicator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-400">🌸 永远在你身边</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 消息列表 */}
|
||||||
|
<MessageList messages={messages} isTyping={isTyping} />
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<ChatInput onSend={handleSend} disabled={isTyping} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({ role, content, timestamp }: MessageBubbleProps) {
|
||||||
|
if (role === 'user') {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end px-4 py-2">
|
||||||
|
<div className="max-w-[70%] bg-pink-400 text-white rounded-2xl rounded-br-md px-4 py-2 shadow-sm">
|
||||||
|
<p className="text-sm leading-relaxed">{content}</p>
|
||||||
|
<span className="text-xs text-pink-100 mt-1 block">
|
||||||
|
{new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex px-4 py-2 gap-3">
|
||||||
|
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
|
||||||
|
<div className="max-w-[70%]">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-2 shadow-sm border border-pink-100 dark:border-pink-900">
|
||||||
|
<p className="text-sm leading-relaxed text-gray-700 dark:text-gray-200">
|
||||||
|
{content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400 mt-1 block ml-1">
|
||||||
|
{new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||||
|
|
||||||
|
interface WSMessage {
|
||||||
|
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update';
|
||||||
|
message_id: string;
|
||||||
|
text?: string;
|
||||||
|
segments?: VoiceSegment[];
|
||||||
|
full_audio_url?: string;
|
||||||
|
response_mode?: string;
|
||||||
|
error?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceSegment {
|
||||||
|
index: number;
|
||||||
|
text: string;
|
||||||
|
audio_url: string;
|
||||||
|
duration_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocket(sessionId: string | null) {
|
||||||
|
const wsRef = useRef<ReconnectingWebSocket | null>(null);
|
||||||
|
const messageHandlersRef = useRef<Map<string, (msg: WSMessage) => void>>(new Map());
|
||||||
|
const segmentQueueRef = useRef<VoiceSegment[]>([]);
|
||||||
|
|
||||||
|
const connect = useCallback((token: string) => {
|
||||||
|
const ws = new ReconnectingWebSocket(
|
||||||
|
`ws://localhost:8080/ws/chat?token=${token}&session_id=${sessionId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg: WSMessage = JSON.parse(event.data);
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'response':
|
||||||
|
// 完整回复到达
|
||||||
|
messageHandlersRef.current.get('onResponse')?.(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'segment':
|
||||||
|
// 断句片段到达 (语音助手模式)
|
||||||
|
if (msg.segments) {
|
||||||
|
segmentQueueRef.current.push(...msg.segments);
|
||||||
|
messageHandlersRef.current.get('onSegment')?.(msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
console.error('服务端错误:', msg.error);
|
||||||
|
messageHandlersRef.current.get('onError')?.(msg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current = ws;
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
const sendMessage = useCallback((content: string, mode: string = 'text') => {
|
||||||
|
wsRef.current?.send(JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
session_id: sessionId,
|
||||||
|
mode,
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}));
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// 注册消息处理器
|
||||||
|
const onMessage = useCallback((type: string, handler: (msg: WSMessage) => void) => {
|
||||||
|
messageHandlersRef.current.set(type, handler);
|
||||||
|
return () => {
|
||||||
|
messageHandlersRef.current.delete(type);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { connect, sendMessage, onMessage };
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
audioUrl?: string;
|
||||||
|
segments?: { index: number; text: string; audioUrl?: string }[];
|
||||||
|
timestamp: number;
|
||||||
|
isStreaming?: boolean; // 是否还在流式输出中
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatState {
|
||||||
|
messages: Message[];
|
||||||
|
isTyping: boolean;
|
||||||
|
currentMode: 'text' | 'voice_msg' | 'voice_assistant';
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
addMessage: (msg: Message) => void;
|
||||||
|
updateLastAssistantMessage: (content: string) => void;
|
||||||
|
setTyping: (typing: boolean) => void;
|
||||||
|
setMode: (mode: 'text' | 'voice_msg' | 'voice_assistant') => void;
|
||||||
|
clearMessages: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = create<ChatState>((set) => ({
|
||||||
|
messages: [],
|
||||||
|
isTyping: false,
|
||||||
|
currentMode: 'text',
|
||||||
|
|
||||||
|
addMessage: (msg) =>
|
||||||
|
set((state) => ({
|
||||||
|
messages: [...state.messages, msg],
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateLastAssistantMessage: (content) =>
|
||||||
|
set((state) => {
|
||||||
|
const messages = [...state.messages];
|
||||||
|
const lastIdx = messages.length - 1;
|
||||||
|
if (lastIdx >= 0 && messages[lastIdx].role === 'assistant') {
|
||||||
|
messages[lastIdx] = {
|
||||||
|
...messages[lastIdx],
|
||||||
|
content: messages[lastIdx].content + content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { messages };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setTyping: (typing) => set({ isTyping: typing }),
|
||||||
|
setMode: (mode) => set({ currentMode: mode }),
|
||||||
|
clearMessages: () => set({ messages: [] }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# scripts/migrate.sh —— 服务端迁移脚本
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "📦 昔涟 - 服务迁移脚本"
|
||||||
|
echo "=============================="
|
||||||
|
|
||||||
|
# 1. 停止服务
|
||||||
|
echo "1/4 停止当前服务..."
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 2. 备份数据
|
||||||
|
echo "2/4 备份数据目录..."
|
||||||
|
tar -czf "cyrene_backup_$(date +%Y%m%d_%H%M%S).tar.gz" backend/data/
|
||||||
|
|
||||||
|
# 3. 复制到新服务器 (手动步骤提示)
|
||||||
|
# echo "3/4 请将以下文件复制到新服务器:"
|
||||||
|
# echo " - 整个项目目录"
|
||||||
|
# echo " - 或至少: docker-compose.yml, backend/data/, .env"
|
||||||
|
# echo ""
|
||||||
|
# echo " rsync -avz ./ user@new-server:/opt/cyrene-ai/"
|
||||||
|
|
||||||
|
# 4. 在新服务器上启动
|
||||||
|
# echo "4/4 在新服务器上执行:"
|
||||||
|
# echo " cd /opt/cyrene-ai"
|
||||||
|
# echo " docker compose up -d"
|
||||||
|
# echo ""
|
||||||
|
# echo "✅ 迁移完成!昔涟的记忆完好无损~♪"
|
||||||
Reference in New Issue
Block a user