修改工具调用机制

This commit is contained in:
2026-06-10 18:54:17 +08:00
parent a838a812a0
commit 04485b6b0e
6 changed files with 589 additions and 78 deletions
+114
View File
@@ -9,6 +9,25 @@ import (
type TimeTool struct{} 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() { func init() {
Register(TimeTool{}) Register(TimeTool{})
} }
@@ -56,6 +75,101 @@ func (TimeTool) Enrich(ctx context.Context, messages []ChatMessage, config ToolC
return enriched, nil 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 { func buildTimeContext(now time.Time) string {
todayStart := dateStart(now) todayStart := dateStart(now)
weekStart := todayStart.AddDate(0, 0, -int((int(todayStart.Weekday())+6)%7)) weekStart := todayStart.AddDate(0, 0, -int((int(todayStart.Weekday())+6)%7))
+2 -1
View File
@@ -105,7 +105,8 @@ func ConfigAllInit() error {
Timeout: 30, Timeout: 30,
MaxTokens: 512, MaxTokens: 512,
Tools: []ConfigsAIChatTool_{ Tools: []ConfigsAIChatTool_{
{Name: "time", Enabled: true, Description: "提供当前日期、时间相对日期换算。"}, {Name: "time", Enabled: true, Description: "解析当前时间相对日期和日期范围。"},
{Name: "ops_ai_assistant_schedule_query", Enabled: true, Description: "按日期范围查询当前用户可见的 OPS 日历/日程。"},
}, },
}, },
} }
+288 -39
View File
@@ -75,11 +75,38 @@ type openaiChatRequest struct {
Stream bool `json:"stream"` Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens,omitempty"` MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"` Temperature float64 `json:"temperature,omitempty"`
Tools []openaiTool `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
} }
type openaiMessage struct { type openaiMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content any `json:"content"` 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 { type openaiContentPart struct {
@@ -94,8 +121,9 @@ type openaiImageURL struct {
} }
type openaiResponseMessage struct { type openaiResponseMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
} }
// openaiStreamChunk is one SSE data line from the upstream // openaiStreamChunk is one SSE data line from the upstream
@@ -142,11 +170,12 @@ type openaiChoice struct {
} }
type openaiDelta struct { type openaiDelta struct {
Role string `json:"role,omitempty"` Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"`
Reasoning string `json:"reasoning,omitempty"` Reasoning string `json:"reasoning,omitempty"`
Thinking string `json:"thinking,omitempty"` Thinking string `json:"thinking,omitempty"`
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
} }
type openaiUsage struct { type openaiUsage struct {
@@ -235,7 +264,6 @@ func handleChat(ctx *gin.Context) {
sendSSEError(ctx, "AI 聊天未配置,请在后台配置 API Key 和模型") sendSSEError(ctx, "AI 聊天未配置,请在后台配置 API Key 和模型")
return return
} }
toolRouterProfile, hasToolRouterProfile := selectOpenAIProfile(cfg, cfg.ToolRouter.OpenAIName)
chatMsgs := convertToChatMessages(req.Messages) chatMsgs := convertToChatMessages(req.Messages)
// Set up SSE headers before routing/tools so progress can stream immediately. // Set up SSE headers before routing/tools so progress can stream immediately.
@@ -272,32 +300,8 @@ func handleChat(ctx *gin.Context) {
toolConfigs := []agents.ToolConfig{} toolConfigs := []agents.ToolConfig{}
if cfg.ToolRouter.Enabled { if cfg.ToolRouter.Enabled {
toolConfigs = buildToolConfigs(cfg.ToolRouter.Tools) 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 // Build OpenAI-compatible request
openaiMsgs, err := convertToOpenAIMessages(chatMsgs) openaiMsgs, err := convertToOpenAIMessages(chatMsgs)
if err != nil { if err != nil {
@@ -305,6 +309,25 @@ func handleChat(ctx *gin.Context) {
sendSSEDone(ctx, flusher) sendSSEDone(ctx, flusher)
return 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{ apiReq := openaiChatRequest{
Model: profile.Model, Model: profile.Model,
Messages: openaiMsgs, Messages: openaiMsgs,
@@ -313,11 +336,6 @@ func handleChat(ctx *gin.Context) {
Temperature: 0.7, 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) trimmedMessages, trimStats := trimOpenAIMessagesToContextWindow(apiReq.Messages, profile.ContextWindowTokens)
apiReq.Messages = trimmedMessages apiReq.Messages = trimmedMessages
if trimStats.RemovedMessages > 0 { if trimStats.RemovedMessages > 0 {
@@ -413,6 +431,39 @@ func handleChat(ctx *gin.Context) {
flusher.Flush() 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 { func streamOpenAI(ctx context.Context, cfg models.ConfigsAIChatOpenAI_, req openaiChatRequest, onData func(openaiStreamChunk)) error {
bodyBytes, err := json.Marshal(req) bodyBytes, err := json.Marshal(req)
if err != nil { if err != nil {
@@ -836,6 +887,204 @@ func buildToolConfigs(configs []models.ConfigsAIChatTool_) []agents.ToolConfig {
return result 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) { func selectOpenAIProfile(cfg models.ConfigsAIChat_, name string) (models.ConfigsAIChatOpenAI_, bool) {
if name != "" { if name != "" {
for _, p := range cfg.OpenAI { for _, p := range cfg.OpenAI {
+28 -1
View File
@@ -98,11 +98,35 @@ func ApiAIChatInit() {
if err := seedAIChatConfigFromYAMLIfEmpty(); err != nil { if err := seedAIChatConfigFromYAMLIfEmpty(); err != nil {
panic(err) panic(err)
} }
if err := ensureBuiltinAIChatTools(); err != nil {
panic(err)
}
if err := RefreshAIChatConfigCache(); err != nil { if err := RefreshAIChatConfigCache(); err != nil {
panic(err) 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 { func seedAIChatConfigFromYAMLIfEmpty() error {
var settingCount int64 var settingCount int64
var profileCount int64 var profileCount int64
@@ -178,7 +202,10 @@ func seedAIChatConfigFromYAMLIfEmpty() error {
tools := toolRouter.Tools tools := toolRouter.Tools
if len(tools) == 0 { 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 { for i, tool := range tools {
if tool.Name == "" { if tool.Name == "" {
+142 -37
View File
@@ -120,6 +120,113 @@ var (
calendarAdmins []uint 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 // CalendarUpdateAdminsCash
func CalendarUpdateAdminsCash() { func CalendarUpdateAdminsCash() {
calendarAdmins = nil calendarAdmins = nil
@@ -440,52 +547,50 @@ func ApiCalendar(r *gin.RouterGroup) {
startStr, _ := data["start"].(string) startStr, _ := data["start"].(string)
endStr, _ := data["end"].(string) endStr, _ := data["end"].(string)
startDate, _ := time.Parse("2006-01-02", startStr) startDate, err := time.Parse("2006-01-02", startStr)
endDate, _ := time.Parse("2006-01-02", endStr) 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 currentUser *TabUser
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
if cookieval != "" { if cookieval != "" {
user, err := AuthenticationAuthorityFromCookie(cookieval) if user, err := AuthenticationAuthorityFromCookie(cookieval); err == nil {
if err == nil { currentUser = user
isLogin = true
currentUserID = user.ID
} }
} }
// 查询日历创建者(用于判断权限) events, err := QueryCalendarSchedulesForAI(CalendarScheduleQuery{
var calendarCreatorID uint CalendarID: calendarID,
var calendar TabCalendar StartDate: startDate,
if models.DB.Where("id = ?", calendarID).First(&calendar).Error == nil { EndDate: endDate,
calendarCreatorID = calendar.UserID 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 { for _, event := range events {
eventMap, _ := json.Marshal(event) relist = append(relist, map[string]interface{}{
var item map[string]interface{} "ID": event.ID,
json.Unmarshal(eventMap, &item) "CalendarID": event.CalendarID,
"UserID": event.UserID,
// 可编辑条件:事件创建者 或 日历创建者 或 日历管理员 "Title": event.Title,
canEdit := false "StartDate": event.StartDate,
if isLogin { "EndDate": event.EndDate,
if event.UserID == currentUserID || calendarCreatorID == currentUserID || slices.Contains(calendarAdmins, currentUserID) { "ScheduleType": event.ScheduleType,
canEdit = true "IsPublic": event.IsPublic,
} "Remark": event.Remark,
} "canEdit": event.CanEdit,
item["canEdit"] = canEdit })
relist = append(relist, item)
} }
//fmt.Println(calendarAdmins)
//fmt.Println(calendarUserGroup)
ReturnJson(ctx, "apiOK", gin.H{"list": relist}) ReturnJson(ctx, "apiOK", gin.H{"list": relist})
}) })
@@ -414,8 +414,18 @@ function messageStats(message, index) {
function formatTraceData(data) { function formatTraceData(data) {
if (!data) return [] if (!data) return []
const parts = [] 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.database) parts.push(`${t('aichat.trace_database')}: ${data.database}`)
if (data.sql) parts.push(data.sql) 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.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.columns === 'number') parts.push(`${t('aichat.trace_columns')}: ${data.columns}`)
if (typeof data.count === 'number') parts.push(`${t('aichat.trace_count')}: ${data.count}`) 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) { if (Array.isArray(data.selections) && data.selections.length) {
parts.push(data.selections.map((item) => `${item.name}: ${item.reason || '-'}`).join('\n')) 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.reason) parts.push(`${t('aichat.trace_reason')}: ${data.reason}`)
if (data.error) parts.push(`${t('aichat.trace_error')}: ${data.error}`) if (data.error) parts.push(`${t('aichat.trace_error')}: ${data.error}`)
if (data.truncated) parts.push(t('aichat.trace_truncated')) if (data.truncated) parts.push(t('aichat.trace_truncated'))