修改工具调用机制
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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 日历/日程。"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user