diff --git a/backend/my_work/agents/time.go b/backend/my_work/agents/time.go index 18a6927..cc1f80f 100644 --- a/backend/my_work/agents/time.go +++ b/backend/my_work/agents/time.go @@ -9,6 +9,25 @@ import ( type TimeTool struct{} +type TimeRangeArgs struct { + Range string `json:"range"` + Timezone string `json:"timezone,omitempty"` + StartDate string `json:"start_date,omitempty"` + EndDate string `json:"end_date,omitempty"` +} + +type TimeRangeResult struct { + Ok bool `json:"ok"` + Now string `json:"now"` + NowWeekday string `json:"now_weekday"` + StartDate string `json:"start_date"` + StartWeekday string `json:"start_weekday"` + EndDate string `json:"end_date"` + EndWeekday string `json:"end_weekday"` + Label string `json:"label"` + Timezone string `json:"timezone"` +} + func init() { Register(TimeTool{}) } @@ -56,6 +75,101 @@ func (TimeTool) Enrich(ctx context.Context, messages []ChatMessage, config ToolC return enriched, nil } +func ResolveTimeRange(args TimeRangeArgs, now time.Time) (TimeRangeResult, error) { + loc := now.Location() + if args.Timezone != "" { + if loaded, err := time.LoadLocation(args.Timezone); err == nil { + loc = loaded + now = now.In(loc) + } + } + + 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, loc) + yearStart := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, loc) + + rangeName := strings.TrimSpace(strings.ToLower(args.Range)) + start := todayStart + end := todayStart + label := "今天" + + switch rangeName { + case "", "today": + label = "今天" + start = todayStart + end = todayStart + case "yesterday": + label = "昨天" + start = todayStart.AddDate(0, 0, -1) + end = start + case "tomorrow": + label = "明天" + start = todayStart.AddDate(0, 0, 1) + end = start + case "this_week": + label = "本周" + start = weekStart + end = weekStart.AddDate(0, 0, 6) + case "last_week": + label = "上周" + start = weekStart.AddDate(0, 0, -7) + end = weekStart.AddDate(0, 0, -1) + case "next_week": + label = "下周" + start = weekStart.AddDate(0, 0, 7) + end = weekStart.AddDate(0, 0, 13) + case "this_month": + label = "本月" + start = monthStart + end = monthStart.AddDate(0, 1, -1) + case "last_month": + label = "上月" + start = monthStart.AddDate(0, -1, 0) + end = monthStart.AddDate(0, 0, -1) + case "next_month": + label = "下月" + start = monthStart.AddDate(0, 1, 0) + end = monthStart.AddDate(0, 2, -1) + case "this_year": + label = "今年" + start = yearStart + end = yearStart.AddDate(1, 0, -1) + case "custom": + if args.StartDate == "" || args.EndDate == "" { + return TimeRangeResult{Ok: false, Now: now.Format("2006-01-02 15:04:05"), Timezone: loc.String()}, fmt.Errorf("custom range requires start_date and end_date") + } + parsedStart, err := time.ParseInLocation("2006-01-02", args.StartDate, loc) + if err != nil { + return TimeRangeResult{Ok: false, Now: now.Format("2006-01-02 15:04:05"), Timezone: loc.String()}, fmt.Errorf("invalid start_date: %w", err) + } + parsedEnd, err := time.ParseInLocation("2006-01-02", args.EndDate, loc) + if err != nil { + return TimeRangeResult{Ok: false, Now: now.Format("2006-01-02 15:04:05"), Timezone: loc.String()}, fmt.Errorf("invalid end_date: %w", err) + } + if parsedEnd.Before(parsedStart) { + return TimeRangeResult{Ok: false, Now: now.Format("2006-01-02 15:04:05"), Timezone: loc.String()}, fmt.Errorf("end_date must be after start_date") + } + label = "自定义" + start = parsedStart + end = parsedEnd + default: + return TimeRangeResult{Ok: false, Now: now.Format("2006-01-02 15:04:05"), Timezone: loc.String()}, fmt.Errorf("unsupported range: %s", args.Range) + } + + return TimeRangeResult{ + Ok: true, + Now: now.Format("2006-01-02 15:04:05"), + NowWeekday: weekdayName(now.Weekday()), + StartDate: start.Format("2006-01-02"), + StartWeekday: weekdayName(start.Weekday()), + EndDate: end.Format("2006-01-02"), + EndWeekday: weekdayName(end.Weekday()), + Label: label, + Timezone: loc.String(), + }, nil +} + func buildTimeContext(now time.Time) string { todayStart := dateStart(now) weekStart := todayStart.AddDate(0, 0, -int((int(todayStart.Weekday())+6)%7)) diff --git a/backend/my_work/models/configs.go b/backend/my_work/models/configs.go index a57ca1e..61d5b2b 100644 --- a/backend/my_work/models/configs.go +++ b/backend/my_work/models/configs.go @@ -105,7 +105,8 @@ func ConfigAllInit() error { Timeout: 30, MaxTokens: 512, Tools: []ConfigsAIChatTool_{ - {Name: "time", Enabled: true, Description: "提供当前日期、时间和相对日期换算。"}, + {Name: "time", Enabled: true, Description: "解析当前时间、相对日期和日期范围。"}, + {Name: "ops_ai_assistant_schedule_query", Enabled: true, Description: "按日期范围查询当前用户可见的 OPS 日历/日程。"}, }, }, } diff --git a/backend/my_work/routers/apiAIChat.go b/backend/my_work/routers/apiAIChat.go index a5a0ddb..4c0b93d 100644 --- a/backend/my_work/routers/apiAIChat.go +++ b/backend/my_work/routers/apiAIChat.go @@ -75,11 +75,38 @@ type openaiChatRequest struct { Stream bool `json:"stream"` MaxTokens int `json:"max_tokens,omitempty"` Temperature float64 `json:"temperature,omitempty"` + Tools []openaiTool `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` } type openaiMessage struct { - Role string `json:"role"` - Content any `json:"content"` + Role string `json:"role"` + Content any `json:"content,omitempty"` + Name string `json:"name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` +} + +type openaiTool struct { + Type string `json:"type"` + Function openaiFunctionDefinition `json:"function"` +} + +type openaiFunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +type openaiToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function openaiFunctionCall `json:"function"` +} + +type openaiFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` } type openaiContentPart struct { @@ -94,8 +121,9 @@ type openaiImageURL struct { } type openaiResponseMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` } // openaiStreamChunk is one SSE data line from the upstream @@ -142,11 +170,12 @@ type openaiChoice struct { } type openaiDelta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - Thinking string `json:"thinking,omitempty"` + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + Thinking string `json:"thinking,omitempty"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` } type openaiUsage struct { @@ -235,7 +264,6 @@ func handleChat(ctx *gin.Context) { sendSSEError(ctx, "AI 聊天未配置,请在后台配置 API Key 和模型") return } - toolRouterProfile, hasToolRouterProfile := selectOpenAIProfile(cfg, cfg.ToolRouter.OpenAIName) chatMsgs := convertToChatMessages(req.Messages) // Set up SSE headers before routing/tools so progress can stream immediately. @@ -272,32 +300,8 @@ func handleChat(ctx *gin.Context) { toolConfigs := []agents.ToolConfig{} if cfg.ToolRouter.Enabled { toolConfigs = buildToolConfigs(cfg.ToolRouter.Tools) - if hasToolRouterProfile && toolRouterProfile.Model != "" && toolRouterProfile.ApiKey != "" { - emitTrace("tool_router", "route", "running", "正在进行工具路由", nil) - routeResult, routeErr := routeTools(ctx.Request.Context(), toolRouterProfile, cfg.ToolRouter, chatMsgs) - if routeErr != nil { - emitTrace("tool_router", "route", "error", "工具路由失败,将继续普通回答", map[string]interface{}{"error": routeErr.Error()}) - toolConfigs = []agents.ToolConfig{} - } else if routeResult != nil { - tracker.addToolUsage(routeResult.Usage, estimateOpenAIMessagesTokens(routeResult.Messages), estimateTokenCount(routeResult.Response)) - data := map[string]interface{}{ - "tools": routeResult.Selected, - "selections": routeResult.Decision.Tools, - "reason": routeResult.Decision.Reason, - } - message := "工具路由结果:无需调用工具" - if len(routeResult.Selected) > 0 { - message = "工具路由结果:将调用 " + strings.Join(routeResult.Selected, ", ") - } - emitTrace("tool_router", "route", "success", message, data) - toolConfigs = filterToolConfigs(toolConfigs, routeResult.Selected) - } - } } - // Enrich messages with tools (pre-process) - chatMsgs = agents.EnrichMessages(ctx.Request.Context(), chatMsgs, toolConfigs, emitTrace) - // Build OpenAI-compatible request openaiMsgs, err := convertToOpenAIMessages(chatMsgs) if err != nil { @@ -305,6 +309,25 @@ func handleChat(ctx *gin.Context) { sendSSEDone(ctx, flusher) return } + functionTools := buildFunctionTools(toolConfigs) + if profile.SystemPrompt != "" { + openaiMsgs = append([]openaiMessage{{Role: "system", Content: profile.SystemPrompt}}, openaiMsgs...) + } + if len(functionTools) > 0 { + toolNames := make([]string, 0, len(functionTools)) + for _, tool := range functionTools { + toolNames = append(toolNames, tool.Function.Name) + } + emitTrace("function_tools", "prepare", "success", "已启用 Function Calling 工具", map[string]interface{}{"tools": toolNames}) + openaiMsgs = append([]openaiMessage{{Role: "system", Content: "可用工具使用规则:当用户询问本月、今天、本周、下周等相对日期的日程时,先调用 time 获取明确 start_date/end_date,再调用 ops_ai_assistant_schedule_query 查询日程。不要臆造工具结果中不存在的日程。"}}, openaiMsgs...) + var toolExecuted bool + openaiMsgs, toolExecuted, err = runOpenAIToolLoop(ctx.Request.Context(), profile, openaiMsgs, functionTools, currentUser, tracker, emitTrace) + if err != nil { + emitTrace("model", "tool_call", "error", "工具调用失败,将继续普通回答", map[string]interface{}{"error": err.Error()}) + } else if toolExecuted { + emitTrace("model", "tool_call", "success", "工具调用完成,准备生成最终回答", nil) + } + } apiReq := openaiChatRequest{ Model: profile.Model, Messages: openaiMsgs, @@ -313,11 +336,6 @@ func handleChat(ctx *gin.Context) { Temperature: 0.7, } - // Add system prompt if configured - if profile.SystemPrompt != "" { - apiReq.Messages = append([]openaiMessage{{Role: "system", Content: profile.SystemPrompt}}, apiReq.Messages...) - } - trimmedMessages, trimStats := trimOpenAIMessagesToContextWindow(apiReq.Messages, profile.ContextWindowTokens) apiReq.Messages = trimmedMessages if trimStats.RemovedMessages > 0 { @@ -413,6 +431,39 @@ func handleChat(ctx *gin.Context) { flusher.Flush() } +func callOpenAIChat(ctx context.Context, cfg models.ConfigsAIChatOpenAI_, req openaiChatRequest) (*openaiChatResponse, error) { + bodyBytes, err := json.Marshal(req) + if err != nil { + return nil, 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 nil, fmt.Errorf("创建请求失败: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+cfg.ApiKey) + + client := &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("连接上游服务失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("上游返回 %d: %s", resp.StatusCode, string(body)) + } + + var result openaiChatResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + return &result, nil +} + func streamOpenAI(ctx context.Context, cfg models.ConfigsAIChatOpenAI_, req openaiChatRequest, onData func(openaiStreamChunk)) error { bodyBytes, err := json.Marshal(req) if err != nil { @@ -836,6 +887,204 @@ func buildToolConfigs(configs []models.ConfigsAIChatTool_) []agents.ToolConfig { return result } +func buildFunctionTools(configs []agents.ToolConfig) []openaiTool { + tools := make([]openaiTool, 0) + for _, config := range configs { + if !config.Enabled { + continue + } + switch strings.ToLower(strings.TrimSpace(config.Name)) { + case "time": + tools = append(tools, openaiTool{ + Type: "function", + Function: openaiFunctionDefinition{ + Name: "time", + Description: "解析当前时间、相对日期和日期范围。遇到本月、今天、本周等相对日期时先调用本工具获得明确日期。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "range": map[string]interface{}{ + "type": "string", + "enum": []string{"today", "yesterday", "tomorrow", "this_week", "last_week", "next_week", "this_month", "last_month", "next_month", "this_year", "custom"}, + "description": "要解析的日期范围。", + }, + "timezone": map[string]interface{}{"type": "string", "description": "可选时区,例如 Asia/Shanghai。"}, + "start_date": map[string]interface{}{"type": "string", "description": "custom 范围开始日期,格式 YYYY-MM-DD。"}, + "end_date": map[string]interface{}{"type": "string", "description": "custom 范围结束日期,格式 YYYY-MM-DD。"}, + }, + "required": []string{"range"}, + }, + }, + }) + case "ops_ai_assistant_schedule_query", "ops_ai_assistant": + tools = append(tools, openaiTool{ + Type: "function", + Function: openaiFunctionDefinition{ + Name: "ops_ai_assistant_schedule_query", + Description: "按明确日期范围查询当前用户可见的 OPS 日历/日程。相对日期需先调用 time 获取 start_date/end_date。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "start_date": map[string]interface{}{"type": "string", "description": "开始日期,格式 YYYY-MM-DD。"}, + "end_date": map[string]interface{}{"type": "string", "description": "结束日期,格式 YYYY-MM-DD。"}, + "calendar_id": map[string]interface{}{"type": "integer", "description": "可选日历 ID;不传则查询全部可见日程。"}, + "limit": map[string]interface{}{"type": "integer", "description": "可选返回上限,默认 100,最大 200。"}, + }, + "required": []string{"start_date", "end_date"}, + }, + }, + }) + } + } + return tools +} + +func parseJSONTraceValue(raw string) interface{} { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + var parsed interface{} + if err := json.Unmarshal([]byte(value), &parsed); err != nil { + return value + } + return parsed +} + +func runOpenAIToolLoop(ctx context.Context, profile models.ConfigsAIChatOpenAI_, messages []openaiMessage, tools []openaiTool, currentUser *TabUser, tracker *tokenUsageTracker, trace agents.TraceFunc) ([]openaiMessage, bool, error) { + toolExecuted := false + for round := 0; round < 5; round++ { + if trace != nil { + trace("model", "tool_call", "running", "正在请求模型决定是否调用工具", map[string]interface{}{"round": round + 1}) + } + req := openaiChatRequest{ + Model: profile.Model, + Messages: messages, + Stream: false, + MaxTokens: profile.MaxTokens, + Temperature: 0.1, + Tools: tools, + ToolChoice: "auto", + } + resp, err := callOpenAIChat(ctx, profile, req) + if err != nil { + return messages, toolExecuted, err + } + responseText := "" + if len(resp.Choices) == 0 { + return messages, toolExecuted, nil + } + message := resp.Choices[0].Message + responseText = message.Content + tracker.addToolUsage(resp.Usage, estimateOpenAIMessagesTokens(messages), estimateTokenCount(responseText)) + if len(message.ToolCalls) == 0 { + return messages, toolExecuted, nil + } + + toolExecuted = true + messages = append(messages, openaiMessage{Role: "assistant", Content: message.Content, ToolCalls: message.ToolCalls}) + for _, toolCall := range message.ToolCalls { + toolName := strings.TrimSpace(toolCall.Function.Name) + parsedArgs := parseJSONTraceValue(toolCall.Function.Arguments) + if trace != nil { + trace(toolName, "call", "running", "模型调用工具:"+toolName, map[string]interface{}{ + "tool": toolName, + "arguments": parsedArgs, + }) + } + resultJSON, err := executeAIFunctionTool(ctx, toolName, []byte(toolCall.Function.Arguments), currentUser) + status := "success" + if err != nil { + status = "error" + resultJSON, _ = json.Marshal(map[string]interface{}{"ok": false, "error": err.Error()}) + } + if trace != nil { + data := map[string]interface{}{ + "tool": toolName, + "result": parseJSONTraceValue(string(resultJSON)), + } + if len(resultJSON) > 1200 { + data["result"] = string(resultJSON[:1200]) + "..." + data["truncated"] = true + } + trace(toolName, "execute", status, "工具执行完成:"+toolName, data) + } + messages = append(messages, openaiMessage{Role: "tool", ToolCallID: toolCall.ID, Name: toolName, Content: string(resultJSON)}) + } + } + return messages, toolExecuted, fmt.Errorf("工具调用超过最大轮数") +} + +type scheduleQueryArgs struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + CalendarID uint `json:"calendar_id,omitempty"` + Limit int `json:"limit,omitempty"` +} + +func executeAIFunctionTool(ctx context.Context, name string, rawArgs []byte, currentUser *TabUser) ([]byte, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + switch name { + case "time": + var args agents.TimeRangeArgs + if len(rawArgs) > 0 { + if err := json.Unmarshal(rawArgs, &args); err != nil { + return nil, err + } + } + result, err := agents.ResolveTimeRange(args, time.Now()) + if err != nil { + return nil, err + } + return json.Marshal(result) + case "ops_ai_assistant_schedule_query", "ops_ai_assistant": + var args scheduleQueryArgs + if err := json.Unmarshal(rawArgs, &args); err != nil { + return nil, err + } + startDate, err := time.Parse("2006-01-02", args.StartDate) + if err != nil { + return nil, fmt.Errorf("invalid start_date: %w", err) + } + endDate, err := time.Parse("2006-01-02", args.EndDate) + if err != nil { + return nil, fmt.Errorf("invalid end_date: %w", err) + } + limit := args.Limit + if limit <= 0 { + limit = 100 + } + if limit > 200 { + limit = 200 + } + events, err := QueryCalendarSchedulesForAI(CalendarScheduleQuery{ + CalendarID: args.CalendarID, + StartDate: startDate, + EndDate: endDate, + User: currentUser, + Limit: limit, + }) + if err != nil { + return nil, err + } + return json.Marshal(map[string]interface{}{ + "ok": true, + "start_date": args.StartDate, + "end_date": args.EndDate, + "count": len(events), + "limit": limit, + "events": events, + }) + default: + return nil, fmt.Errorf("unknown tool: %s", name) + } +} + func selectOpenAIProfile(cfg models.ConfigsAIChat_, name string) (models.ConfigsAIChatOpenAI_, bool) { if name != "" { for _, p := range cfg.OpenAI { diff --git a/backend/my_work/routers/apiAIChatConfig.go b/backend/my_work/routers/apiAIChatConfig.go index a453df3..fe28310 100644 --- a/backend/my_work/routers/apiAIChatConfig.go +++ b/backend/my_work/routers/apiAIChatConfig.go @@ -98,11 +98,35 @@ func ApiAIChatInit() { if err := seedAIChatConfigFromYAMLIfEmpty(); err != nil { panic(err) } + if err := ensureBuiltinAIChatTools(); err != nil { + panic(err) + } if err := RefreshAIChatConfigCache(); err != nil { panic(err) } } +func ensureBuiltinAIChatTools() error { + builtins := []TabAIChatTool{ + {Name: "time", Enabled: true, Description: "解析当前时间、相对日期和日期范围。", SortOrder: 0}, + {Name: "ops_ai_assistant_schedule_query", Enabled: true, Description: "按日期范围查询当前用户可见的 OPS 日历/日程。", SortOrder: 10}, + } + for _, builtin := range builtins { + var existing TabAIChatTool + err := models.DB.Where("name = ?", builtin.Name).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + if err := models.DB.Create(&builtin).Error; err != nil { + return err + } + continue + } + if err != nil { + return err + } + } + return nil +} + func seedAIChatConfigFromYAMLIfEmpty() error { var settingCount int64 var profileCount int64 @@ -178,7 +202,10 @@ func seedAIChatConfigFromYAMLIfEmpty() error { tools := toolRouter.Tools if len(tools) == 0 { - tools = []models.ConfigsAIChatTool_{{Name: "time", Enabled: true, Description: "提供当前日期、时间和相对日期换算。"}} + tools = []models.ConfigsAIChatTool_{ + {Name: "time", Enabled: true, Description: "解析当前时间、相对日期和日期范围。"}, + {Name: "ops_ai_assistant_schedule_query", Enabled: true, Description: "按日期范围查询当前用户可见的 OPS 日历/日程。"}, + } } for i, tool := range tools { if tool.Name == "" { diff --git a/backend/my_work/routers/apiCalendar.go b/backend/my_work/routers/apiCalendar.go index d63a822..766562f 100644 --- a/backend/my_work/routers/apiCalendar.go +++ b/backend/my_work/routers/apiCalendar.go @@ -120,6 +120,113 @@ var ( calendarAdmins []uint ) +type CalendarScheduleQuery struct { + CalendarID uint + StartDate time.Time + EndDate time.Time + User *TabUser + Limit int +} + +type CalendarScheduleEvent struct { + ID uint `json:"id"` + CalendarID uint `json:"calendar_id"` + UserID uint `json:"user_id"` + Title string `json:"title"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + ScheduleType string `json:"schedule_type"` + IsPublic bool `json:"is_public"` + Remark string `json:"remark,omitempty"` + CanEdit bool `json:"canEdit"` +} + +func QueryCalendarSchedulesForAI(query CalendarScheduleQuery) ([]CalendarScheduleEvent, error) { + startDate := dateOnly(query.StartDate) + endDate := dateOnly(query.EndDate) + if endDate.Before(startDate) { + return nil, nil + } + + currentUserID := uint(0) + if query.User != nil { + currentUserID = query.User.ID + } + + db := models.DB.Where("start_date <= ? AND end_date >= ? AND deleted_at IS NULL", &endDate, &startDate) + if query.CalendarID > 0 { + db = db.Where("calendar_id = ? OR is_public = ?", query.CalendarID, true) + } else if currentUserID > 0 { + db = db.Where("is_public = ? OR calendar_id IN (?)", true, models.DB.Model(&TabCalendar{}).Select("id").Where("deleted_at IS NULL AND (is_public = ? OR user_id = ?)", true, currentUserID)) + } else { + db = db.Where("is_public = ? OR calendar_id IN (?)", true, models.DB.Model(&TabCalendar{}).Select("id").Where("deleted_at IS NULL AND is_public = ?", true)) + } + + if query.Limit > 0 { + db = db.Limit(query.Limit) + } + + var events []TabCalendarEvent + if err := db.Order("start_date asc, end_date asc, id asc").Find(&events).Error; err != nil { + return nil, err + } + + calendarIDs := make([]uint, 0) + calendarIDSet := map[uint]bool{} + if query.CalendarID > 0 { + calendarIDs = append(calendarIDs, query.CalendarID) + calendarIDSet[query.CalendarID] = true + } + for _, event := range events { + if !calendarIDSet[event.CalendarID] { + calendarIDs = append(calendarIDs, event.CalendarID) + calendarIDSet[event.CalendarID] = true + } + } + + calendarCreators := map[uint]uint{} + if len(calendarIDs) > 0 { + var calendars []TabCalendar + models.DB.Where("id IN ?", calendarIDs).Find(&calendars) + for _, calendar := range calendars { + calendarCreators[calendar.ID] = calendar.UserID + } + } + + result := make([]CalendarScheduleEvent, 0, len(events)) + for _, event := range events { + canEdit := false + if currentUserID > 0 { + calendarCreatorID := calendarCreators[event.CalendarID] + canEdit = event.UserID == currentUserID || calendarCreatorID == currentUserID || slices.Contains(calendarAdmins, currentUserID) + } + result = append(result, CalendarScheduleEvent{ + ID: event.ID, + CalendarID: event.CalendarID, + UserID: event.UserID, + Title: event.Title, + StartDate: formatDatePtr(event.StartDate), + EndDate: formatDatePtr(event.EndDate), + ScheduleType: event.ScheduleType, + IsPublic: event.IsPublic, + Remark: event.Remark, + CanEdit: canEdit, + }) + } + return result, nil +} + +func dateOnly(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +func formatDatePtr(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("2006-01-02") +} + // CalendarUpdateAdminsCash func CalendarUpdateAdminsCash() { calendarAdmins = nil @@ -440,52 +547,50 @@ func ApiCalendar(r *gin.RouterGroup) { startStr, _ := data["start"].(string) endStr, _ := data["end"].(string) - startDate, _ := time.Parse("2006-01-02", startStr) - endDate, _ := time.Parse("2006-01-02", endStr) + startDate, err := time.Parse("2006-01-02", startStr) + if err != nil { + ReturnJson(ctx, "jsonErr", nil) + return + } + endDate, err := time.Parse("2006-01-02", endStr) + if err != nil { + ReturnJson(ctx, "jsonErr", nil) + return + } - // 查询:当前日历的事件 + 所有公共日程 - var events []TabCalendarEvent - models.DB.Where( - "(calendar_id = ? OR is_public = ?) AND start_date <= ? AND end_date >= ? AND deleted_at IS NULL", - calendarID, true, &endDate, &startDate, - ).Find(&events) - - // 判断是否已登录 - var currentUserID uint - isLogin := false + var currentUser *TabUser if cookieval != "" { - user, err := AuthenticationAuthorityFromCookie(cookieval) - if err == nil { - isLogin = true - currentUserID = user.ID + if user, err := AuthenticationAuthorityFromCookie(cookieval); err == nil { + currentUser = user } } - // 查询日历创建者(用于判断权限) - var calendarCreatorID uint - var calendar TabCalendar - if models.DB.Where("id = ?", calendarID).First(&calendar).Error == nil { - calendarCreatorID = calendar.UserID + events, err := QueryCalendarSchedulesForAI(CalendarScheduleQuery{ + CalendarID: calendarID, + StartDate: startDate, + EndDate: endDate, + User: currentUser, + }) + if err != nil { + ReturnJson(ctx, "apiErr", nil) + return } - var relist []map[string]interface{} + relist := make([]map[string]interface{}, 0, len(events)) for _, event := range events { - eventMap, _ := json.Marshal(event) - var item map[string]interface{} - json.Unmarshal(eventMap, &item) - - // 可编辑条件:事件创建者 或 日历创建者 或 日历管理员 - canEdit := false - if isLogin { - if event.UserID == currentUserID || calendarCreatorID == currentUserID || slices.Contains(calendarAdmins, currentUserID) { - canEdit = true - } - } - item["canEdit"] = canEdit - relist = append(relist, item) + relist = append(relist, map[string]interface{}{ + "ID": event.ID, + "CalendarID": event.CalendarID, + "UserID": event.UserID, + "Title": event.Title, + "StartDate": event.StartDate, + "EndDate": event.EndDate, + "ScheduleType": event.ScheduleType, + "IsPublic": event.IsPublic, + "Remark": event.Remark, + "canEdit": event.CanEdit, + }) } - //fmt.Println(calendarAdmins) - //fmt.Println(calendarUserGroup) ReturnJson(ctx, "apiOK", gin.H{"list": relist}) }) diff --git a/frontend/ops_vue_js/src/views/aichat/AiChatView.vue b/frontend/ops_vue_js/src/views/aichat/AiChatView.vue index f7e9d20..8c0e7cd 100644 --- a/frontend/ops_vue_js/src/views/aichat/AiChatView.vue +++ b/frontend/ops_vue_js/src/views/aichat/AiChatView.vue @@ -414,8 +414,18 @@ function messageStats(message, index) { function formatTraceData(data) { if (!data) return [] const parts = [] + const stringify = (value) => { + if (typeof value === 'string') return value + try { + return JSON.stringify(value, null, 2) + } catch (e) { + return String(value) + } + } if (data.database) parts.push(`${t('aichat.trace_database')}: ${data.database}`) if (data.sql) parts.push(data.sql) + if (data.tool) parts.push(`tool: ${data.tool}`) + if (typeof data.round === 'number') parts.push(`round: ${data.round}`) if (typeof data.rows === 'number') parts.push(`${t('aichat.trace_rows')}: ${data.rows}`) if (typeof data.columns === 'number') parts.push(`${t('aichat.trace_columns')}: ${data.columns}`) if (typeof data.count === 'number') parts.push(`${t('aichat.trace_count')}: ${data.count}`) @@ -423,6 +433,11 @@ function formatTraceData(data) { if (Array.isArray(data.selections) && data.selections.length) { parts.push(data.selections.map((item) => `${item.name}: ${item.reason || '-'}`).join('\n')) } + if (data.arguments !== undefined) parts.push(`arguments:\n${stringify(data.arguments)}`) + if (data.result !== undefined) parts.push(`result:\n${stringify(data.result)}`) + if (data.start_date) parts.push(`start_date: ${data.start_date}`) + if (data.end_date) parts.push(`end_date: ${data.end_date}`) + if (data.limit) parts.push(`limit: ${data.limit}`) if (data.reason) parts.push(`${t('aichat.trace_reason')}: ${data.reason}`) if (data.error) parts.push(`${t('aichat.trace_error')}: ${data.error}`) if (data.truncated) parts.push(t('aichat.trace_truncated'))