diff --git a/backend/my_work/agents/agent.go b/backend/my_work/agents/agent.go new file mode 100644 index 0000000..89259f2 --- /dev/null +++ b/backend/my_work/agents/agent.go @@ -0,0 +1,89 @@ +package agents + +import ( + "context" + "fmt" + "strings" +) + +type ToolConfig struct { + Name string + Enabled bool + Description string +} + +type TraceFunc func(tool string, stage string, status string, message string, data map[string]interface{}) + +type Tool interface { + Name() string + Enabled(config ToolConfig) bool + ShouldUse(messages []ChatMessage) bool + Enrich(ctx context.Context, messages []ChatMessage, config ToolConfig, trace TraceFunc) ([]ChatMessage, error) +} + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +var registry []Tool + +func Register(tool Tool) { + registry = append(registry, tool) +} + +func EnabledTools(configs []ToolConfig) []Tool { + tools := make([]Tool, 0) + for _, tool := range registry { + config := FindToolConfig(configs, tool.Name()) + if tool.Enabled(config) { + tools = append(tools, tool) + } + } + return tools +} + +func EnrichMessages(ctx context.Context, messages []ChatMessage, configs []ToolConfig, trace TraceFunc) []ChatMessage { + enriched := append([]ChatMessage{}, messages...) + for _, tool := range EnabledTools(configs) { + if !tool.ShouldUse(enriched) { + continue + } + config := FindToolConfig(configs, tool.Name()) + var err error + enriched, err = tool.Enrich(ctx, enriched, config, trace) + if err != nil && trace != nil { + trace(tool.Name(), "execute", "error", err.Error(), nil) + } + } + return enriched +} + +func FindToolConfig(configs []ToolConfig, name string) ToolConfig { + for _, config := range configs { + if strings.EqualFold(config.Name, name) { + return config + } + } + return ToolConfig{Name: name, Enabled: false} +} + +func LastUserContent(messages []ChatMessage) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + return messages[i].Content + } + } + return "" +} + +func SystemMessage(content string) ChatMessage { + return ChatMessage{Role: "system", Content: content} +} + +func SafeString(value interface{}) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} diff --git a/backend/my_work/agents/time.go b/backend/my_work/agents/time.go new file mode 100644 index 0000000..a02e3a9 --- /dev/null +++ b/backend/my_work/agents/time.go @@ -0,0 +1,85 @@ +package agents + +import ( + "context" + "fmt" + "strings" + "time" +) + +type TimeTool struct{} + +func init() { + Register(TimeTool{}) +} + +func (TimeTool) Name() string { + return "time" +} + +func (TimeTool) Enabled(config ToolConfig) bool { + return config.Enabled +} + +func (TimeTool) ShouldUse(messages []ChatMessage) bool { + content := strings.ToLower(LastUserContent(messages)) + keywords := []string{ + "时间", "日期", "今天", "昨天", "明天", "本周", "这周", "上周", "下周", "本月", "这个月", "上月", "下月", "今年", "去年", "明年", + "time", "date", "today", "yesterday", "tomorrow", "week", "month", "year", "now", + } + for _, keyword := range keywords { + if strings.Contains(content, keyword) { + return true + } + } + return false +} + +func (TimeTool) Enrich(ctx context.Context, messages []ChatMessage, config ToolConfig, trace TraceFunc) ([]ChatMessage, error) { + select { + case <-ctx.Done(): + return messages, ctx.Err() + default: + } + + now := time.Now() + content := buildTimeContext(now) + if trace != nil { + trace("time", "execute", "success", "已获取当前时间上下文", map[string]interface{}{ + "now": now.Format("2006-01-02 15:04:05"), + "today": now.Format("2006-01-02"), + }) + } + + enriched := append([]ChatMessage{}, messages...) + enriched = append(enriched, SystemMessage(content)) + return enriched, nil +} + +func buildTimeContext(now time.Time) string { + todayStart := dateStart(now) + weekStart := todayStart.AddDate(0, 0, -int((int(todayStart.Weekday())+6)%7)) + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + yearStart := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()) + + return fmt.Sprintf(`以下是当前时间上下文,请在回答涉及相对日期/时间的问题时使用: +- 当前时间:%s +- 今天:%s,范围 %s 至 %s +- 昨天:%s +- 明天:%s +- 本周:%s 至 %s +- 本月:%s 至 %s +- 今年:%s 至 %s`, + now.Format("2006-01-02 15:04:05 MST"), + todayStart.Format("2006-01-02"), todayStart.Format("2006-01-02 15:04:05"), todayStart.AddDate(0, 0, 1).Add(-time.Second).Format("2006-01-02 15:04:05"), + todayStart.AddDate(0, 0, -1).Format("2006-01-02"), + todayStart.AddDate(0, 0, 1).Format("2006-01-02"), + weekStart.Format("2006-01-02"), weekStart.AddDate(0, 0, 7).Add(-time.Second).Format("2006-01-02"), + monthStart.Format("2006-01-02"), monthStart.AddDate(0, 1, 0).Add(-time.Second).Format("2006-01-02"), + yearStart.Format("2006-01-02"), yearStart.AddDate(1, 0, 0).Add(-time.Second).Format("2006-01-02"), + ) +} + +func dateStart(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} diff --git a/backend/my_work/main.go b/backend/my_work/main.go index 62d3124..00c01d4 100644 --- a/backend/my_work/main.go +++ b/backend/my_work/main.go @@ -82,6 +82,7 @@ func main() { routers.ApiWarehouseInit() routers.ApiCustomerInit() routers.ApiCalendarInit() + routers.ApiAIChatInit() routers.BindsInit() //最后初始化绑定数据表 diff --git a/backend/my_work/models/configs.go b/backend/my_work/models/configs.go index 5b26106..01f67d0 100644 --- a/backend/my_work/models/configs.go +++ b/backend/my_work/models/configs.go @@ -26,9 +26,41 @@ type ConfigsFile_ struct { AllowPdfMime map[string]string `mapstructure:"allowPdfMime"` } +type ConfigsAIChatOpenAI_ struct { + Name string `mapstructure:"name"` + Active bool `mapstructure:"active"` + ApiKey string `mapstructure:"apiKey"` + BaseUrl string `mapstructure:"baseUrl"` + Model string `mapstructure:"model"` + Timeout int `mapstructure:"timeout"` + MaxTokens int `mapstructure:"maxTokens"` + SystemPrompt string `mapstructure:"systemPrompt"` +} + +type ConfigsAIChatTool_ struct { + Name string `mapstructure:"name"` + Enabled bool `mapstructure:"enabled"` + Description string `mapstructure:"description"` +} + +type ConfigsAIChatToolRouter_ struct { + Enabled bool `mapstructure:"enabled"` + OpenAIName string `mapstructure:"openaiName"` + Timeout int `mapstructure:"timeout"` + MaxTokens int `mapstructure:"maxTokens"` + Tools []ConfigsAIChatTool_ `mapstructure:"tools"` +} + +type ConfigsAIChat_ struct { + Enabled bool `mapstructure:"enabled"` + OpenAI []ConfigsAIChatOpenAI_ `mapstructure:"openai"` + ToolRouter ConfigsAIChatToolRouter_ `mapstructure:"toolRouter"` +} + var ConfigsWed ConfigsWeb_ var ConfigsUser ConfigsUser_ var ConfigsFile ConfigsFile_ +var ConfigsAIChat ConfigsAIChat_ func ConfigAllInit() error { @@ -53,5 +85,35 @@ func ConfigAllInit() error { panic(err) } + //初始化aichat config + ConfigsAIChat = ConfigsAIChat_{ + Enabled: false, + OpenAI: []ConfigsAIChatOpenAI_{ + { + Name: "default", + Active: true, + BaseUrl: "https://ark.cn-beijing.volces.com/api/v3", + Timeout: 120, + MaxTokens: 4096, + SystemPrompt: "你是一个有帮助的 AI 助手。", + }, + }, + ToolRouter: ConfigsAIChatToolRouter_{ + Enabled: true, + OpenAIName: "default", + Timeout: 30, + MaxTokens: 512, + Tools: []ConfigsAIChatTool_{ + {Name: "time", Enabled: true, Description: "提供当前日期、时间和相对日期换算。"}, + }, + }, + } + if aiChatConfig, ok := Configs["aichat"].(map[string]interface{}); ok { + err = mapstructure.Decode(aiChatConfig, &ConfigsAIChat) + if err != nil { + panic(err) + } + } + return nil } diff --git a/backend/my_work/routers/api.go b/backend/my_work/routers/api.go index 922c63c..406dec8 100644 --- a/backend/my_work/routers/api.go +++ b/backend/my_work/routers/api.go @@ -46,6 +46,7 @@ func ApiRoot(r *gin.RouterGroup) { ApiSysAdmin(r.Group("/admin")) ApiCustomer(r.Group("/customer")) ApiCalendar(r.Group("/calendar")) + ApiAIChat(r.Group("/aichat")) r.GET("/", func(ctx *gin.Context) { ReturnJson(ctx, "apiOK", gin.H{ "isOpsApiRoot": true, diff --git a/backend/my_work/routers/apiAIChat.go b/backend/my_work/routers/apiAIChat.go new file mode 100644 index 0000000..c42f1a9 --- /dev/null +++ b/backend/my_work/routers/apiAIChat.go @@ -0,0 +1,481 @@ +package routers + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "ops/agents" + "ops/models" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// SSE frame types sent to frontend +type sseEvent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + Stage string `json:"stage,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Stats *tokenUsageStats `json:"stats,omitempty"` + Error string `json:"error,omitempty"` +} + +type tokenUsageStats struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// chatRequestFromFrontend is the expected POST body +type chatRequest struct { + Messages []chatMessage `json:"messages"` + OpenAIName string `json:"openaiName,omitempty"` +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// openaiChatRequest is the request sent to the upstream OpenAI-compatible API +type openaiChatRequest struct { + Model string `json:"model"` + Messages []openaiMessage `json:"messages"` + Stream bool `json:"stream"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` +} + +type openaiMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// openaiStreamChunk is one SSE data line from the upstream +type openaiStreamChunk struct { + ID string `json:"id,omitempty"` + Object string `json:"object,omitempty"` + Created int64 `json:"created,omitempty"` + Model string `json:"model,omitempty"` + Choices []openaiChoice `json:"choices"` + Usage *openaiUsage `json:"usage,omitempty"` +} + +type openaiChatResponse struct { + Choices []openaiResponseChoice `json:"choices"` + Usage *openaiUsage `json:"usage,omitempty"` +} + +type openaiResponseChoice struct { + Message openaiMessage `json:"message"` +} + +type toolRouteResponse struct { + Tools []struct { + Name string `json:"name"` + Reason string `json:"reason"` + } `json:"tools"` + Reason string `json:"reason"` +} + +type openaiChoice struct { + Index int `json:"index"` + Delta openaiDelta `json:"delta"` + Finish *string `json:"finish_reason,omitempty"` +} + +type openaiDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` +} + +type openaiUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +func ApiAIChat(r *gin.RouterGroup) { + r.GET("/openai", handleOpenAIProfiles) + r.POST("/chat", handleChat) + + admin := r.Group("/admin") + admin.POST("/config", handleAIChatAdminGetConfig) + admin.POST("/config/update", handleAIChatAdminUpdateConfig) + admin.POST("/refresh", handleAIChatAdminRefreshCache) +} + +func handleOpenAIProfiles(ctx *gin.Context) { + cfg := getAIChatConfig() + active := "" + profiles := make([]map[string]interface{}, 0, len(cfg.OpenAI)) + for _, profile := range cfg.OpenAI { + if profile.Active { + active = profile.Name + } + profiles = append(profiles, map[string]interface{}{ + "name": profile.Name, + "active": profile.Active, + "baseUrl": profile.BaseUrl, + "model": profile.Model, + "timeout": profile.Timeout, + "maxTokens": profile.MaxTokens, + }) + } + ReturnJson(ctx, "apiOK", gin.H{ + "enabled": cfg.Enabled, + "active": active, + "profiles": profiles, + "toolRouter": gin.H{ + "enabled": cfg.ToolRouter.Enabled, + "openaiName": cfg.ToolRouter.OpenAIName, + "timeout": cfg.ToolRouter.Timeout, + "maxTokens": cfg.ToolRouter.MaxTokens, + }, + }) +} + +func handleChat(ctx *gin.Context) { + data, _ := SeparateData(ctx) + + if data == nil { + sendSSEError(ctx, "请求数据为空") + return + } + + var req chatRequest + if err := decodeJSON(data, &req); err != nil { + sendSSEError(ctx, "解析消息失败: "+err.Error()) + return + } + + if len(req.Messages) == 0 { + sendSSEError(ctx, "消息不能为空") + return + } + + // Check ai config + cfg := getAIChatConfig() + profile, ok := selectOpenAIProfile(cfg, req.OpenAIName) + if !cfg.Enabled || !ok || profile.Model == "" || profile.ApiKey == "" { + sendSSEError(ctx, "AI 聊天未配置,请在后台配置 API Key 和模型") + return + } + toolRouterProfile, hasToolRouterProfile := selectOpenAIProfile(cfg, cfg.ToolRouter.OpenAIName) + + // Convert to agent messages and enrich with tools + chatMsgs := convertToChatMessages(req.Messages) + toolConfigs := []agents.ToolConfig{} + if cfg.ToolRouter.Enabled { + toolConfigs = buildToolConfigs(cfg.ToolRouter.Tools) + if hasToolRouterProfile && toolRouterProfile.Model != "" && toolRouterProfile.ApiKey != "" { + selected, err := routeTools(ctx.Request.Context(), toolRouterProfile, cfg.ToolRouter, chatMsgs) + if err == nil && selected != nil { + toolConfigs = filterToolConfigs(toolConfigs, selected) + } + } + } + + // Set up SSE headers + ctx.Writer.Header().Set("Content-Type", "text/event-stream") + ctx.Writer.Header().Set("Cache-Control", "no-cache") + ctx.Writer.Header().Set("Connection", "keep-alive") + ctx.Writer.WriteHeader(http.StatusOK) + flusher, _ := ctx.Writer.(http.Flusher) + + // Enrich messages with tools (pre-process) + chatMsgs = agents.EnrichMessages(ctx.Request.Context(), chatMsgs, toolConfigs, func(tool, stage, status, message string, data map[string]interface{}) { + sendSSE(ctx, flusher, sseEvent{ + Type: "trace", + Tool: tool, + Stage: stage, + Status: status, + Message: message, + Data: data, + }) + }) + + // Build OpenAI-compatible request + openaiMsgs := convertToOpenAIMessages(chatMsgs) + apiReq := openaiChatRequest{ + Model: profile.Model, + Messages: openaiMsgs, + Stream: true, + MaxTokens: profile.MaxTokens, + Temperature: 0.7, + } + + // Add system prompt if configured + if profile.SystemPrompt != "" { + apiReq.Messages = append([]openaiMessage{{Role: "system", Content: profile.SystemPrompt}}, apiReq.Messages...) + } + + err := streamOpenAI(ctx.Request.Context(), profile, apiReq, func(chunk openaiStreamChunk) { + for _, choice := range chunk.Choices { + if choice.Delta.Content != "" { + sendSSE(ctx, flusher, sseEvent{ + Type: "delta", + Text: choice.Delta.Content, + }) + } + } + if chunk.Usage != nil { + sendSSE(ctx, flusher, sseEvent{ + Type: "stats", + Stats: &tokenUsageStats{ + PromptTokens: chunk.Usage.PromptTokens, + CompletionTokens: chunk.Usage.CompletionTokens, + TotalTokens: chunk.Usage.TotalTokens, + }, + }) + } + }) + if err != nil { + sendSSE(ctx, flusher, sseEvent{Type: "error", Error: "请求失败: " + err.Error()}) + sendSSEDone(ctx, flusher) + return + } + + sendSSEDone(ctx, flusher) + flusher.Flush() +} + +func streamOpenAI(ctx context.Context, cfg models.ConfigsAIChatOpenAI_, req openaiChatRequest, onData func(openaiStreamChunk)) error { + bodyBytes, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("序列化请求失败: %w", err) + } + + url := strings.TrimRight(cfg.BaseUrl, "/") + "/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("创建请求失败: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+cfg.ApiKey) + httpReq.Header.Set("Accept", "text/event-stream") + + client := &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return fmt.Errorf("连接上游服务失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("上游返回 %d: %s", resp.StatusCode, string(body)) + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 256*1024) + + for scanner.Scan() { + line := scanner.Text() + + if strings.TrimSpace(line) == "" { + continue + } + if !strings.HasPrefix(line, "data: ") { + continue + } + + payload := strings.TrimPrefix(line, "data: ") + payload = strings.TrimSpace(payload) + + if payload == "[DONE]" { + continue + } + + var chunk openaiStreamChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil { + continue + } + + onData(chunk) + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("读取流失败: %w", err) + } + + return nil +} + +// Initialize with system prompt if present +func sendSSE(ctx *gin.Context, flusher http.Flusher, event sseEvent) { + data, err := json.Marshal(event) + if err != nil { + return + } + _, _ = fmt.Fprintf(ctx.Writer, "data: %s\n\n", string(data)) + flusher.Flush() +} + +func sendSSEDone(ctx *gin.Context, flusher http.Flusher) { + _, _ = fmt.Fprint(ctx.Writer, "data: [DONE]\n\n") + flusher.Flush() +} + +func sendSSEError(ctx *gin.Context, message string) { + ctx.Writer.Header().Set("Content-Type", "text/event-stream") + ctx.Writer.Header().Set("Cache-Control", "no-cache") + ctx.Writer.Header().Set("Connection", "keep-alive") + ctx.Writer.WriteHeader(http.StatusOK) + flusher, _ := ctx.Writer.(http.Flusher) + + sendSSE(ctx, flusher, sseEvent{ + Type: "error", + Error: message, + }) + + sendSSEDone(ctx, flusher) + flusher.Flush() +} + +func convertToChatMessages(msgs []chatMessage) []agents.ChatMessage { + result := make([]agents.ChatMessage, 0, len(msgs)) + for _, m := range msgs { + result = append(result, agents.ChatMessage{Role: m.Role, Content: m.Content}) + } + return result +} + +func convertToOpenAIMessages(msgs []agents.ChatMessage) []openaiMessage { + result := make([]openaiMessage, 0, len(msgs)) + for _, m := range msgs { + result = append(result, openaiMessage{Role: m.Role, Content: m.Content}) + } + return result +} + +func buildToolConfigs(configs []models.ConfigsAIChatTool_) []agents.ToolConfig { + result := make([]agents.ToolConfig, 0, len(configs)) + for _, c := range configs { + result = append(result, agents.ToolConfig{ + Name: c.Name, + Enabled: c.Enabled, + Description: c.Description, + }) + } + return result +} + +func selectOpenAIProfile(cfg models.ConfigsAIChat_, name string) (models.ConfigsAIChatOpenAI_, bool) { + if name != "" { + for _, p := range cfg.OpenAI { + if p.Name == name { + return p, true + } + } + return models.ConfigsAIChatOpenAI_{}, false + } + for _, p := range cfg.OpenAI { + if p.Active { + return p, true + } + } + if len(cfg.OpenAI) > 0 { + return cfg.OpenAI[0], true + } + return models.ConfigsAIChatOpenAI_{}, false +} + +func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router models.ConfigsAIChatToolRouter_, messages []agents.ChatMessage) ([]string, error) { + openaiMsgs := []openaiMessage{} + lastUserContent := agents.LastUserContent(messages) + if lastUserContent != "" { + openaiMsgs = append(openaiMsgs, openaiMessage{Role: "user", Content: lastUserContent}) + } + + toolNames := make([]string, 0, len(router.Tools)) + for _, t := range router.Tools { + if t.Enabled { + toolNames = append(toolNames, t.Name+" - "+t.Description) + } + } + if len(toolNames) == 0 { + return nil, nil + } + + sysPrompt := "请根据用户的最新一条消息,判断需要启用哪些工具来完成用户需求。\n可选工具:\n" + strings.Join(toolNames, "\n") + "\n\n回复格式要求:\n```json\n{\"tools\":[{\"name\":\"工具名称\",\"reason\":\"选择原因\"}],\"reason\":\"整体判断理由\"}\n```\n仅输出 JSON 代码块。如果没有需要启用的工具,返回 {\"tools\":[]}。" + openaiMsgs = append([]openaiMessage{{Role: "system", Content: sysPrompt}}, openaiMsgs...) + + req := openaiChatRequest{ + Model: profile.Model, + Messages: openaiMsgs, + Stream: false, + MaxTokens: router.MaxTokens, + Temperature: 0.1, + } + + bodyBytes, _ := json.Marshal(req) + url := strings.TrimRight(profile.BaseUrl, "/") + "/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+profile.ApiKey) + client := &http.Client{Timeout: time.Duration(router.Timeout) * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result openaiChatResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + if len(result.Choices) == 0 { + return nil, nil + } + + response := result.Choices[0].Message.Content + toolRouteResponse := extractToolsFromResponse(response) + return toolRouteResponse, nil +} + +func extractToolsFromResponse(response string) []string { + start := strings.Index(response, "{") + end := strings.LastIndex(response, "}") + if start == -1 || end == -1 || end <= start { + return nil + } + var parsed toolRouteResponse + if err := json.Unmarshal([]byte(response[start:end+1]), &parsed); err != nil { + return nil + } + tools := make([]string, 0, len(parsed.Tools)) + for _, t := range parsed.Tools { + tools = append(tools, t.Name) + } + return tools +} + +func filterToolConfigs(configs []agents.ToolConfig, selected []string) []agents.ToolConfig { + if len(selected) == 0 { + return []agents.ToolConfig{} + } + selectedSet := make(map[string]bool, len(selected)) + for _, s := range selected { + selectedSet[s] = true + } + filtered := make([]agents.ToolConfig, 0, len(configs)) + for _, c := range configs { + if selectedSet[c.Name] { + filtered = append(filtered, c) + } + } + return filtered +} diff --git a/backend/my_work/routers/apiAIChatConfig.go b/backend/my_work/routers/apiAIChatConfig.go new file mode 100644 index 0000000..3721299 --- /dev/null +++ b/backend/my_work/routers/apiAIChatConfig.go @@ -0,0 +1,457 @@ +package routers + +import ( + "errors" + "ops/models" + "sync" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type TabAIChatSetting struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Enabled bool `gorm:"default:false;index"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` + UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"` +} + +type TabAIChatOpenAIProfile struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"size:100;not null;uniqueIndex"` + Active bool `gorm:"default:false;index"` + ApiKey string `gorm:"type:text"` + BaseUrl string `gorm:"size:500"` + Model string `gorm:"size:200"` + Timeout int `gorm:"default:120"` + MaxTokens int `gorm:"default:4096"` + SystemPrompt string `gorm:"type:text"` + SortOrder int `gorm:"default:0;index"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` + UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"` +} + +type TabAIChatToolRouter struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Enabled bool `gorm:"default:true"` + OpenAIName string `gorm:"size:100;index"` + Timeout int `gorm:"default:30"` + MaxTokens int `gorm:"default:512"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` + UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"` +} + +type TabAIChatTool struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"size:100;not null;uniqueIndex"` + Enabled bool `gorm:"default:true;index"` + Description string `gorm:"type:text"` + SortOrder int `gorm:"default:0;index"` + CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"` + UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"` +} + +var aiChatConfigMu sync.RWMutex + +func ApiAIChatInit() { + err := models.DB.AutoMigrate( + &TabAIChatSetting{}, + &TabAIChatOpenAIProfile{}, + &TabAIChatToolRouter{}, + &TabAIChatTool{}, + ) + if err != nil { + panic(err) + } + + if err := seedAIChatConfigFromYAMLIfEmpty(); err != nil { + panic(err) + } + if err := RefreshAIChatConfigCache(); err != nil { + panic(err) + } +} + +func seedAIChatConfigFromYAMLIfEmpty() error { + var settingCount int64 + var profileCount int64 + models.DB.Model(&TabAIChatSetting{}).Count(&settingCount) + models.DB.Model(&TabAIChatOpenAIProfile{}).Count(&profileCount) + if settingCount > 0 || profileCount > 0 { + return nil + } + + cfg := models.ConfigsAIChat + return models.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&TabAIChatSetting{ID: 1, Enabled: cfg.Enabled}).Error; err != nil { + return err + } + + profiles := cfg.OpenAI + if len(profiles) == 0 { + profiles = []models.ConfigsAIChatOpenAI_{{ + Name: "default", + Active: true, + BaseUrl: "https://ark.cn-beijing.volces.com/api/v3", + Timeout: 120, + MaxTokens: 4096, + SystemPrompt: "你是一个有帮助的 AI 助手。", + }} + } + for i, profile := range profiles { + if profile.Name == "" { + profile.Name = "default" + } + if profile.Timeout <= 0 { + profile.Timeout = 120 + } + if profile.MaxTokens <= 0 { + profile.MaxTokens = 4096 + } + if err := tx.Create(&TabAIChatOpenAIProfile{ + Name: profile.Name, + Active: profile.Active, + ApiKey: profile.ApiKey, + BaseUrl: profile.BaseUrl, + Model: profile.Model, + Timeout: profile.Timeout, + MaxTokens: profile.MaxTokens, + SystemPrompt: profile.SystemPrompt, + SortOrder: i, + }).Error; err != nil { + return err + } + } + + toolRouter := cfg.ToolRouter + if toolRouter.Timeout <= 0 { + toolRouter.Timeout = 30 + } + if toolRouter.MaxTokens <= 0 { + toolRouter.MaxTokens = 512 + } + if toolRouter.OpenAIName == "" && len(profiles) > 0 { + toolRouter.OpenAIName = profiles[0].Name + } + if err := tx.Create(&TabAIChatToolRouter{ + ID: 1, + Enabled: toolRouter.Enabled, + OpenAIName: toolRouter.OpenAIName, + Timeout: toolRouter.Timeout, + MaxTokens: toolRouter.MaxTokens, + }).Error; err != nil { + return err + } + + tools := toolRouter.Tools + if len(tools) == 0 { + tools = []models.ConfigsAIChatTool_{{Name: "time", Enabled: true, Description: "提供当前日期、时间和相对日期换算。"}} + } + for i, tool := range tools { + if tool.Name == "" { + continue + } + if err := tx.Create(&TabAIChatTool{ + Name: tool.Name, + Enabled: tool.Enabled, + Description: tool.Description, + SortOrder: i, + }).Error; err != nil { + return err + } + } + return nil + }) +} + +func RefreshAIChatConfigCache() error { + var setting TabAIChatSetting + if err := models.DB.First(&setting, 1).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + + var profiles []TabAIChatOpenAIProfile + if err := models.DB.Order("sort_order asc, id asc").Find(&profiles).Error; err != nil { + return err + } + + var toolRouter TabAIChatToolRouter + if err := models.DB.First(&toolRouter, 1).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var tools []TabAIChatTool + if err := models.DB.Order("sort_order asc, id asc").Find(&tools).Error; err != nil { + return err + } + + cfg := models.ConfigsAIChat_{ + Enabled: setting.Enabled, + OpenAI: make([]models.ConfigsAIChatOpenAI_, 0, len(profiles)), + ToolRouter: models.ConfigsAIChatToolRouter_{ + Enabled: toolRouter.Enabled, + OpenAIName: toolRouter.OpenAIName, + Timeout: defaultInt(toolRouter.Timeout, 30), + MaxTokens: defaultInt(toolRouter.MaxTokens, 512), + Tools: make([]models.ConfigsAIChatTool_, 0, len(tools)), + }, + } + + for _, profile := range profiles { + cfg.OpenAI = append(cfg.OpenAI, models.ConfigsAIChatOpenAI_{ + Name: profile.Name, + Active: profile.Active, + ApiKey: profile.ApiKey, + BaseUrl: profile.BaseUrl, + Model: profile.Model, + Timeout: defaultInt(profile.Timeout, 120), + MaxTokens: defaultInt(profile.MaxTokens, 4096), + SystemPrompt: profile.SystemPrompt, + }) + } + for _, tool := range tools { + cfg.ToolRouter.Tools = append(cfg.ToolRouter.Tools, models.ConfigsAIChatTool_{ + Name: tool.Name, + Enabled: tool.Enabled, + Description: tool.Description, + }) + } + + aiChatConfigMu.Lock() + models.ConfigsAIChat = cfg + aiChatConfigMu.Unlock() + return nil +} + +func getAIChatConfig() models.ConfigsAIChat_ { + aiChatConfigMu.RLock() + defer aiChatConfigMu.RUnlock() + return models.ConfigsAIChat +} + +func defaultInt(value int, fallback int) int { + if value <= 0 { + return fallback + } + return value +} + +func handleAIChatAdminGetConfig(ctx *gin.Context) { + if ok, _ := requireSysAdmin(ctx); !ok { + return + } + cfg := getAIChatConfig() + ReturnJson(ctx, "apiOK", gin.H{ + "enabled": cfg.Enabled, + "openai": maskAIChatProfiles(cfg.OpenAI), + "toolRouter": gin.H{ + "enabled": cfg.ToolRouter.Enabled, + "openaiName": cfg.ToolRouter.OpenAIName, + "timeout": cfg.ToolRouter.Timeout, + "maxTokens": cfg.ToolRouter.MaxTokens, + "tools": maskAIChatTools(cfg.ToolRouter.Tools), + }, + }) +} + +func handleAIChatAdminRefreshCache(ctx *gin.Context) { + if ok, _ := requireSysAdmin(ctx); !ok { + return + } + if err := RefreshAIChatConfigCache(); err != nil { + ReturnJson(ctx, "apiErr", gin.H{"error": err.Error()}) + return + } + ReturnJson(ctx, "apiOK", nil) +} + +func handleAIChatAdminUpdateConfig(ctx *gin.Context) { + ok, data := requireSysAdmin(ctx) + if !ok { + return + } + if data == nil { + ReturnJson(ctx, "apiErr", nil) + return + } + + var req models.ConfigsAIChat_ + if err := decodeJSON(data, &req); err != nil { + ReturnJson(ctx, "apiErr", gin.H{"error": err.Error()}) + return + } + if len(req.OpenAI) == 0 { + ReturnJson(ctx, "apiErr", gin.H{"error": "openai profiles cannot be empty"}) + return + } + + if err := saveAIChatConfig(req); err != nil { + ReturnJson(ctx, "apiErr", gin.H{"error": err.Error()}) + return + } + if err := RefreshAIChatConfigCache(); err != nil { + ReturnJson(ctx, "apiErr", gin.H{"error": err.Error()}) + return + } + ReturnJson(ctx, "apiOK", nil) +} + +func saveAIChatConfig(req models.ConfigsAIChat_) error { + return models.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(&TabAIChatSetting{ID: 1, Enabled: req.Enabled}).Error; err != nil { + return err + } + + var existingProfiles []TabAIChatOpenAIProfile + if err := tx.Find(&existingProfiles).Error; err != nil { + return err + } + existingByName := make(map[string]TabAIChatOpenAIProfile, len(existingProfiles)) + for _, profile := range existingProfiles { + existingByName[profile.Name] = profile + } + + incomingNames := make(map[string]bool, len(req.OpenAI)) + activeSet := false + for i, profile := range req.OpenAI { + if profile.Name == "" { + continue + } + incomingNames[profile.Name] = true + if profile.Timeout <= 0 { + profile.Timeout = 120 + } + if profile.MaxTokens <= 0 { + profile.MaxTokens = 4096 + } + if profile.Active { + if activeSet { + profile.Active = false + } else { + activeSet = true + } + } + + tab := TabAIChatOpenAIProfile{ + Name: profile.Name, + Active: profile.Active, + ApiKey: profile.ApiKey, + BaseUrl: profile.BaseUrl, + Model: profile.Model, + Timeout: profile.Timeout, + MaxTokens: profile.MaxTokens, + SystemPrompt: profile.SystemPrompt, + SortOrder: i, + } + if old, ok := existingByName[profile.Name]; ok { + tab.ID = old.ID + if tab.ApiKey == "" { + tab.ApiKey = old.ApiKey + } + } + if err := tx.Save(&tab).Error; err != nil { + return err + } + } + for _, old := range existingProfiles { + if !incomingNames[old.Name] { + if err := tx.Delete(&old).Error; err != nil { + return err + } + } + } + if !activeSet && len(req.OpenAI) > 0 { + if err := tx.Model(&TabAIChatOpenAIProfile{}).Where("name = ?", req.OpenAI[0].Name).Update("active", true).Error; err != nil { + return err + } + } + + toolRouter := req.ToolRouter + if toolRouter.Timeout <= 0 { + toolRouter.Timeout = 30 + } + if toolRouter.MaxTokens <= 0 { + toolRouter.MaxTokens = 512 + } + if err := tx.Save(&TabAIChatToolRouter{ID: 1, Enabled: toolRouter.Enabled, OpenAIName: toolRouter.OpenAIName, Timeout: toolRouter.Timeout, MaxTokens: toolRouter.MaxTokens}).Error; err != nil { + return err + } + + var existingTools []TabAIChatTool + if err := tx.Find(&existingTools).Error; err != nil { + return err + } + existingToolByName := make(map[string]TabAIChatTool, len(existingTools)) + for _, tool := range existingTools { + existingToolByName[tool.Name] = tool + } + incomingToolNames := make(map[string]bool, len(toolRouter.Tools)) + for i, tool := range toolRouter.Tools { + if tool.Name == "" { + continue + } + incomingToolNames[tool.Name] = true + tab := TabAIChatTool{Name: tool.Name, Enabled: tool.Enabled, Description: tool.Description, SortOrder: i} + if old, ok := existingToolByName[tool.Name]; ok { + tab.ID = old.ID + } + if err := tx.Save(&tab).Error; err != nil { + return err + } + } + for _, old := range existingTools { + if !incomingToolNames[old.Name] { + if err := tx.Delete(&old).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func maskAIChatProfiles(profiles []models.ConfigsAIChatOpenAI_) []gin.H { + items := make([]gin.H, 0, len(profiles)) + for _, profile := range profiles { + items = append(items, gin.H{ + "name": profile.Name, + "active": profile.Active, + "apiKeySet": profile.ApiKey != "", + "baseUrl": profile.BaseUrl, + "model": profile.Model, + "timeout": profile.Timeout, + "maxTokens": profile.MaxTokens, + "systemPrompt": profile.SystemPrompt, + }) + } + return items +} + +func maskAIChatTools(tools []models.ConfigsAIChatTool_) []gin.H { + items := make([]gin.H, 0, len(tools)) + for _, tool := range tools { + items = append(items, gin.H{ + "name": tool.Name, + "enabled": tool.Enabled, + "description": tool.Description, + }) + } + return items +} + +func requireSysAdmin(ctx *gin.Context) (bool, map[string]interface{}) { + isAuth, user, data := AuthenticationAuthority(ctx) + if !isAuth { + ReturnJson(ctx, "userCookieError", nil) + return false, nil + } + if !SysAdminCheck(user.ID) { + ReturnJson(ctx, "permission_denied", nil) + return false, nil + } + return true, data +} diff --git a/frontend/ops_vue_js/src/api/aichat.js b/frontend/ops_vue_js/src/api/aichat.js new file mode 100644 index 0000000..565e961 --- /dev/null +++ b/frontend/ops_vue_js/src/api/aichat.js @@ -0,0 +1,108 @@ +import { api } from './index' +import { useUserStore } from '@/stores/user' + +export async function fetchOpenAIProfiles() { + return api.get('/aichat/openai') +} + +export async function fetchAIChatAdminConfig() { + return api.post('/aichat/admin/config', {}) +} + +export async function updateAIChatAdminConfig(config) { + return api.post('/aichat/admin/config/update', config) +} + +export async function refreshAIChatAdminConfig() { + return api.post('/aichat/admin/refresh', {}) +} + +function parseSSEBlock(block) { + const lines = block.split('\n') + const dataLines = [] + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()) + } + } + + if (dataLines.length === 0) return null + return dataLines.join('\n') +} + +export async function streamChat(messages, options = {}, handlers = {}) { + if (typeof options.onDelta === 'function' || typeof options.onError === 'function') { + handlers = options + options = {} + } + + const userStore = useUserStore() + const response = await fetch('/api/aichat/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userCookieValue: userStore.cookieValue || '', + data: { messages, openaiName: options.openaiName || '' }, + }), + }) + + if (!response.ok) { + handlers.onError?.(`HTTP ${response.status}`) + return + } + if (!response.body) { + handlers.onError?.('ReadableStream is not supported') + return + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const blocks = buffer.split('\n\n') + buffer = blocks.pop() || '' + + for (const block of blocks) { + const payload = parseSSEBlock(block) + if (!payload) continue + if (payload === '[DONE]') { + handlers.onDone?.() + return + } + + try { + const frame = JSON.parse(payload) + switch (frame.type) { + case 'delta': + handlers.onDelta?.(frame.text || '') + break + case 'trace': + handlers.onTrace?.(frame) + break + case 'stats': + handlers.onStats?.(frame.stats || null) + break + case 'error': + handlers.onError?.(frame.error || frame.message || 'AI request failed') + break + case '[DONE]': + handlers.onDone?.() + return + default: + handlers.onFrame?.(frame) + break + } + } catch (error) { + handlers.onError?.(error.message) + } + } + } + + handlers.onDone?.() +} diff --git a/frontend/ops_vue_js/src/components/AppHeader.vue b/frontend/ops_vue_js/src/components/AppHeader.vue index bb00897..e7428ed 100644 --- a/frontend/ops_vue_js/src/components/AppHeader.vue +++ b/frontend/ops_vue_js/src/components/AppHeader.vue @@ -58,6 +58,7 @@ const navItems = computed(() => [ { label: t("appname.purchase"), to: "/purchase" }, { label: t("appname.work_order"), to: "/work_order" }, { label: t("appname.warehouse"), to: "/warehouse" }, + { label: t("appname.aichat"), to: "/aichat" }, ]); diff --git a/frontend/ops_vue_js/src/i18n/en.json b/frontend/ops_vue_js/src/i18n/en.json index 30b61b6..4c80bd5 100644 --- a/frontend/ops_vue_js/src/i18n/en.json +++ b/frontend/ops_vue_js/src/i18n/en.json @@ -36,7 +36,66 @@ "warehouse": "Warehouse", "warehouse_items": "Items Overview", "work_order": "Work Order", - "calendar": "Calendar" + "calendar": "Calendar", + "aichat": "AI Chat" + }, + "aichat": { + "title": "AI Chat", + "subtitle": "Chat with the AI assistant using streaming responses and backend tool traces.", + "empty_title": "Start an AI chat", + "empty_hint": "Type a question and press Enter to send. Use Shift + Enter for a new line.", + "input_placeholder": "Type a message...", + "send": "Send", + "clear": "Clear", + "thinking": "Thinking...", + "streaming": "Generating response", + "tokens": "Token usage", + "profile": "AI profile", + "default_profile": "Default profile", + "tool_router": "Tool router", + "enter_hint": "Enter to send, Shift + Enter for a new line", + "error_prefix": "Request failed: " + }, + "aiconfig": { + "title": "AI Config", + "subtitle": "Configure AI profiles, tool routing, and tool list stored in the database.", + "reload": "Reload", + "refresh_cache": "Refresh Cache", + "save": "Save", + "enabled": "Enabled", + "profiles": "AI Profiles", + "add_profile": "Add Profile", + "remove": "Remove", + "active": "Default Profile", + "name": "Name", + "api_key": "API Key", + "api_key_keep": "Already configured; leave blank to keep it", + "base_url": "Base URL", + "model": "Model", + "timeout": "Timeout (seconds)", + "max_tokens": "Max Tokens", + "system_prompt": "System Prompt", + "tool_router": "Tool Router", + "router_profile": "Router Profile", + "none": "None", + "tools": "Tools", + "add_tool": "Add Tool", + "description": "Description", + "load_failed": "Failed to load AI config", + "save_success": "AI config saved", + "save_failed": "Failed to save AI config", + "refresh_success": "AI config cache refreshed", + "refresh_failed": "Failed to refresh AI config cache", + "error_profile_required": "At least one AI profile is required", + "error_profile_name_required": "Profile name is required", + "error_profile_name_duplicate": "Profile names must be unique", + "error_active_profile_required": "When AI is enabled, the default profile must have Base URL and model configured", + "error_api_key_required": "When AI is enabled, the default profile must have an API key", + "error_timeout": "Timeout must be a positive integer", + "error_max_tokens": "Max tokens must be a positive integer", + "error_router_profile": "Tool router profile must exist in profile list", + "error_tool_name_required": "Tool name is required", + "error_tool_name_duplicate": "Tool names must be unique" }, "tagadder": { "not_fund_item": "No matching items found", diff --git a/frontend/ops_vue_js/src/i18n/zh-CN.json b/frontend/ops_vue_js/src/i18n/zh-CN.json index 6d1562b..9bb6767 100644 --- a/frontend/ops_vue_js/src/i18n/zh-CN.json +++ b/frontend/ops_vue_js/src/i18n/zh-CN.json @@ -36,7 +36,66 @@ "warehouse": "仓库", "warehouse_items": "物品总览", "work_order": "工单", - "calendar": "日历" + "calendar": "日历", + "aichat": "AI 对话" + }, + "aichat": { + "title": "AI 对话", + "subtitle": "通过流式响应与 AI 助手对话,可显示后端工具执行过程。", + "empty_title": "开始一次 AI 对话", + "empty_hint": "输入问题后按 Enter 发送,Shift + Enter 换行。", + "input_placeholder": "输入消息...", + "send": "发送", + "clear": "清空", + "thinking": "正在思考...", + "streaming": "正在生成回复", + "tokens": "Token 用量", + "profile": "AI 接口", + "default_profile": "默认接口", + "tool_router": "工具路由", + "enter_hint": "Enter 发送,Shift + Enter 换行", + "error_prefix": "请求失败:" + }, + "aiconfig": { + "title": "AI 配置", + "subtitle": "配置数据库中的 AI 接口、工具路由和工具列表。", + "reload": "重新加载", + "refresh_cache": "刷新缓存", + "save": "保存", + "enabled": "启用", + "profiles": "AI 接口 Profiles", + "add_profile": "添加接口", + "remove": "删除", + "active": "默认接口", + "name": "名称", + "api_key": "API Key", + "api_key_keep": "已配置,留空保留原密钥", + "base_url": "Base URL", + "model": "模型", + "timeout": "超时(秒)", + "max_tokens": "最大 Token", + "system_prompt": "系统提示词", + "tool_router": "工具路由", + "router_profile": "路由接口", + "none": "不指定", + "tools": "工具", + "add_tool": "添加工具", + "description": "描述", + "load_failed": "加载 AI 配置失败", + "save_success": "AI 配置已保存", + "save_failed": "保存 AI 配置失败", + "refresh_success": "AI 配置缓存已刷新", + "refresh_failed": "刷新 AI 配置缓存失败", + "error_profile_required": "至少需要一个 AI 接口", + "error_profile_name_required": "接口名称不能为空", + "error_profile_name_duplicate": "接口名称不能重复", + "error_active_profile_required": "启用 AI 时默认接口必须配置 Base URL 和模型", + "error_api_key_required": "启用 AI 时默认接口必须配置 API Key", + "error_timeout": "超时必须是正整数", + "error_max_tokens": "最大 Token 必须是正整数", + "error_router_profile": "工具路由接口必须来自现有接口列表", + "error_tool_name_required": "工具名称不能为空", + "error_tool_name_duplicate": "工具名称不能重复" }, "tagadder": { "not_fund_item": "没有找到匹配项", diff --git a/frontend/ops_vue_js/src/router/index.js b/frontend/ops_vue_js/src/router/index.js index f791676..7a2e792 100644 --- a/frontend/ops_vue_js/src/router/index.js +++ b/frontend/ops_vue_js/src/router/index.js @@ -109,6 +109,12 @@ const router = createRouter({ component: () => import('@/views/sysadmin/SysAdminView.vue'), meta: { requireSysAdmin: true }, }, + { + path: 'admin/aiconfig', + name: 'admin-aiconfig', + component: () => import('@/views/admin/AIConfigView.vue'), + meta: { requireSysAdmin: true }, + }, { path: 'customer', name: 'customer', @@ -150,6 +156,11 @@ const router = createRouter({ name: 'user-my', component: () => import('@/views/user/MyProfile.vue'), }, + { + path: 'aichat', + name: 'aichat', + component: () => import('@/views/aichat/AiChatView.vue'), + }, ], }, @@ -206,7 +217,7 @@ router.beforeEach((to) => { const userStore = useUserStore() // 不需要登录的页面(精确匹配或前缀匹配) - const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/calendars', '/404'] + const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/calendars', '/aichat', '/404'] const publicPrefixes = ['/calendar/'] if (publicPages.includes(to.path) || publicPrefixes.some(p => to.path.startsWith(p))) return true diff --git a/frontend/ops_vue_js/src/views/admin/AIConfigView.vue b/frontend/ops_vue_js/src/views/admin/AIConfigView.vue new file mode 100644 index 0000000..bf84bfa --- /dev/null +++ b/frontend/ops_vue_js/src/views/admin/AIConfigView.vue @@ -0,0 +1,513 @@ + + +