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