From ffbb6b5125b61fc308ea0b48b583a3f6cfd91a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Wed, 10 Jun 2026 16:36:26 +0800 Subject: [PATCH] =?UTF-8?q?ai=E8=B0=83=E8=AF=95ok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/my_work/agents/agent.go | 5 +- backend/my_work/routers/apiAIChat.go | 491 +++++++++++++++--- frontend/ops_vue_js/src/api/aichat.js | 4 + frontend/ops_vue_js/src/i18n/en.json | 24 +- frontend/ops_vue_js/src/i18n/zh-CN.json | 24 +- .../src/views/aichat/AiChatView.vue | 184 ++++++- 6 files changed, 650 insertions(+), 82 deletions(-) diff --git a/backend/my_work/agents/agent.go b/backend/my_work/agents/agent.go index 89259f2..748ca1a 100644 --- a/backend/my_work/agents/agent.go +++ b/backend/my_work/agents/agent.go @@ -22,8 +22,9 @@ type Tool interface { } type ChatMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content"` + ImageURL string `json:"image_url,omitempty"` } var registry []Tool diff --git a/backend/my_work/routers/apiAIChat.go b/backend/my_work/routers/apiAIChat.go index c42f1a9..c58090a 100644 --- a/backend/my_work/routers/apiAIChat.go +++ b/backend/my_work/routers/apiAIChat.go @@ -4,10 +4,13 @@ import ( "bufio" "bytes" "context" + "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" "ops/agents" "ops/models" "strings" @@ -30,9 +33,23 @@ type sseEvent struct { } type tokenUsageStats struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + ToolPromptTokens int `json:"tool_prompt_tokens"` + ToolCompletionTokens int `json:"tool_completion_tokens"` + TotalTokens int `json:"total_tokens"` + CompletionTokensPerSec float64 `json:"completion_tokens_per_sec"` + PeakCompletionTokensPerSec float64 `json:"peak_completion_tokens_per_sec"` + Estimated bool `json:"estimated"` +} + +const maxImageDataSize = 4 * 1024 * 1024 + +var allowedImageTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/webp": true, + "image/gif": true, } // chatRequestFromFrontend is the expected POST body @@ -42,8 +59,10 @@ type chatRequest struct { } type chatMessage struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content"` + ImageURL string `json:"image_url,omitempty"` + ImageURLAlias string `json:"imageURL,omitempty"` } // openaiChatRequest is the request sent to the upstream OpenAI-compatible API @@ -56,6 +75,22 @@ type openaiChatRequest struct { } type openaiMessage struct { + Role string `json:"role"` + Content any `json:"content"` +} + +type openaiContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *openaiImageURL `json:"image_url,omitempty"` +} + +type openaiImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` +} + +type openaiResponseMessage struct { Role string `json:"role"` Content string `json:"content"` } @@ -76,17 +111,27 @@ type openaiChatResponse struct { } type openaiResponseChoice struct { - Message openaiMessage `json:"message"` + Message openaiResponseMessage `json:"message"` } -type toolRouteResponse struct { - Tools []struct { - Name string `json:"name"` - Reason string `json:"reason"` - } `json:"tools"` +type toolSelection struct { + Name string `json:"name"` Reason string `json:"reason"` } +type toolRoutingDecision struct { + Tools []toolSelection `json:"tools"` + Reason string `json:"reason"` +} + +type toolRoutingResult struct { + Decision toolRoutingDecision + Selected []string + Messages []openaiMessage + Response string + Usage *openaiUsage +} + type openaiChoice struct { Index int `json:"index"` Delta openaiDelta `json:"delta"` @@ -94,8 +139,11 @@ type openaiChoice struct { } type openaiDelta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,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"` } type openaiUsage struct { @@ -171,41 +219,60 @@ func handleChat(ctx *gin.Context) { return } toolRouterProfile, hasToolRouterProfile := selectOpenAIProfile(cfg, cfg.ToolRouter.OpenAIName) - - // Convert to agent messages and enrich with tools chatMsgs := convertToChatMessages(req.Messages) + + // Set up SSE headers before routing/tools so progress can stream immediately. + ctx.Writer.Header().Set("Content-Type", "text/event-stream") + ctx.Writer.Header().Set("Cache-Control", "no-cache") + ctx.Writer.Header().Set("Connection", "keep-alive") + ctx.Writer.Header().Set("X-Accel-Buffering", "no") + ctx.Writer.WriteHeader(http.StatusOK) + flusher, _ := ctx.Writer.(http.Flusher) + tracker := newTokenUsageTracker() + + emitTrace := func(tool, stage, status, message string, data map[string]interface{}) { + sendSSE(ctx, flusher, sseEvent{Type: "trace", Tool: tool, Stage: stage, Status: status, Message: message, Data: data}) + } + emitStats := func(stats tokenUsageStats) { + sendSSE(ctx, flusher, sseEvent{Type: "stats", Stats: &stats}) + } + toolConfigs := []agents.ToolConfig{} if cfg.ToolRouter.Enabled { toolConfigs = buildToolConfigs(cfg.ToolRouter.Tools) if hasToolRouterProfile && toolRouterProfile.Model != "" && toolRouterProfile.ApiKey != "" { - selected, err := routeTools(ctx.Request.Context(), toolRouterProfile, cfg.ToolRouter, chatMsgs) - if err == nil && selected != nil { - toolConfigs = filterToolConfigs(toolConfigs, selected) + 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) } } } - // Set up SSE headers - ctx.Writer.Header().Set("Content-Type", "text/event-stream") - ctx.Writer.Header().Set("Cache-Control", "no-cache") - ctx.Writer.Header().Set("Connection", "keep-alive") - ctx.Writer.WriteHeader(http.StatusOK) - flusher, _ := ctx.Writer.(http.Flusher) - // Enrich messages with tools (pre-process) - chatMsgs = agents.EnrichMessages(ctx.Request.Context(), chatMsgs, toolConfigs, func(tool, stage, status, message string, data map[string]interface{}) { - sendSSE(ctx, flusher, sseEvent{ - Type: "trace", - Tool: tool, - Stage: stage, - Status: status, - Message: message, - Data: data, - }) - }) + chatMsgs = agents.EnrichMessages(ctx.Request.Context(), chatMsgs, toolConfigs, emitTrace) // Build OpenAI-compatible request - openaiMsgs := convertToOpenAIMessages(chatMsgs) + openaiMsgs, err := convertToOpenAIMessages(chatMsgs) + if err != nil { + sendSSE(ctx, flusher, sseEvent{Type: "error", Error: err.Error()}) + sendSSEDone(ctx, flusher) + return + } apiReq := openaiChatRequest{ Model: profile.Model, Messages: openaiMsgs, @@ -219,24 +286,48 @@ func handleChat(ctx *gin.Context) { apiReq.Messages = append([]openaiMessage{{Role: "system", Content: profile.SystemPrompt}}, apiReq.Messages...) } - err := streamOpenAI(ctx.Request.Context(), profile, apiReq, func(chunk openaiStreamChunk) { + modelPromptTokens := estimateOpenAIMessagesTokens(apiReq.Messages) + completionTokens := 0 + modelUsageReceived := false + streamStarted := time.Now() + windowStarted := streamStarted + windowTokens := 0 + peakTokensPerSecond := 0.0 + emitTrace("model", "stream", "running", "正在请求模型回复", nil) + + err = streamOpenAI(ctx.Request.Context(), profile, apiReq, func(chunk openaiStreamChunk) { for _, choice := range chunk.Choices { + reasoningText := choice.Delta.ReasoningContent + if reasoningText == "" { + reasoningText = choice.Delta.Reasoning + } + if reasoningText == "" { + reasoningText = choice.Delta.Thinking + } + if reasoningText != "" { + sendSSE(ctx, flusher, sseEvent{Type: "reasoning", Text: reasoningText}) + } + if choice.Delta.Content != "" { - sendSSE(ctx, flusher, sseEvent{ - Type: "delta", - Text: choice.Delta.Content, - }) + deltaTokens := estimateTokenCount(choice.Delta.Content) + completionTokens += deltaTokens + windowTokens += deltaTokens + elapsedWindow := time.Since(windowStarted).Seconds() + if elapsedWindow >= 1 { + peakTokensPerSecond = maxFloat(peakTokensPerSecond, float64(windowTokens)/elapsedWindow) + windowStarted = time.Now() + windowTokens = 0 + } else if peakTokensPerSecond == 0 && elapsedWindow > 0.25 { + peakTokensPerSecond = maxFloat(peakTokensPerSecond, float64(windowTokens)/elapsedWindow) + } + stats := tracker.setModelEstimate(modelPromptTokens, completionTokens).snapshot(tokensPerSecond(completionTokens, streamStarted), peakTokensPerSecond) + sendSSE(ctx, flusher, sseEvent{Type: "delta", Text: choice.Delta.Content, Stats: &stats}) } } if chunk.Usage != nil { - sendSSE(ctx, flusher, sseEvent{ - Type: "stats", - Stats: &tokenUsageStats{ - PromptTokens: chunk.Usage.PromptTokens, - CompletionTokens: chunk.Usage.CompletionTokens, - TotalTokens: chunk.Usage.TotalTokens, - }, - }) + modelUsageReceived = true + stats := tracker.setModelUsage(chunk.Usage).snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond) + emitStats(stats) } }) if err != nil { @@ -245,6 +336,18 @@ func handleChat(ctx *gin.Context) { return } + if windowTokens > 0 { + elapsedWindow := time.Since(windowStarted).Seconds() + if elapsedWindow > 0 { + peakTokensPerSecond = maxFloat(peakTokensPerSecond, float64(windowTokens)/elapsedWindow) + } + } + emitTrace("model", "stream", "success", "模型回复完成", nil) + if modelUsageReceived { + emitStats(tracker.snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond)) + } else { + emitStats(tracker.setModelEstimate(modelPromptTokens, completionTokens).snapshot(tokensPerSecond(completionTokens, streamStarted), peakTokensPerSecond)) + } sendSSEDone(ctx, flusher) flusher.Flush() } @@ -345,17 +448,251 @@ func sendSSEError(ctx *gin.Context, message string) { func convertToChatMessages(msgs []chatMessage) []agents.ChatMessage { result := make([]agents.ChatMessage, 0, len(msgs)) for _, m := range msgs { - result = append(result, agents.ChatMessage{Role: m.Role, Content: m.Content}) + imageURL := m.ImageURL + if imageURL == "" { + imageURL = m.ImageURLAlias + } + result = append(result, agents.ChatMessage{Role: m.Role, Content: m.Content, ImageURL: imageURL}) } return result } -func convertToOpenAIMessages(msgs []agents.ChatMessage) []openaiMessage { +func convertToOpenAIMessages(msgs []agents.ChatMessage) ([]openaiMessage, error) { result := make([]openaiMessage, 0, len(msgs)) for _, m := range msgs { - result = append(result, openaiMessage{Role: m.Role, Content: m.Content}) + content, err := buildOpenAIContent(m) + if err != nil { + return nil, err + } + result = append(result, openaiMessage{Role: m.Role, Content: content}) } - return result + return result, nil +} + +func buildOpenAIContent(m agents.ChatMessage) (any, error) { + if strings.TrimSpace(m.ImageURL) == "" { + return m.Content, nil + } + + imageURL, err := normalizeImageURL(m.ImageURL) + if err != nil { + return nil, err + } + + parts := []openaiContentPart{ + { + Type: "image_url", + ImageURL: &openaiImageURL{ + URL: imageURL, + Detail: "auto", + }, + }, + } + if m.Content != "" { + parts = append(parts, openaiContentPart{Type: "text", Text: m.Content}) + } + return parts, nil +} + +func normalizeImageURL(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", errors.New("图片地址为空") + } + + if strings.HasPrefix(strings.ToLower(value), "data:") { + return normalizeImageDataURI(value) + } + + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", errors.New("图片地址无效,仅支持 http/https URL 或 base64 data URI") + } + return value, nil +} + +func normalizeImageDataURI(raw string) (string, error) { + commaIndex := strings.Index(raw, ",") + if commaIndex == -1 { + return "", errors.New("图片 data URI 格式无效") + } + + metadata := strings.TrimSpace(raw[len("data:"):commaIndex]) + payload := strings.TrimSpace(raw[commaIndex+1:]) + if metadata == "" || payload == "" { + return "", errors.New("图片 data URI 格式无效") + } + + metadataParts := strings.Split(metadata, ";") + mimeType := strings.ToLower(strings.TrimSpace(metadataParts[0])) + if !allowedImageTypes[mimeType] { + return "", errors.New("图片格式不支持,仅支持 jpeg/png/webp/gif") + } + + hasBase64 := false + for _, part := range metadataParts[1:] { + if strings.EqualFold(strings.TrimSpace(part), "base64") { + hasBase64 = true + break + } + } + if !hasBase64 { + return "", errors.New("图片 data URI 必须使用 base64 编码") + } + + if len(payload) > maxImageDataSize*4/3+16 { + return "", errors.New("图片过大,请选择小于 4MB 的图片") + } + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return "", errors.New("图片 base64 数据无效") + } + if len(decoded) > maxImageDataSize { + return "", errors.New("图片过大,请选择小于 4MB 的图片") + } + + return "data:" + mimeType + ";base64," + payload, nil +} + +type tokenUsageTracker struct { + promptTokens int + completionTokens int + toolPromptTokens int + toolCompletionTokens int + estimated bool +} + +func newTokenUsageTracker() *tokenUsageTracker { + return &tokenUsageTracker{estimated: true} +} + +func (t *tokenUsageTracker) addToolUsage(usage *openaiUsage, estimatedPromptTokens, estimatedCompletionTokens int) { + if usage != nil { + t.toolPromptTokens += usage.PromptTokens + t.toolCompletionTokens += usage.CompletionTokens + return + } + t.toolPromptTokens += estimatedPromptTokens + t.toolCompletionTokens += estimatedCompletionTokens + t.estimated = true +} + +func (t *tokenUsageTracker) setModelEstimate(promptTokens, completionTokens int) *tokenUsageTracker { + t.promptTokens = promptTokens + t.completionTokens = completionTokens + t.estimated = true + return t +} + +func (t *tokenUsageTracker) setModelUsage(usage *openaiUsage) *tokenUsageTracker { + if usage == nil { + return t + } + t.promptTokens = usage.PromptTokens + t.completionTokens = usage.CompletionTokens + return t +} + +func (t *tokenUsageTracker) snapshot(completionTokensPerSec, peakCompletionTokensPerSec float64) tokenUsageStats { + totalTokens := t.promptTokens + t.completionTokens + t.toolPromptTokens + t.toolCompletionTokens + return tokenUsageStats{ + PromptTokens: t.promptTokens, + CompletionTokens: t.completionTokens, + ToolPromptTokens: t.toolPromptTokens, + ToolCompletionTokens: t.toolCompletionTokens, + TotalTokens: totalTokens, + CompletionTokensPerSec: completionTokensPerSec, + PeakCompletionTokensPerSec: peakCompletionTokensPerSec, + Estimated: t.estimated, + } +} + +func estimateOpenAIMessagesTokens(messages []openaiMessage) int { + total := 0 + for _, message := range messages { + total += estimateTokenCount(message.Role) + 4 + total += estimateOpenAIContentTokens(message.Content) + } + return total +} + +func estimateOpenAIContentTokens(content any) int { + switch value := content.(type) { + case string: + return estimateTokenCount(value) + case []openaiContentPart: + total := 0 + for _, part := range value { + switch part.Type { + case "text": + total += estimateTokenCount(part.Text) + case "image_url": + total += 85 + } + } + return total + case []interface{}: + data, err := json.Marshal(value) + if err != nil { + return 0 + } + return estimateTokenCount(string(data)) + default: + data, err := json.Marshal(value) + if err != nil { + return 0 + } + return estimateTokenCount(string(data)) + } +} + +func estimateTokenCount(text string) int { + text = strings.TrimSpace(text) + if text == "" { + return 0 + } + + tokens := 0 + asciiRunes := 0 + flushASCII := func() { + if asciiRunes > 0 { + tokens += (asciiRunes + 3) / 4 + asciiRunes = 0 + } + } + + for _, r := range text { + if r <= 127 { + if r == ' ' || r == '\n' || r == '\t' || r == '\r' { + flushASCII() + continue + } + asciiRunes++ + continue + } + flushASCII() + tokens++ + } + flushASCII() + if tokens == 0 { + return 1 + } + return tokens +} + +func tokensPerSecond(tokens int, start time.Time) float64 { + elapsed := time.Since(start).Seconds() + if tokens <= 0 || elapsed <= 0 { + return 0 + } + return float64(tokens) / elapsed +} + +func maxFloat(a, b float64) float64 { + if b > a { + return b + } + return a } func buildToolConfigs(configs []models.ConfigsAIChatTool_) []agents.ToolConfig { @@ -390,13 +727,14 @@ func selectOpenAIProfile(cfg models.ConfigsAIChat_, name string) (models.Configs return models.ConfigsAIChatOpenAI_{}, false } -func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router models.ConfigsAIChatToolRouter_, messages []agents.ChatMessage) ([]string, error) { - openaiMsgs := []openaiMessage{} - lastUserContent := agents.LastUserContent(messages) - if lastUserContent != "" { - openaiMsgs = append(openaiMsgs, openaiMessage{Role: "user", Content: lastUserContent}) +func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router models.ConfigsAIChatToolRouter_, messages []agents.ChatMessage) (*toolRoutingResult, error) { + lastUserContent := strings.TrimSpace(agents.LastUserContent(messages)) + if lastUserContent == "" { + return nil, nil } + openaiMsgs := []openaiMessage{{Role: "user", Content: lastUserContent}} + toolNames := make([]string, 0, len(router.Tools)) for _, t := range router.Tools { if t.Enabled { @@ -418,7 +756,10 @@ func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router Temperature: 0.1, } - bodyBytes, _ := json.Marshal(req) + bodyBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } url := strings.TrimRight(profile.BaseUrl, "/") + "/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes)) if err != nil { @@ -433,34 +774,42 @@ func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router } 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, err } if len(result.Choices) == 0 { - return nil, nil + return &toolRoutingResult{Messages: openaiMsgs, Usage: result.Usage}, nil } response := result.Choices[0].Message.Content - toolRouteResponse := extractToolsFromResponse(response) - return toolRouteResponse, nil + decision := extractToolRoutingDecision(response) + selected := make([]string, 0, len(decision.Tools)) + for _, t := range decision.Tools { + name := strings.TrimSpace(t.Name) + if name != "" { + selected = append(selected, name) + } + } + return &toolRoutingResult{Decision: decision, Selected: selected, Messages: openaiMsgs, Response: response, Usage: result.Usage}, nil } -func extractToolsFromResponse(response string) []string { +func extractToolRoutingDecision(response string) toolRoutingDecision { start := strings.Index(response, "{") end := strings.LastIndex(response, "}") if start == -1 || end == -1 || end <= start { - return nil + return toolRoutingDecision{} } - var parsed toolRouteResponse + var parsed toolRoutingDecision if err := json.Unmarshal([]byte(response[start:end+1]), &parsed); err != nil { - return nil + return toolRoutingDecision{} } - tools := make([]string, 0, len(parsed.Tools)) - for _, t := range parsed.Tools { - tools = append(tools, t.Name) - } - return tools + return parsed } func filterToolConfigs(configs []agents.ToolConfig, selected []string) []agents.ToolConfig { diff --git a/frontend/ops_vue_js/src/api/aichat.js b/frontend/ops_vue_js/src/api/aichat.js index 565e961..0a319d8 100644 --- a/frontend/ops_vue_js/src/api/aichat.js +++ b/frontend/ops_vue_js/src/api/aichat.js @@ -81,6 +81,10 @@ export async function streamChat(messages, options = {}, handlers = {}) { switch (frame.type) { case 'delta': handlers.onDelta?.(frame.text || '') + if (frame.stats) handlers.onStats?.(frame.stats) + break + case 'reasoning': + handlers.onReasoning?.(frame.text || '', frame) break case 'trace': handlers.onTrace?.(frame) diff --git a/frontend/ops_vue_js/src/i18n/en.json b/frontend/ops_vue_js/src/i18n/en.json index 4c80bd5..fca507d 100644 --- a/frontend/ops_vue_js/src/i18n/en.json +++ b/frontend/ops_vue_js/src/i18n/en.json @@ -54,7 +54,29 @@ "default_profile": "Default profile", "tool_router": "Tool router", "enter_hint": "Enter to send, Shift + Enter for a new line", - "error_prefix": "Request failed: " + "error_prefix": "Request failed: ", + "attach_image": "Attach image", + "remove_image": "Remove image", + "image_type_error": "Unsupported image type. Supported formats: jpeg/png/webp/gif", + "image_size_error": "Image is too large. Please choose an image smaller than 4 MB", + "image_read_error": "Failed to read image. Please try another file", + "reasoning": "Reasoning", + "trace_details": "Call details", + "trace_database": "Database", + "trace_rows": "Rows", + "trace_columns": "Columns", + "trace_count": "Count", + "trace_tools": "Tools", + "trace_reason": "Reason", + "trace_error": "Error", + "trace_truncated": "Result truncated", + "tokens_avg_speed": "Average speed", + "tokens_peak_speed": "Peak speed", + "tokens_total": "Total tokens", + "tokens_prompt": "Input", + "tokens_completion": "Output", + "tokens_tool": "Tools", + "tokens_estimated": "local estimate" }, "aiconfig": { "title": "AI Config", diff --git a/frontend/ops_vue_js/src/i18n/zh-CN.json b/frontend/ops_vue_js/src/i18n/zh-CN.json index 9bb6767..1cb079e 100644 --- a/frontend/ops_vue_js/src/i18n/zh-CN.json +++ b/frontend/ops_vue_js/src/i18n/zh-CN.json @@ -54,7 +54,29 @@ "default_profile": "默认接口", "tool_router": "工具路由", "enter_hint": "Enter 发送,Shift + Enter 换行", - "error_prefix": "请求失败:" + "error_prefix": "请求失败:", + "attach_image": "添加图片", + "remove_image": "移除图片", + "image_type_error": "图片格式不支持,仅支持 jpeg/png/webp/gif", + "image_size_error": "图片过大,请选择小于 4MB 的图片", + "image_read_error": "图片读取失败,请尝试其他文件", + "reasoning": "思考内容", + "trace_details": "调用详情", + "trace_database": "数据库", + "trace_rows": "行数", + "trace_columns": "列数", + "trace_count": "结果数", + "trace_tools": "工具", + "trace_reason": "原因", + "trace_error": "错误", + "trace_truncated": "结果已截断", + "tokens_avg_speed": "平均速度", + "tokens_peak_speed": "峰值速度", + "tokens_total": "总 token", + "tokens_prompt": "输入", + "tokens_completion": "输出", + "tokens_tool": "工具", + "tokens_estimated": "本地估算" }, "aiconfig": { "title": "AI 配置", diff --git a/frontend/ops_vue_js/src/views/aichat/AiChatView.vue b/frontend/ops_vue_js/src/views/aichat/AiChatView.vue index 441489a..a805e6b 100644 --- a/frontend/ops_vue_js/src/views/aichat/AiChatView.vue +++ b/frontend/ops_vue_js/src/views/aichat/AiChatView.vue @@ -1,7 +1,7 @@