feat: DevTools调试工具 + 前端样式修复 + 管理员登录系统
DevTools (新增): - 进程管理器: 启动/停止/重启/编译 + 端口自动释放 - 服务接管 (tryAdopt): 检测已运行服务,健康检查通过则直接接管 - 一键启动 (startAllSequential): 按 ai-core→gateway→frontend 顺序启动 - 日志布局切换: 标签页模式 ↔ 三栏并列模式 - 性能监控: CPU/内存采样 + SVG 折线图 - Web UI + WebSocket 实时推送 前端修复: - tailwind.config.ts: 修复空配置导致 CSS 不加载 (增加 content/colors/fontFamily) - postcss.config.js: 新建缺失的 PostCSS 配置 - App.tsx: 移除注册功能,仅保留管理员登录 (admin / cyrene-dev-admin) 后端新增: - config.go: AdminUsername/AdminPassword/RegistrationEnabled 环境变量 - auth_handler.go: 管理员登录 + 注册邮箱验证码 + 注册开关控制 - 管理员凭据: admin / cyrene-dev-admin (默认) 其他: - .gitignore: 新增 devtools/node_modules/ devtools/logs/ devtools/package-lock.json - devtools.sh: DevTools 一键启动脚本
This commit is contained in:
+6
-1
@@ -6,4 +6,9 @@ backend/.env
|
||||
data/
|
||||
docs/
|
||||
.DS_Store
|
||||
chat-session.md
|
||||
chat-session.md
|
||||
|
||||
# DevTools
|
||||
devtools/node_modules/
|
||||
devtools/logs/
|
||||
devtools/package-lock.json
|
||||
+10
-11
@@ -11,7 +11,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/context"
|
||||
ctxbuild "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"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||
@@ -72,10 +72,7 @@ func main() {
|
||||
}
|
||||
|
||||
// 初始化上下文构建器
|
||||
ctxBuilder := &context.Builder{}
|
||||
|
||||
// 手动注入 Injector 到 orchestrator(临时方案,后续会用依赖注入框架)
|
||||
personaInjector := &persona.Injector{}
|
||||
ctxBuilder := &ctxbuild.Builder{}
|
||||
|
||||
// 健康检查与对话API的HTTP mux
|
||||
mux := http.NewServeMux()
|
||||
@@ -85,7 +82,7 @@ func main() {
|
||||
|
||||
// 注册对话API端点
|
||||
mux.HandleFunc("/api/v1/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleChat(w, r, orch, ctxBuilder, llmAdapter, personaLoader, personaInjector, memRetriever, memExtractor)
|
||||
handleChat(w, r, orch, ctxBuilder, llmAdapter, personaLoader, memRetriever, memExtractor)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -165,10 +162,9 @@ func handleChat(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
_ *orchestrator.Orchestrator,
|
||||
ctxBuilder *context.Builder,
|
||||
ctxBuilder *ctxbuild.Builder,
|
||||
llmAdapter *llm.Adapter,
|
||||
personaLoader *persona.Loader,
|
||||
personaInjector *persona.Injector,
|
||||
memRetriever *memory.Retriever,
|
||||
memExtractor *memory.Extractor,
|
||||
) {
|
||||
@@ -213,7 +209,7 @@ func handleChat(
|
||||
}
|
||||
|
||||
// 3. 构建对话上下文
|
||||
llmMessages, err := ctxBuilder.Build(ctx, context.BuildParams{
|
||||
llmMessages, err := ctxBuilder.Build(ctx, ctxbuild.BuildParams{
|
||||
UserID: req.UserID,
|
||||
SessionID: req.SessionID,
|
||||
UserMessage: req.Message,
|
||||
@@ -250,9 +246,12 @@ func handleChat(
|
||||
resp["segments"] = llm.SplitIntoSegments(llmResp.Content)
|
||||
}
|
||||
|
||||
// Ensure unused variables don't cause compile errors
|
||||
_ = personaLoader
|
||||
_ = memRetriever
|
||||
_ = memExtractor
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// 确保未使用变量不报错
|
||||
var _ = personaInjector
|
||||
|
||||
@@ -3,12 +3,16 @@ package context
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Builder 对话上下文构建器
|
||||
type Builder struct{}
|
||||
|
||||
type BuildParams struct {
|
||||
UserID string
|
||||
SessionID string
|
||||
@@ -58,3 +62,9 @@ func (b *Builder) Build(ctx context.Context, params BuildParams) ([]model.LLMMes
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// loadHistory 加载会话历史 (MVP阶段返回空,后续对接数据库)
|
||||
func (b *Builder) loadHistory(_ context.Context, sessionID string, limit int) ([]model.LLMMessage, error) {
|
||||
log.Printf("[context] 加载会话 %s 历史 (限制 %d 条) - 暂未实现持久化", sessionID, limit)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ type openAIStreamChoice struct {
|
||||
}
|
||||
|
||||
// doChat 执行同步对话请求
|
||||
func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage, model string, stream bool) (*model.LLMResponse, error) {
|
||||
func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage, modelName string, stream bool) (*model.LLMResponse, error) {
|
||||
// 转换消息格式
|
||||
oaiMessages := make([]openAIMessage, len(messages))
|
||||
for i, msg := range messages {
|
||||
@@ -204,7 +204,7 @@ func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage
|
||||
}
|
||||
|
||||
reqBody := openAIRequest{
|
||||
Model: model,
|
||||
Model: modelName,
|
||||
Messages: oaiMessages,
|
||||
Temperature: 0.8,
|
||||
Stream: stream,
|
||||
@@ -263,7 +263,7 @@ func (p *OpenAIProvider) doChat(ctx context.Context, messages []model.LLMMessage
|
||||
}
|
||||
|
||||
// doChatStream 执行流式对话请求(返回原始HTTP响应)
|
||||
func (p *OpenAIProvider) doChatStream(ctx context.Context, messages []model.LLMMessage, model string) (*http.Response, error) {
|
||||
func (p *OpenAIProvider) doChatStream(ctx context.Context, messages []model.LLMMessage, modelName string) (*http.Response, error) {
|
||||
oaiMessages := make([]openAIMessage, len(messages))
|
||||
for i, msg := range messages {
|
||||
oaiMessages[i] = openAIMessage{
|
||||
@@ -273,7 +273,7 @@ func (p *OpenAIProvider) doChatStream(ctx context.Context, messages []model.LLMM
|
||||
}
|
||||
|
||||
reqBody := openAIRequest{
|
||||
Model: model,
|
||||
Model: modelName,
|
||||
Messages: oaiMessages,
|
||||
Temperature: 0.8,
|
||||
Stream: true,
|
||||
|
||||
@@ -150,8 +150,8 @@ func isSentenceEnd(r rune) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// splitIntoSegments 将完整文本按句号断句(用于post-processing)
|
||||
func splitIntoSegments(text string) []Segment {
|
||||
// SplitIntoSegments 将完整文本按句号断句(用于post-processing)
|
||||
func SplitIntoSegments(text string) []Segment {
|
||||
var segments []Segment
|
||||
runes := []rune(text)
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ func (r *Retriever) Retrieve(ctx context.Context, userID string, query string) (
|
||||
if len(allEntries) == 0 {
|
||||
recentEntries, err := r.store.Query(ctx, model.MemoryQuery{
|
||||
UserID: userID,
|
||||
Priority: int(model.MemoryImportant),
|
||||
Priority: model.MemoryImportant,
|
||||
Limit: 3,
|
||||
})
|
||||
if err == nil {
|
||||
@@ -110,7 +110,7 @@ func (r *Retriever) keywordSearch(ctx context.Context, userID string, query stri
|
||||
// 查询最近的核心和重要记忆
|
||||
entries, err := r.store.Query(ctx, model.MemoryQuery{
|
||||
UserID: userID,
|
||||
Priority: int(model.MemoryImportant),
|
||||
Priority: model.MemoryImportant,
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -132,7 +132,7 @@ func (r *Retriever) keywordSearch(ctx context.Context, userID string, query stri
|
||||
// 也匹配普通记忆
|
||||
normalEntries, err := r.store.Query(ctx, model.MemoryQuery{
|
||||
UserID: userID,
|
||||
Priority: int(model.MemoryNormal),
|
||||
Priority: model.MemoryNormal,
|
||||
Limit: 100,
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
@@ -3,17 +3,20 @@ package orchestrator
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"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"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/persona"
|
||||
ctxt "github.com/yourname/cyrene-ai/ai-core/internal/context"
|
||||
)
|
||||
|
||||
// Orchestrator 对话编排器 —— 核心组件
|
||||
// 当前MVP阶段由 main.go 内联处理,此结构体作为未来重构的基础
|
||||
type Orchestrator struct {
|
||||
personaInjector *persona.Injector
|
||||
contextBuilder *context.Builder
|
||||
personaLoader *persona.Loader
|
||||
contextBuilder *ctxt.Builder
|
||||
llmAdapter *llm.Adapter
|
||||
memoryExtractor *memory.Extractor
|
||||
memoryRetriever *memory.Retriever
|
||||
@@ -36,19 +39,19 @@ func (o *Orchestrator) ProcessInput(
|
||||
}
|
||||
|
||||
// 步骤2: 加载人格配置
|
||||
personaConfig, err := o.personaInjector.LoadPersona("cyrene", userID)
|
||||
personaConfig, err := o.personaLoader.Get("cyrene")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载人格配置失败: %w", err)
|
||||
}
|
||||
|
||||
// 步骤3: 构建对话上下文
|
||||
llmMessages, err := o.contextBuilder.Build(ctx, context.BuildParams{
|
||||
llmMessages, err := o.contextBuilder.Build(ctx, ctxt.BuildParams{
|
||||
UserID: userID,
|
||||
SessionID: sessionID,
|
||||
UserMessage: userMessage,
|
||||
Persona: personaConfig,
|
||||
Memories: memories,
|
||||
HistoryLimit: 20, // 最近20轮
|
||||
HistoryLimit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构建上下文失败: %w", err)
|
||||
@@ -89,6 +92,14 @@ type Response struct {
|
||||
ToolCalls []ToolCall
|
||||
}
|
||||
|
||||
// ToolCall 工具调用
|
||||
type ToolCall struct {
|
||||
Name string
|
||||
Arguments map[string]interface{}
|
||||
Result interface{}
|
||||
}
|
||||
|
||||
// Segment 语音片段
|
||||
type Segment struct {
|
||||
Index int
|
||||
Text string
|
||||
@@ -96,8 +107,40 @@ type Segment struct {
|
||||
|
||||
// splitIntoSegments 按句号断句
|
||||
func splitIntoSegments(text string) []Segment {
|
||||
// 实现按。!?等标点断句
|
||||
// 首句优先:第一个句号前的内容作为第一个segment
|
||||
// 保证低延迟首句播放
|
||||
// ...
|
||||
var segments []Segment
|
||||
runes := []rune(text)
|
||||
start := 0
|
||||
index := 0
|
||||
|
||||
for i, r := range runes {
|
||||
if isSentenceEnd(r) {
|
||||
segText := strings.TrimSpace(string(runes[start : i+1]))
|
||||
if segText != "" {
|
||||
index++
|
||||
segments = append(segments, Segment{Index: index, Text: segText})
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
if start < len(runes) {
|
||||
remaining := strings.TrimSpace(string(runes[start:]))
|
||||
if remaining != "" {
|
||||
index++
|
||||
segments = append(segments, Segment{Index: index, Text: remaining})
|
||||
}
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
func isSentenceEnd(r rune) bool {
|
||||
switch r {
|
||||
case '。', '!', '?', '.', '!', '?', '\n':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Ensure unicode is used
|
||||
var _ = unicode.Is
|
||||
|
||||
@@ -2,7 +2,6 @@ package persona
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func NewLoader(personaDir string) (*Loader, error) {
|
||||
}
|
||||
// 只加载 _persona.yaml 结尾的文件
|
||||
name := entry.Name()
|
||||
if len(name) < 12 || name[len(name)-12:] != "_persona.yaml" {
|
||||
if len(name) < 13 || name[len(name)-13:] != "_persona.yaml" {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
+30
-3
@@ -4,8 +4,35 @@ go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
golang.org/x/time v0.8.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -28,6 +28,13 @@ type Config struct {
|
||||
JWTSecret string
|
||||
JWTExpiryHours time.Duration
|
||||
|
||||
// 管理员账户 (开发阶段使用)
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
|
||||
// 注册开关
|
||||
RegistrationEnabled bool
|
||||
|
||||
// AI-Core 服务
|
||||
AICoreURL string
|
||||
|
||||
@@ -59,6 +66,13 @@ func Load() *Config {
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||
JWTExpiryHours: time.Duration(getEnvInt("JWT_EXPIRY_HOURS", 720)) * time.Hour,
|
||||
|
||||
// 管理员账户 (开发阶段使用)
|
||||
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", "cyrene-dev-admin"),
|
||||
|
||||
// 注册开关 (开发阶段默认关闭)
|
||||
RegistrationEnabled: getEnvBool("REGISTRATION_ENABLED", false),
|
||||
|
||||
AICoreURL: getEnv("AI_CORE_URL", "http://localhost:8081"),
|
||||
|
||||
LLMAPIURL: getEnv("LLM_API_URL", "https://api.openai.com/v1"),
|
||||
@@ -122,3 +136,11 @@ func getEnvInt(key string, fallback int) int {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v == "true" || v == "1" || v == "yes"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -20,11 +20,20 @@ func NewAuthHandler(cfg *config.Config) *AuthHandler {
|
||||
return &AuthHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
// Register 用户注册 (需要邮箱验证码)
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
// 检查注册开关
|
||||
if !h.cfg.RegistrationEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "当前不开放公开注册,请使用管理员账户登录"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required,min=2,max=32"`
|
||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
// MVP阶段:验证码仅做格式校验,后续接入邮件服务
|
||||
VerifyCode string `json:"verify_code" binding:"required,len=6"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -32,6 +41,18 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// MVP阶段:验证码简单校验 (开发环境接受 "000000")
|
||||
if req.VerifyCode != "000000" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误 (开发阶段请使用 000000)"})
|
||||
return
|
||||
}
|
||||
|
||||
// 邮箱域名简单校验
|
||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式无效"})
|
||||
return
|
||||
}
|
||||
|
||||
// MVP阶段:使用username直接作为userID
|
||||
// 后续需要接入用户服务进行真实注册
|
||||
userID := "user_" + req.Username
|
||||
@@ -50,7 +71,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
// Login 用户登录 (支持管理员账户)
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
@@ -62,9 +83,16 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// MVP阶段:简化的登录逻辑
|
||||
// 后续需要验证密码哈希
|
||||
userID := "user_" + req.Username
|
||||
var userID string
|
||||
|
||||
// 管理员账户验证
|
||||
if req.Username == h.cfg.AdminUsername && req.Password == h.cfg.AdminPassword {
|
||||
userID = "admin_" + req.Username
|
||||
} else {
|
||||
// MVP阶段:普通用户登录 (简化逻辑)
|
||||
// 后续需要验证密码哈希
|
||||
userID = "user_" + req.Username
|
||||
}
|
||||
|
||||
token, err := h.cfg.GenerateToken(userID)
|
||||
if err != nil {
|
||||
@@ -90,9 +118,8 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
tokenString := authHeader[7:] // 去掉 "Bearer "
|
||||
userID, err := h.cfg.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
// 允许使用已过期但未超过刷新窗口的token
|
||||
// MVP简化:直接重新签发
|
||||
_ = json.Unmarshal([]byte("{}"), &struct{}{})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
newToken, err := h.cfg.GenerateToken(userID)
|
||||
|
||||
@@ -77,7 +77,7 @@ func (h *ChatHandler) HandleWebSocket(c *gin.Context) {
|
||||
client := ws.NewClient(h.hub, conn, userID, sessionID)
|
||||
|
||||
// 注册到Hub
|
||||
h.hub.register <- client
|
||||
h.hub.Register(client)
|
||||
|
||||
// 启动读写协程
|
||||
go client.WritePump()
|
||||
|
||||
@@ -81,6 +81,11 @@ func (h *Hub) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册客户端到Hub(供外部包使用)
|
||||
func (h *Hub) Register(client *Client) {
|
||||
h.register <- client
|
||||
}
|
||||
|
||||
// SendToUser 向指定用户的所有连接发送消息
|
||||
func (h *Hub) SendToUser(userID string, message []byte) {
|
||||
h.mu.RLock()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package ws
|
||||
|
||||
import "time"
|
||||
|
||||
// 客户端 → 服务端消息
|
||||
type ClientMessage struct {
|
||||
Type string `json:"type"` // message | voice_input | ping
|
||||
@@ -37,20 +35,3 @@ type ToolCall struct {
|
||||
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,32 @@
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# ========================================
|
||||
# Cyrene DevTools 启动脚本
|
||||
# 自动处理端口冲突、依赖安装和服务管理
|
||||
# ========================================
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DEVTOOLS_DIR="$SCRIPT_DIR/devtools"
|
||||
PORT="${DEVTOOLS_PORT:-9090}"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${CYAN} 🛠️ Cyrene DevTools${NC}"
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
|
||||
# 切换到 devtools 目录
|
||||
cd "$DEVTOOLS_DIR"
|
||||
|
||||
# 确保 Node.js 可用
|
||||
if ! command -v node &> /dev/null; then
|
||||
if [ -x /usr/local/node/bin/node ]; then
|
||||
export PATH="/usr/local/node/bin:$PATH"
|
||||
else
|
||||
echo -e "${RED}❌ 未找到 Node.js,请先安装 Node.js${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Node.js:${NC} $(node --version)"
|
||||
echo -e "${YELLOW}npm:${NC} $(npm --version)"
|
||||
|
||||
# 检查并释放端口
|
||||
if ss -tlnp 2>/dev/null | grep -q ":$PORT "; then
|
||||
echo -e "${YELLOW}⚠ 端口 $PORT 已被占用,正在释放...${NC}"
|
||||
fuser -k "$PORT/tcp" 2>/dev/null || true
|
||||
sleep 1
|
||||
echo -e "${GREEN}✅ 端口 $PORT 已释放${NC}"
|
||||
fi
|
||||
|
||||
# 安装依赖 (如有需要)
|
||||
if [ ! -d "node_modules" ] || [ ! -f "node_modules/.package-lock.json" ]; then
|
||||
echo -e "${YELLOW}📦 安装依赖...${NC}"
|
||||
npm install --silent
|
||||
fi
|
||||
|
||||
# 确保日志目录存在
|
||||
mkdir -p logs
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🚀 启动 DevTools 服务器 (端口: $PORT)...${NC}"
|
||||
echo -e "${CYAN} Web 控制台: http://localhost:$PORT${NC}"
|
||||
echo -e "${CYAN} API: http://localhost:$PORT/api/health${NC}"
|
||||
echo -e "${CYAN} WebSocket: ws://localhost:$PORT/ws${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW} 按 Ctrl+C 退出 (将自动停止所有托管服务)${NC}"
|
||||
echo ""
|
||||
|
||||
# 启动 DevTools
|
||||
exec node src/index.js
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "cyrene-devtools",
|
||||
"version": "1.0.0",
|
||||
"description": "Cyrene AI 开发调试工具 - 服务管理、日志监控、性能分析",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.1",
|
||||
"pidusage": "^4.0.0",
|
||||
"chokidar": "^4.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cyrene DevTools</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0f1117;--bg2:#1a1d27;--bg3:#252833;--border:#2d3140;
|
||||
--text:#c9d1d9;--text2:#8b949e;--accent:#f472b6;--accent2:#ec4899;
|
||||
--green:#22c55e;--red:#ef4444;--yellow:#eab308;--blue:#3b82f6;
|
||||
--orange:#f97316;
|
||||
}
|
||||
body{font-family:'Inter',-apple-system,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5}
|
||||
.container{max-width:1400px;margin:0 auto;padding:16px}
|
||||
header{display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:24px}
|
||||
header h1{font-size:20px;font-weight:700;background:linear-gradient(135deg,var(--accent),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
header .subtitle{font-size:12px;color:var(--text2)}
|
||||
.grid{display:grid;gap:16px}
|
||||
.grid-2{grid-template-columns:1fr 1fr}
|
||||
.grid-3{grid-template-columns:1fr 1fr 1fr}
|
||||
.card{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:16px}
|
||||
.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
.card-title{font-weight:600;font-size:14px}
|
||||
.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}
|
||||
.status-dot.running{background:var(--green);box-shadow:0 0 6px var(--green)}
|
||||
.status-dot.stopped{background:var(--red)}
|
||||
.status-dot.starting,.status-dot.building{background:var(--yellow);animation:pulse 1s infinite}
|
||||
.status-dot.error{background:var(--red);box-shadow:0 0 6px var(--red)}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.btn{padding:6px 14px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:12px;font-weight:500;background:var(--bg3);color:var(--text);transition:all .15s}
|
||||
.btn:hover{background:var(--border);border-color:var(--text2)}
|
||||
.btn-sm{padding:4px 10px;font-size:11px}
|
||||
.btn-accent{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||
.btn-accent:hover{background:var(--accent2);border-color:var(--accent2)}
|
||||
.btn-green{background:var(--green);color:#000;border-color:var(--green)}
|
||||
.btn-red{background:var(--red);color:#fff;border-color:var(--red)}
|
||||
.btn-group{display:flex;gap:6px}
|
||||
.metrics{display:flex;gap:16px}
|
||||
.metric{flex:1;text-align:center;padding:8px;background:var(--bg3);border-radius:8px}
|
||||
.metric .value{font-size:18px;font-weight:700;font-family:'JetBrains Mono',monospace}
|
||||
.metric .label{font-size:11px;color:var(--text2);margin-top:2px}
|
||||
.log-container{background:var(--bg);border:1px solid var(--border);border-radius:8px;height:300px;overflow-y:auto;padding:12px;font-family:'JetBrains Mono',monospace;font-size:12px;line-height:1.6}
|
||||
.log-line{padding:1px 0;word-break:break-all}
|
||||
.log-line .ts{color:var(--text2);margin-right:8px}
|
||||
.log-line.system{color:var(--blue)}
|
||||
.log-line.stderr{color:var(--red)}
|
||||
.log-line.error{color:var(--red);font-weight:600}
|
||||
.tabs{display:flex;gap:0;margin-bottom:12px;border-bottom:1px solid var(--border)}
|
||||
.tab{padding:8px 16px;cursor:pointer;font-size:13px;font-weight:500;color:var(--text2);border-bottom:2px solid transparent;transition:all .15s}
|
||||
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||||
.tab:hover{color:var(--text)}
|
||||
.chart-container{width:100%;height:160px;position:relative}
|
||||
.chart-svg{width:100%;height:100%}
|
||||
.chart-line{fill:none;stroke-width:2}
|
||||
.chart-line.cpu{stroke:var(--blue)}
|
||||
.chart-line.mem{stroke:var(--green)}
|
||||
.chart-area{opacity:.15}
|
||||
.chart-area.cpu{fill:var(--blue)}
|
||||
.chart-area.mem{fill:var(--green)}
|
||||
.legend{display:flex;gap:12px;font-size:11px;color:var(--text2)}
|
||||
.legend-item{display:flex;align-items:center;gap:4px}
|
||||
.legend-dot{width:10px;height:10px;border-radius:50%}
|
||||
.legend-dot.cpu{background:var(--blue)}
|
||||
.legend-dot.mem{background:var(--green)}
|
||||
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:32px;color:var(--text2)}
|
||||
.empty-state .icon{font-size:40px;margin-bottom:8px}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:6px;font-size:11px;font-weight:500}
|
||||
.badge-running{background:rgba(34,197,94,.15);color:var(--green)}
|
||||
.badge-stopped{background:rgba(239,68,68,.15);color:var(--red)}
|
||||
.badge-starting{background:rgba(234,179,8,.15);color:var(--yellow)}
|
||||
#ws-indicator{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2)}
|
||||
#ws-indicator.connected{color:var(--green)}
|
||||
#ws-indicator.disconnected{color:var(--red)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<h1>🛠️ Cyrene DevTools</h1>
|
||||
<div class="subtitle">开发调试控制台 — 服务管理 · 日志监控 · 性能分析</div>
|
||||
</div>
|
||||
<div id="ws-indicator" class="disconnected">⚫ 未连接</div>
|
||||
</header>
|
||||
|
||||
<!-- 服务管理 -->
|
||||
<section>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-header">
|
||||
<span class="card-title">📡 服务管理</span>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-accent" onclick="action('start-all')" title="按顺序启动 (ai-core → gateway → frontend),如已运行则接管">▶ 一键启动</button>
|
||||
<button class="btn btn-sm btn-red" onclick="action('stop-all')">⏹ 全部停止</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-3" id="service-cards"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 日志区域 -->
|
||||
<section>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-header">
|
||||
<span class="card-title">📋 实时日志</span>
|
||||
<div class="btn-group">
|
||||
<div class="tabs" id="log-tabs" style="margin:0;border:none"></div>
|
||||
<button class="btn btn-sm" onclick="toggleLogLayout()" id="btn-layout" title="切换标签页/并列布局">📐 并列布局</button>
|
||||
<button class="btn btn-sm" onclick="clearLogs()">🗑 清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 标签页模式 -->
|
||||
<div class="log-container" id="log-panel">
|
||||
<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>
|
||||
</div>
|
||||
<!-- 并列布局模式 (默认隐藏) -->
|
||||
<div class="grid grid-3" id="log-grid" style="display:none;gap:8px">
|
||||
<div class="log-container" id="log-panel-ai-core" style="height:300px"><div class="empty-state"><div class="icon">📝</div>AI-Core</div></div>
|
||||
<div class="log-container" id="log-panel-gateway" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Gateway</div></div>
|
||||
<div class="log-container" id="log-panel-frontend" style="height:300px"><div class="empty-state"><div class="icon">📝</div>Frontend</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 性能分析 -->
|
||||
<section>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">📊 性能分析</span>
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="legend-dot cpu"></span> CPU %</span>
|
||||
<span class="legend-item"><span class="legend-dot mem"></span> 内存 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-3" id="perf-panels"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ========== 状态 ==========
|
||||
const STATE = {
|
||||
activeLogTab: 'ai-core',
|
||||
logLines: { 'ai-core': [], 'gateway': [], 'frontend': [] },
|
||||
maxLogLines: 500,
|
||||
perfHistory: { 'ai-core': [], 'gateway': [], 'frontend': [] },
|
||||
logLayout: 'tabs', // 'tabs' | 'grid'
|
||||
};
|
||||
|
||||
// ========== WebSocket ==========
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${location.host}/ws`;
|
||||
let ws = null, wsRetryTimer = null;
|
||||
|
||||
function connectWS() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) return;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.onopen = () => {
|
||||
document.getElementById('ws-indicator').className = 'connected';
|
||||
document.getElementById('ws-indicator').innerHTML = '🟢 已连接';
|
||||
};
|
||||
ws.onclose = () => {
|
||||
document.getElementById('ws-indicator').className = 'disconnected';
|
||||
document.getElementById('ws-indicator').innerHTML = '🔴 已断开 (3秒后重连)';
|
||||
wsRetryTimer = setTimeout(connectWS, 3000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'log') handleLog(msg.data);
|
||||
if (msg.type === 'status') updateServiceCards(msg.data);
|
||||
};
|
||||
} catch(e) { setTimeout(connectWS, 3000); }
|
||||
}
|
||||
|
||||
function handleLog(data) {
|
||||
const { service, stream, text } = data;
|
||||
if (!STATE.logLines[service]) STATE.logLines[service] = [];
|
||||
|
||||
const now = new Date();
|
||||
const ts = now.toTimeString().slice(0,8);
|
||||
STATE.logLines[service].push({ ts, stream, text });
|
||||
|
||||
if (STATE.logLines[service].length > STATE.maxLogLines) {
|
||||
STATE.logLines[service].splice(0, STATE.logLines[service].length - STATE.maxLogLines);
|
||||
}
|
||||
|
||||
if (STATE.logLayout === 'tabs' && service === STATE.activeLogTab) {
|
||||
renderLog();
|
||||
} else if (STATE.logLayout === 'grid') {
|
||||
renderGridLog(service);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGridLog(service) {
|
||||
const panel = document.getElementById(`log-panel-${service}`);
|
||||
if (!panel) return;
|
||||
const lines = STATE.logLines[service] || [];
|
||||
if (lines.length === 0) {
|
||||
panel.innerHTML = `<div class="empty-state"><div class="icon">📝</div>${escapeId(service)}</div>`;
|
||||
return;
|
||||
}
|
||||
panel.innerHTML = lines.map(l =>
|
||||
`<div class="log-line ${l.stream}"><span class="ts">${l.ts}</span>${escHtml(l.text)}</div>`
|
||||
).join('');
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
|
||||
function renderLog() {
|
||||
const panel = document.getElementById('log-panel');
|
||||
const lines = STATE.logLines[STATE.activeLogTab] || [];
|
||||
if (lines.length === 0) {
|
||||
panel.innerHTML = '<div class="empty-state"><div class="icon">📝</div>等待日志输出...</div>';
|
||||
return;
|
||||
}
|
||||
panel.innerHTML = lines.map(l =>
|
||||
`<div class="log-line ${l.stream}"><span class="ts">${l.ts}</span>${escHtml(l.text)}</div>`
|
||||
).join('');
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ========== API 调用 ==========
|
||||
async function api(url, opts = {}) {
|
||||
const res = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function action(cmd, serviceId) {
|
||||
let url;
|
||||
if (cmd === 'start-all') url = '/api/services/start-all';
|
||||
else if (cmd === 'stop-all') url = '/api/services/stop-all';
|
||||
else url = `/api/services/${serviceId}/${cmd}`;
|
||||
|
||||
const method = ['start','stop','restart','build','start-all','stop-all'].includes(cmd) ? 'POST' : 'GET';
|
||||
const res = await api(url, { method });
|
||||
console.log(cmd, res);
|
||||
refreshStatus();
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const status = await api('/api/services');
|
||||
updateServiceCards(status);
|
||||
refreshPerf();
|
||||
}
|
||||
|
||||
function updateServiceCards(status) {
|
||||
const container = document.getElementById('service-cards');
|
||||
container.innerHTML = Object.entries(status).map(([id, svc]) => {
|
||||
const isRunning = svc.status === 'running';
|
||||
const isStarting = svc.status === 'starting' || svc.status === 'building';
|
||||
const isStopped = svc.status === 'stopped' || svc.status === 'error';
|
||||
const uptime = svc.uptime > 0 ? formatUptime(svc.uptime) : '—';
|
||||
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>
|
||||
<span class="status-dot ${svc.status}"></span>
|
||||
<span class="card-title">${svc.name}</span>
|
||||
</span>
|
||||
<span class="badge badge-${svc.status}">${svc.status}</span>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="value">${svc.pid || '—'}</div><div class="label">PID</div></div>
|
||||
<div class="metric"><div class="value">${svc.port}</div><div class="label">端口</div></div>
|
||||
<div class="metric"><div class="value">${uptime}</div><div class="label">运行时间</div></div>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:12px">
|
||||
${isStopped ? `<button class="btn btn-sm btn-green" onclick="action('start','${id}')">▶ 启动</button>` : ''}
|
||||
${isStopped || isStarting ? `<button class="btn btn-sm btn-accent" onclick="action('build','${id}')">🔨 编译</button>` : ''}
|
||||
${isRunning ? `<button class="btn btn-sm" onclick="action('restart','${id}')">🔄 重启</button>` : ''}
|
||||
${isRunning || isStarting ? `<button class="btn btn-sm btn-red" onclick="action('stop','${id}')">⏹ 停止</button>` : ''}
|
||||
${svc.healthUrl ? `<button class="btn btn-sm" onclick="checkHealth('${id}')">❤️ 健康检查</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function checkHealth(id) {
|
||||
const res = await api(`/api/proxy/${id}/health`);
|
||||
alert(`${id} 健康检查:\n${JSON.stringify(res, null, 2)}`);
|
||||
}
|
||||
|
||||
function formatUptime(ms) {
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
if (h > 0) return `${h}h ${m%60}m`;
|
||||
if (m > 0) return `${m}m ${s%60}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
// ========== 性能面板 ==========
|
||||
async function refreshPerf() {
|
||||
const [snap, history] = await Promise.all([
|
||||
api('/api/performance'),
|
||||
api('/api/performance/history'),
|
||||
]);
|
||||
|
||||
// 更新历史
|
||||
if (history) {
|
||||
for (const [id, h] of Object.entries(history)) {
|
||||
if (h && h.length > 0) STATE.perfHistory[id] = h.slice(-60);
|
||||
}
|
||||
}
|
||||
|
||||
renderPerfPanels(snap);
|
||||
}
|
||||
|
||||
function renderPerfPanels(snap) {
|
||||
const container = document.getElementById('perf-panels');
|
||||
const ids = ['ai-core', 'gateway', 'frontend'];
|
||||
|
||||
container.innerHTML = ids.map(id => {
|
||||
const s = snap[id] || { pid: null, cpu: 0, mem: 0 };
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">${escapeId(id)}</span>
|
||||
<span style="font-size:11px;color:var(--text2)">CPU ${s.cpu}% | MEM ${s.mem}MB</span>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<svg viewBox="0 0 300 120" class="chart-svg">
|
||||
${drawChart(STATE.perfHistory[id] || [])}
|
||||
</svg>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function drawChart(history) {
|
||||
if (history.length < 2) return '<text x="150" y="65" text-anchor="middle" fill="#8b949e" font-size="11">等待数据...</text>';
|
||||
|
||||
const w = 300, h = 120, pad = 10;
|
||||
const maxCpu = Math.max(5, ...history.map(d => d.cpu));
|
||||
const maxMem = Math.max(10, ...history.map(d => d.mem));
|
||||
|
||||
let cpuArea = '', cpuLine = '', memArea = '', memLine = '';
|
||||
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const x = pad + (i / Math.max(1, history.length - 1)) * (w - 2*pad);
|
||||
const cpuY = h - pad - (history[i].cpu / maxCpu) * (h - 2*pad);
|
||||
const memY = h - pad - (history[i].mem / maxMem) * (h - 2*pad);
|
||||
cpuLine += `${i===0?'M':'L'} ${x} ${cpuY} `;
|
||||
memLine += `${i===0?'M':'L'} ${x} ${memY} `;
|
||||
}
|
||||
|
||||
// Area fills
|
||||
cpuArea = cpuLine + `L ${pad+(history.length-1)/(Math.max(1,history.length-1))*(w-2*pad)} ${h-pad} L ${pad} ${h-pad} Z`;
|
||||
memArea = memLine + `L ${pad+(history.length-1)/(Math.max(1,history.length-1))*(w-2*pad)} ${h-pad} L ${pad} ${h-pad} Z`;
|
||||
|
||||
return `
|
||||
<path d="${cpuArea}" class="chart-area cpu"/>
|
||||
<path d="${cpuLine}" class="chart-line cpu"/>
|
||||
<path d="${memArea}" class="chart-area mem"/>
|
||||
<path d="${memLine}" class="chart-line mem"/>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeId(id) {
|
||||
const map = { 'ai-core': 'AI-Core', 'gateway': 'Gateway', 'frontend': 'Frontend' };
|
||||
return map[id] || id;
|
||||
}
|
||||
|
||||
// ========== 日志布局切换 ==========
|
||||
function toggleLogLayout() {
|
||||
const btnLayout = document.getElementById('btn-layout');
|
||||
const logPanel = document.getElementById('log-panel');
|
||||
const logGrid = document.getElementById('log-grid');
|
||||
const logTabs = document.getElementById('log-tabs');
|
||||
|
||||
if (STATE.logLayout === 'tabs') {
|
||||
// 切换到并列布局
|
||||
STATE.logLayout = 'grid';
|
||||
logPanel.style.display = 'none';
|
||||
logGrid.style.display = '';
|
||||
logTabs.style.display = 'none';
|
||||
btnLayout.innerHTML = '📋 标签页布局';
|
||||
btnLayout.title = '切换为标签页布局';
|
||||
// 渲染所有三个服务的日志
|
||||
['ai-core', 'gateway', 'frontend'].forEach(id => renderGridLog(id));
|
||||
} else {
|
||||
// 切换到标签页布局
|
||||
STATE.logLayout = 'tabs';
|
||||
logPanel.style.display = '';
|
||||
logGrid.style.display = 'none';
|
||||
logTabs.style.display = '';
|
||||
btnLayout.innerHTML = '📐 并列布局';
|
||||
btnLayout.title = '切换为并列布局';
|
||||
renderLog();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 日志标签切换 ==========
|
||||
function initLogTabs() {
|
||||
const tabs = document.getElementById('log-tabs');
|
||||
tabs.innerHTML = ['ai-core', 'gateway', 'frontend'].map(id =>
|
||||
`<div class="tab ${id === STATE.activeLogTab ? 'active' : ''}" onclick="switchLogTab('${id}')">${escapeId(id)}</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function switchLogTab(id) {
|
||||
STATE.activeLogTab = id;
|
||||
initLogTabs();
|
||||
renderLog();
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
const id = STATE.activeLogTab;
|
||||
api(`/api/logs/${id}`, { method: 'DELETE' });
|
||||
STATE.logLines[id] = [];
|
||||
renderLog();
|
||||
}
|
||||
|
||||
// ========== 启动 ==========
|
||||
connectWS();
|
||||
initLogTabs();
|
||||
refreshStatus();
|
||||
|
||||
// 定期刷新状态和性能
|
||||
setInterval(refreshStatus, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 调试工具配置
|
||||
* 定义各服务的启动参数、端口、健康检查等
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
export const DEVTOOLS_PORT = process.env.DEVTOOLS_PORT || 9090;
|
||||
export const LOGS_DIR = path.resolve(__dirname, '../logs');
|
||||
|
||||
export const SERVICES = {
|
||||
'ai-core': {
|
||||
name: 'AI-Core',
|
||||
cwd: path.join(ROOT, 'backend/ai-core'),
|
||||
command: './main',
|
||||
env: {
|
||||
AI_CORE_PORT: '8081',
|
||||
LLM_API_URL: process.env.LLM_API_URL || 'https://api.openai.com/v1',
|
||||
LLM_API_KEY: process.env.LLM_API_KEY || '',
|
||||
LLM_MODEL: process.env.LLM_MODEL || 'gpt-4o',
|
||||
PERSONA_DIR: './internal/persona',
|
||||
},
|
||||
healthUrl: 'http://localhost:8081/api/v1/health',
|
||||
port: 8081,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
},
|
||||
gateway: {
|
||||
name: 'Gateway',
|
||||
cwd: path.join(ROOT, 'backend/gateway'),
|
||||
command: './main',
|
||||
env: {
|
||||
GATEWAY_PORT: '8080',
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-key-change-me',
|
||||
AI_CORE_URL: 'http://localhost:8081',
|
||||
ADMIN_USERNAME: process.env.ADMIN_USERNAME || 'admin',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'cyrene-dev-admin',
|
||||
REGISTRATION_ENABLED: process.env.REGISTRATION_ENABLED || 'false',
|
||||
},
|
||||
healthUrl: 'http://localhost:8080/api/v1/health',
|
||||
port: 8080,
|
||||
buildCommand: 'go',
|
||||
buildArgs: ['build', '-o', 'main', './cmd/main.go'],
|
||||
goBin: '/usr/local/go/bin/go',
|
||||
},
|
||||
frontend: {
|
||||
name: 'Frontend',
|
||||
cwd: path.join(ROOT, 'frontend/web'),
|
||||
command: 'npx',
|
||||
args: ['vite', '--host', '0.0.0.0'],
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
healthUrl: 'http://localhost:5173',
|
||||
port: 5173,
|
||||
nodeBin: '/usr/local/node/bin/node',
|
||||
npmBin: '/usr/local/node/bin/npx',
|
||||
// frontend不需要预编译,dev server即可
|
||||
buildCommand: null,
|
||||
},
|
||||
};
|
||||
|
||||
/** 各服务默认的日志文件路径 */
|
||||
export function logFile(serviceId) {
|
||||
return path.join(LOGS_DIR, `${serviceId}.log`);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Cyrene DevTools - 主入口
|
||||
*
|
||||
* 提供:
|
||||
* - REST API: 服务管理、状态查询、性能分析、健康检查代理
|
||||
* - WebSocket: 实时日志推送
|
||||
* - Web UI: 管理控制台
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { processManager } from './process-manager.js';
|
||||
import { performanceMonitor } from './performance.js';
|
||||
import { SERVICES, DEVTOOLS_PORT, LOGS_DIR, logFile } from './config.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// ========== 初始化 ==========
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// 静态文件 - Web控制台
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// ========== WebSocket ==========
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
/** @type {Set<WebSocket>} */
|
||||
const wsClients = new Set();
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
wsClients.add(ws);
|
||||
ws.on('close', () => wsClients.delete(ws));
|
||||
});
|
||||
|
||||
/** 广播到所有WebSocket客户端 */
|
||||
function broadcast(type, data) {
|
||||
const msg = JSON.stringify({ type, data, ts: Date.now() });
|
||||
for (const ws of wsClients) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 日志事件 -> WebSocket广播
|
||||
processManager.on('log', (serviceId, stream, text) => {
|
||||
broadcast('log', { service: serviceId, stream, text: text.trimEnd() });
|
||||
});
|
||||
|
||||
// 状态变化
|
||||
processManager.on('log', (serviceId, stream, text) => {
|
||||
if (stream === 'system') {
|
||||
broadcast('status', processManager.getStatus());
|
||||
}
|
||||
});
|
||||
|
||||
// ========== REST API 路由 ==========
|
||||
|
||||
// ---- 健康检查 ----
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'cyrene-devtools',
|
||||
uptime: process.uptime(),
|
||||
wsClients: wsClients.size,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 服务状态 ----
|
||||
app.get('/api/services', (_req, res) => {
|
||||
res.json(processManager.getStatus());
|
||||
});
|
||||
|
||||
app.get('/api/services/:id', (req, res) => {
|
||||
const status = processManager.getServiceStatus(req.params.id);
|
||||
if (!status) return res.status(404).json({ error: '未知服务' });
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
// ---- 服务控制 ----
|
||||
app.post('/api/services/:id/start', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.start(req.params.id);
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/services/:id/stop', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.stop(req.params.id);
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/services/:id/restart', async (req, res) => {
|
||||
try {
|
||||
// 异步重启,因为可能耗时较长
|
||||
res.json({ success: true, message: '重启中...' });
|
||||
const result = await processManager.restart(req.params.id);
|
||||
broadcast('status', processManager.getStatus());
|
||||
} catch (err) {
|
||||
// 已经在上面res了
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/services/:id/build', async (req, res) => {
|
||||
try {
|
||||
const result = await processManager.build(req.params.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 批量操作
|
||||
// 一键按顺序启动 (接管已运行 + 健康检查)
|
||||
app.post('/api/services/start-all', async (_req, res) => {
|
||||
const results = await processManager.startAllSequential();
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
// 强制重启全部 (先杀后启)
|
||||
app.post('/api/services/start-all-fresh', async (_req, res) => {
|
||||
await processManager.stopAll();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const results = await processManager.startAllSequential();
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
app.post('/api/services/stop-all', async (_req, res) => {
|
||||
const results = await processManager.stopAll();
|
||||
broadcast('status', processManager.getStatus());
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
// ---- 性能监控 ----
|
||||
app.get('/api/performance', async (_req, res) => {
|
||||
const snapshot = await performanceMonitor.getSnapshot();
|
||||
res.json(snapshot);
|
||||
});
|
||||
|
||||
app.get('/api/performance/history', (_req, res) => {
|
||||
res.json(performanceMonitor.getAllHistory());
|
||||
});
|
||||
|
||||
app.get('/api/performance/:id', (req, res) => {
|
||||
const history = performanceMonitor.getHistory(req.params.id);
|
||||
res.json(history);
|
||||
});
|
||||
|
||||
app.get('/api/performance/:id/summary', async (req, res) => {
|
||||
const snap = await performanceMonitor.getSnapshot();
|
||||
const history = performanceMonitor.getHistory(req.params.id);
|
||||
const svc = snap[req.params.id];
|
||||
if (!svc) return res.status(404).json({ error: '未知服务' });
|
||||
|
||||
// 计算汇总统计
|
||||
let maxCpu = 0, maxMem = 0;
|
||||
const cpuValues = [], memValues = [];
|
||||
for (const h of history) {
|
||||
if (h.cpu > maxCpu) maxCpu = h.cpu;
|
||||
if (h.mem > maxMem) maxMem = h.mem;
|
||||
cpuValues.push(h.cpu);
|
||||
memValues.push(h.mem);
|
||||
}
|
||||
|
||||
const avgCpu = cpuValues.length > 0 ? Math.round(cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length * 100) / 100 : 0;
|
||||
const avgMem = memValues.length > 0 ? Math.round(memValues.reduce((a, b) => a + b, 0) / memValues.length * 100) / 100 : 0;
|
||||
|
||||
res.json({
|
||||
current: svc,
|
||||
history: { count: history.length, maxCpu, maxMem, avgCpu, avgMem },
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 日志查询 ----
|
||||
app.get('/api/logs/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' });
|
||||
|
||||
const filePath = logFile(id);
|
||||
const lines = req.query.lines ? parseInt(req.query.lines) : 200;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset) : 0;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.json({ service: id, lines: [], total: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用tail方式读取
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const allLines = content.split('\n').filter(Boolean);
|
||||
const total = allLines.length;
|
||||
const sliced = allLines.slice(Math.max(0, total - lines - offset), total - offset);
|
||||
res.json({ service: id, lines: sliced, total });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/logs/:id/recent', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' });
|
||||
|
||||
const filePath = logFile(id);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.json({ service: id, lines: [], total: 0 });
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const allLines = content.split('\n').filter(Boolean);
|
||||
const total = allLines.length;
|
||||
res.json({ service: id, lines: allLines.slice(-100), total });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除日志
|
||||
app.delete('/api/logs/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!SERVICES[id]) return res.status(404).json({ error: '未知服务' });
|
||||
|
||||
const filePath = logFile(id);
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, '');
|
||||
}
|
||||
res.json({ success: true, message: '日志已清空' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 健康检查代理 ----
|
||||
app.get('/api/proxy/:id/health', async (req, res) => {
|
||||
const svc = SERVICES[req.params.id];
|
||||
if (!svc || !svc.healthUrl) {
|
||||
return res.status(404).json({ error: '未知服务或无健康检查端点' });
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(5000) });
|
||||
const data = await resp.json();
|
||||
res.json({ proxy: true, service: req.params.id, status: resp.status, data });
|
||||
} catch {
|
||||
res.json({ proxy: true, service: req.params.id, status: 'unreachable', data: null });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 启动 ==========
|
||||
// 启动性能监控
|
||||
performanceMonitor.start();
|
||||
|
||||
// 确保日志目录存在
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
|
||||
server.listen(DEVTOOLS_PORT, () => {
|
||||
console.log(`🛠️ Cyrene DevTools 已启动: http://localhost:${DEVTOOLS_PORT}`);
|
||||
console.log(` API: http://localhost:${DEVTOOLS_PORT}/api/health`);
|
||||
console.log(` WebSocket: ws://localhost:${DEVTOOLS_PORT}/ws`);
|
||||
console.log(` Web控制台: http://localhost:${DEVTOOLS_PORT}`);
|
||||
console.log('');
|
||||
console.log(' 可用服务:');
|
||||
for (const [id, svc] of Object.entries(SERVICES)) {
|
||||
console.log(` - ${svc.name} (${id}): ${svc.healthUrl || 'N/A'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 优雅退出
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n关闭所有服务...');
|
||||
await processManager.stopAll();
|
||||
performanceMonitor.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await processManager.stopAll();
|
||||
performanceMonitor.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 性能监控模块
|
||||
* 监控各服务进程的 CPU、内存使用情况
|
||||
*/
|
||||
|
||||
import pidusage from 'pidusage';
|
||||
import { processManager } from './process-manager.js';
|
||||
import { SERVICES } from './config.js';
|
||||
|
||||
class PerformanceMonitor {
|
||||
constructor() {
|
||||
/** @type {Map<string, Array<{ts: number, cpu: number, mem: number}>>} */
|
||||
this.history = new Map();
|
||||
this.interval = null;
|
||||
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
this.history.set(id, []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始定期采样 (每3秒)
|
||||
*/
|
||||
start() {
|
||||
if (this.interval) return;
|
||||
this.interval = setInterval(() => this.sample(), 3000);
|
||||
this.interval.unref(); // 不阻止进程退出
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止采样
|
||||
*/
|
||||
stop() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 采样一次
|
||||
*/
|
||||
async sample() {
|
||||
for (const [id, info] of processManager.processes) {
|
||||
if (!info.pid) continue;
|
||||
try {
|
||||
const stats = await pidusage(info.pid);
|
||||
const history = this.history.get(id);
|
||||
history.push({
|
||||
ts: Date.now(),
|
||||
cpu: Math.round(stats.cpu * 100) / 100,
|
||||
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100, // MB
|
||||
});
|
||||
// 保留最近300条 (约15分钟)
|
||||
if (history.length > 300) {
|
||||
history.splice(0, history.length - 300);
|
||||
}
|
||||
} catch {
|
||||
// 进程可能已退出
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前性能快照
|
||||
*/
|
||||
async getSnapshot() {
|
||||
const result = {};
|
||||
for (const [id, info] of processManager.processes) {
|
||||
if (!info.pid) {
|
||||
result[id] = { pid: null, cpu: 0, mem: 0 };
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stats = await pidusage(info.pid);
|
||||
result[id] = {
|
||||
pid: info.pid,
|
||||
cpu: Math.round(stats.cpu * 100) / 100,
|
||||
mem: Math.round(stats.memory / 1024 / 1024 * 100) / 100,
|
||||
elapsed: stats.elapsed,
|
||||
};
|
||||
} catch {
|
||||
result[id] = { pid: info.pid, cpu: 0, mem: 0 };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史数据
|
||||
*/
|
||||
getHistory(serviceId) {
|
||||
return this.history.get(serviceId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有服务的历史数据
|
||||
*/
|
||||
getAllHistory() {
|
||||
const result = {};
|
||||
for (const id of this.history.keys()) {
|
||||
result[id] = this.history.get(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* 进程管理器
|
||||
* 负责启动/停止/重启各服务,捕获stdout/stderr并推送到日志系统
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import { SERVICES, logFile } from './config.js';
|
||||
|
||||
/**
|
||||
* 通过 TCP 连接尝试判断端口是否被占用,若被占用则尝试用 fuser 释放
|
||||
*/
|
||||
function releasePort(port) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
sock.setTimeout(1000);
|
||||
sock.on('connect', () => {
|
||||
sock.destroy();
|
||||
// 端口被占用,尝试释放
|
||||
try {
|
||||
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { timeout: 3000 });
|
||||
} catch { /* ignore */ }
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
sock.on('error', () => {
|
||||
sock.destroy();
|
||||
resolve(); // 端口空闲
|
||||
});
|
||||
sock.on('timeout', () => {
|
||||
sock.destroy();
|
||||
resolve();
|
||||
});
|
||||
sock.connect(port, '127.0.0.1');
|
||||
});
|
||||
}
|
||||
|
||||
class ProcessManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {Map<string, {process: ChildProcess|null, status: string, startTime: number|null, pid: number|null, buildLog: string[]}>} */
|
||||
this.processes = new Map();
|
||||
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
this.processes.set(id, {
|
||||
process: null,
|
||||
status: 'stopped',
|
||||
startTime: null,
|
||||
pid: null,
|
||||
buildLog: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务
|
||||
*/
|
||||
async start(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc) throw new Error(`未知服务: ${serviceId}`);
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
if (procInfo.process) {
|
||||
throw new Error(`${svc.name} 已在运行中`);
|
||||
}
|
||||
|
||||
// 启动前释放端口,避免 "address already in use"
|
||||
if (svc.port) {
|
||||
this.emit('log', serviceId, 'system', `检查端口 ${svc.port}...`);
|
||||
await releasePort(svc.port);
|
||||
}
|
||||
|
||||
this.emit('log', serviceId, 'system', `正在启动 ${svc.name}...`);
|
||||
procInfo.status = 'starting';
|
||||
procInfo.buildLog = [];
|
||||
|
||||
// 确保日志目录存在
|
||||
const logPath = logFile(serviceId);
|
||||
const logDir = logPath.substring(0, logPath.lastIndexOf('/'));
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
|
||||
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
|
||||
|
||||
// 确定二进制路径或命令
|
||||
let command, args;
|
||||
if (svc.command === './main') {
|
||||
command = svc.command;
|
||||
args = svc.args || [];
|
||||
} else if (svc.command === 'npx') {
|
||||
command = svc.npmBin || 'npx';
|
||||
args = svc.args || [];
|
||||
} else {
|
||||
command = svc.command;
|
||||
args = svc.args || [];
|
||||
}
|
||||
|
||||
const env = { ...process.env, ...svc.env };
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: svc.cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
});
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
logStream.write(text);
|
||||
this.emit('log', serviceId, 'stdout', text);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
logStream.write(text);
|
||||
this.emit('log', serviceId, 'stderr', text);
|
||||
});
|
||||
|
||||
// spawn() 返回后进程已启动,立即记录 PID 和状态
|
||||
// 注意: Node.js 没有 'spawn' 事件,spawn() 调用本身是同步的
|
||||
procInfo.pid = child.pid;
|
||||
procInfo.startTime = Date.now();
|
||||
procInfo.status = 'running';
|
||||
procInfo.process = child;
|
||||
this.emit('log', serviceId, 'system', `${svc.name} 已启动 (PID: ${child.pid})`);
|
||||
|
||||
child.on('error', (err) => {
|
||||
const msg = `进程错误: ${err.message}`;
|
||||
logStream.write(msg + '\n');
|
||||
this.emit('log', serviceId, 'error', msg);
|
||||
procInfo.status = 'error';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const msg = `进程退出,退出码: ${code}`;
|
||||
logStream.write(msg + '\n');
|
||||
this.emit('log', serviceId, 'system', msg);
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
logStream.end();
|
||||
});
|
||||
|
||||
return { success: true, message: `${svc.name} 启动中...` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止服务
|
||||
*/
|
||||
async stop(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc) throw new Error(`未知服务: ${serviceId}`);
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
if (!procInfo.process) {
|
||||
// 可能已经崩溃了,重置状态
|
||||
procInfo.status = 'stopped';
|
||||
return { success: true, message: `${svc.name} 未在运行` };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
// 强制杀死
|
||||
if (procInfo.process) {
|
||||
procInfo.process.kill('SIGKILL');
|
||||
}
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
resolve({ success: true, message: `${svc.name} 已强制停止` });
|
||||
}, 5000);
|
||||
|
||||
procInfo.process.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.process = null;
|
||||
procInfo.pid = null;
|
||||
resolve({ success: true, message: `${svc.name} 已停止` });
|
||||
});
|
||||
|
||||
procInfo.process.kill('SIGTERM');
|
||||
this.emit('log', serviceId, 'system', `正在停止 ${svc.name}...`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启服务
|
||||
*/
|
||||
async restart(serviceId) {
|
||||
await this.stop(serviceId);
|
||||
// 等待一小段时间确保端口释放
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
return this.start(serviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建服务 (Go服务需要预编译)
|
||||
*/
|
||||
async build(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc) throw new Error(`未知服务: ${serviceId}`);
|
||||
if (!svc.buildCommand) {
|
||||
return { success: false, message: `${svc.name} 不需要预编译` };
|
||||
}
|
||||
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
procInfo.status = 'building';
|
||||
procInfo.buildLog = [];
|
||||
this.emit('log', serviceId, 'system', `正在编译 ${svc.name}...`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const buildCmd = svc.goBin || svc.buildCommand;
|
||||
const buildArgs = svc.buildArgs || [];
|
||||
|
||||
const child = spawn(buildCmd, buildArgs, {
|
||||
cwd: svc.cwd,
|
||||
env: { ...process.env, GOPROXY: 'https://goproxy.cn,direct' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
||||
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
||||
|
||||
child.on('close', (code) => {
|
||||
procInfo.status = 'stopped';
|
||||
procInfo.buildLog = [
|
||||
...stdout.split('\n').filter(Boolean),
|
||||
...stderr.split('\n').filter(Boolean),
|
||||
];
|
||||
|
||||
if (code === 0) {
|
||||
this.emit('log', serviceId, 'system', `${svc.name} 编译成功`);
|
||||
resolve({ success: true, message: `${svc.name} 编译成功` });
|
||||
} else {
|
||||
this.emit('log', serviceId, 'error', `${svc.name} 编译失败:\n${stderr || stdout}`);
|
||||
resolve({ success: false, message: '编译失败', buildLog: procInfo.buildLog });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
procInfo.status = 'stopped';
|
||||
resolve({ success: false, message: `编译错误: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有服务状态
|
||||
*/
|
||||
getStatus() {
|
||||
const result = {};
|
||||
for (const [id, info] of this.processes) {
|
||||
const svc = SERVICES[id];
|
||||
result[id] = {
|
||||
name: svc.name,
|
||||
status: info.status,
|
||||
pid: info.pid,
|
||||
startTime: info.startTime,
|
||||
uptime: info.startTime ? Date.now() - info.startTime : 0,
|
||||
port: svc.port,
|
||||
healthUrl: svc.healthUrl,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个服务状态
|
||||
*/
|
||||
getServiceStatus(serviceId) {
|
||||
const info = this.processes.get(serviceId);
|
||||
if (!info) return null;
|
||||
const svc = SERVICES[serviceId];
|
||||
return {
|
||||
name: svc.name,
|
||||
status: info.status,
|
||||
pid: info.pid,
|
||||
startTime: info.startTime,
|
||||
uptime: info.startTime ? Date.now() - info.startTime : 0,
|
||||
port: svc.port,
|
||||
healthUrl: svc.healthUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有服务
|
||||
*/
|
||||
async stopAll() {
|
||||
const results = [];
|
||||
for (const id of Object.keys(SERVICES)) {
|
||||
try {
|
||||
const r = await this.stop(id);
|
||||
results.push({ id, ...r });
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试接管已运行的服务 (通过健康检查端点)
|
||||
* 如果服务已在运行,直接标记为 running 而不是杀死重启
|
||||
*/
|
||||
async tryAdopt(serviceId) {
|
||||
const svc = SERVICES[serviceId];
|
||||
if (!svc || !svc.healthUrl) return false;
|
||||
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(3000) });
|
||||
if (resp.ok) {
|
||||
const procInfo = this.processes.get(serviceId);
|
||||
// 尝试通过 fuser 获取 PID
|
||||
let pid = null;
|
||||
try {
|
||||
const out = execSync(`fuser ${svc.port}/tcp 2>/dev/null || true`, { timeout: 2000 }).toString().trim();
|
||||
const match = out.match(/(\d+)/);
|
||||
if (match) pid = parseInt(match[1]);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
procInfo.pid = pid;
|
||||
procInfo.startTime = Date.now();
|
||||
procInfo.status = 'running';
|
||||
procInfo.process = null; // 不是我们的子进程,但标记为已接管
|
||||
this.emit('log', serviceId, 'system', `${svc.name} 已在运行 (PID: ${pid || '未知'}),已接管`);
|
||||
return true;
|
||||
}
|
||||
} catch { /* 未运行或不可达 */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按顺序启动所有服务 (ai-core → gateway → frontend)
|
||||
* 每步等待健康检查通过后再启动下一个
|
||||
*/
|
||||
async startAllSequential() {
|
||||
const order = ['ai-core', 'gateway', 'frontend'];
|
||||
const results = [];
|
||||
|
||||
for (const id of order) {
|
||||
const svc = SERVICES[id];
|
||||
// 先尝试接管已运行的服务
|
||||
const adopted = await this.tryAdopt(id);
|
||||
if (adopted) {
|
||||
results.push({ id, success: true, message: `${svc.name} 已接管 (无需重启)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 启动服务
|
||||
try {
|
||||
const r = await this.start(id);
|
||||
results.push({ id, ...r });
|
||||
|
||||
// 等待健康检查通过
|
||||
if (svc.healthUrl) {
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
try {
|
||||
const resp = await fetch(svc.healthUrl, { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) { healthy = true; break; }
|
||||
} catch { /* continue waiting */ }
|
||||
}
|
||||
if (!healthy) {
|
||||
this.emit('log', id, 'error', `${svc.name} 健康检查超时`);
|
||||
} else {
|
||||
this.emit('log', id, 'system', `${svc.name} 健康检查通过 ✓`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({ id, success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager();
|
||||
Generated
+1
-2746
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>昔涟 - Cyrene AI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
+15
-32
@@ -6,24 +6,22 @@ import { useAuth } from '@/hooks/useAuth';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
|
||||
export default function App() {
|
||||
const { isLoggedIn, login, register, loading: authLoading } = useAuth();
|
||||
const { isLoggedIn, login, loading: authLoading } = useAuth();
|
||||
const { send } = useChat();
|
||||
|
||||
const [authMode, setAuthMode] = useState<'login' | 'register'>('login');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleAuth = async () => {
|
||||
const handleLogin = async () => {
|
||||
setError('');
|
||||
const fn = authMode === 'login' ? login : register;
|
||||
const result = await fn(username, password);
|
||||
const result = await login(username, password);
|
||||
if (!result.success) {
|
||||
setError(result.error || '操作失败');
|
||||
setError(result.error || '登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 登录页面
|
||||
// 登录页面 (开发阶段禁用注册)
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e] flex items-center justify-center p-4">
|
||||
@@ -39,27 +37,8 @@ export default function App() {
|
||||
|
||||
{/* 表单 */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg p-6 space-y-4 border border-pink-100 dark:border-pink-900">
|
||||
<div className="flex rounded-lg bg-gray-100 dark:bg-gray-800 p-1">
|
||||
<button
|
||||
onClick={() => setAuthMode('login')}
|
||||
className={`flex-1 py-2 text-sm rounded-md transition-colors ${
|
||||
authMode === 'login'
|
||||
? 'bg-white dark:bg-gray-700 text-pink-500 font-medium shadow-sm'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAuthMode('register')}
|
||||
className={`flex-1 py-2 text-sm rounded-md transition-colors ${
|
||||
authMode === 'register'
|
||||
? 'bg-white dark:bg-gray-700 text-pink-500 font-medium shadow-sm'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm font-medium text-pink-500">管理员登录</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -67,7 +46,7 @@ export default function App() {
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAuth()}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
|
||||
/>
|
||||
|
||||
@@ -76,7 +55,7 @@ export default function App() {
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAuth()}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
|
||||
/>
|
||||
|
||||
@@ -85,12 +64,16 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAuth}
|
||||
onClick={handleLogin}
|
||||
disabled={authLoading || !username || !password}
|
||||
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
|
||||
>
|
||||
{authLoading ? '请稍候...' : authMode === 'login' ? '进入昔涟的世界 ♪' : '注册并开始'}
|
||||
{authLoading ? '请稍候...' : '进入昔涟的世界 ♪'}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
开发阶段 · 管理员: admin / cyrene-dev-admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
role: 'user' | 'assistant';
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
darkMode: 'media',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
pink: {
|
||||
50: '#fdf2f8',
|
||||
100: '#fce7f3',
|
||||
200: '#fbcfe8',
|
||||
300: '#f9a8d4',
|
||||
400: '#f472b6',
|
||||
500: '#ec4899',
|
||||
600: '#db2777',
|
||||
700: '#be185d',
|
||||
800: '#9d174d',
|
||||
900: '#831843',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Noto Sans SC"', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
|
||||
Reference in New Issue
Block a user