ai调试ok
This commit is contained in:
@@ -22,8 +22,9 @@ type Tool interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var registry []Tool
|
var registry []Tool
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"ops/agents"
|
"ops/agents"
|
||||||
"ops/models"
|
"ops/models"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -30,9 +33,23 @@ type sseEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tokenUsageStats struct {
|
type tokenUsageStats struct {
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
TotalTokens int `json:"total_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
|
// chatRequestFromFrontend is the expected POST body
|
||||||
@@ -42,8 +59,10 @@ type chatRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type chatMessage struct {
|
type chatMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
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
|
// openaiChatRequest is the request sent to the upstream OpenAI-compatible API
|
||||||
@@ -56,6 +75,22 @@ type openaiChatRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type openaiMessage 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"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
@@ -76,17 +111,27 @@ type openaiChatResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type openaiResponseChoice struct {
|
type openaiResponseChoice struct {
|
||||||
Message openaiMessage `json:"message"`
|
Message openaiResponseMessage `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type toolRouteResponse struct {
|
type toolSelection struct {
|
||||||
Tools []struct {
|
Name string `json:"name"`
|
||||||
Name string `json:"name"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
} `json:"tools"`
|
|
||||||
Reason string `json:"reason"`
|
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 {
|
type openaiChoice struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Delta openaiDelta `json:"delta"`
|
Delta openaiDelta `json:"delta"`
|
||||||
@@ -94,8 +139,11 @@ 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"`
|
||||||
|
Reasoning string `json:"reasoning,omitempty"`
|
||||||
|
Thinking string `json:"thinking,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type openaiUsage struct {
|
type openaiUsage struct {
|
||||||
@@ -171,41 +219,60 @@ func handleChat(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
toolRouterProfile, hasToolRouterProfile := selectOpenAIProfile(cfg, cfg.ToolRouter.OpenAIName)
|
toolRouterProfile, hasToolRouterProfile := selectOpenAIProfile(cfg, cfg.ToolRouter.OpenAIName)
|
||||||
|
|
||||||
// Convert to agent messages and enrich with tools
|
|
||||||
chatMsgs := convertToChatMessages(req.Messages)
|
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{}
|
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 != "" {
|
if hasToolRouterProfile && toolRouterProfile.Model != "" && toolRouterProfile.ApiKey != "" {
|
||||||
selected, err := routeTools(ctx.Request.Context(), toolRouterProfile, cfg.ToolRouter, chatMsgs)
|
emitTrace("tool_router", "route", "running", "正在进行工具路由", nil)
|
||||||
if err == nil && selected != nil {
|
routeResult, routeErr := routeTools(ctx.Request.Context(), toolRouterProfile, cfg.ToolRouter, chatMsgs)
|
||||||
toolConfigs = filterToolConfigs(toolConfigs, selected)
|
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)
|
// Enrich messages with tools (pre-process)
|
||||||
chatMsgs = agents.EnrichMessages(ctx.Request.Context(), chatMsgs, toolConfigs, func(tool, stage, status, message string, data map[string]interface{}) {
|
chatMsgs = agents.EnrichMessages(ctx.Request.Context(), chatMsgs, toolConfigs, emitTrace)
|
||||||
sendSSE(ctx, flusher, sseEvent{
|
|
||||||
Type: "trace",
|
|
||||||
Tool: tool,
|
|
||||||
Stage: stage,
|
|
||||||
Status: status,
|
|
||||||
Message: message,
|
|
||||||
Data: data,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build OpenAI-compatible request
|
// 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{
|
apiReq := openaiChatRequest{
|
||||||
Model: profile.Model,
|
Model: profile.Model,
|
||||||
Messages: openaiMsgs,
|
Messages: openaiMsgs,
|
||||||
@@ -219,24 +286,48 @@ func handleChat(ctx *gin.Context) {
|
|||||||
apiReq.Messages = append([]openaiMessage{{Role: "system", Content: profile.SystemPrompt}}, apiReq.Messages...)
|
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 {
|
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 != "" {
|
if choice.Delta.Content != "" {
|
||||||
sendSSE(ctx, flusher, sseEvent{
|
deltaTokens := estimateTokenCount(choice.Delta.Content)
|
||||||
Type: "delta",
|
completionTokens += deltaTokens
|
||||||
Text: choice.Delta.Content,
|
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 {
|
if chunk.Usage != nil {
|
||||||
sendSSE(ctx, flusher, sseEvent{
|
modelUsageReceived = true
|
||||||
Type: "stats",
|
stats := tracker.setModelUsage(chunk.Usage).snapshot(tokensPerSecond(tracker.completionTokens, streamStarted), peakTokensPerSecond)
|
||||||
Stats: &tokenUsageStats{
|
emitStats(stats)
|
||||||
PromptTokens: chunk.Usage.PromptTokens,
|
|
||||||
CompletionTokens: chunk.Usage.CompletionTokens,
|
|
||||||
TotalTokens: chunk.Usage.TotalTokens,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -245,6 +336,18 @@ func handleChat(ctx *gin.Context) {
|
|||||||
return
|
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)
|
sendSSEDone(ctx, flusher)
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
@@ -345,17 +448,251 @@ func sendSSEError(ctx *gin.Context, message string) {
|
|||||||
func convertToChatMessages(msgs []chatMessage) []agents.ChatMessage {
|
func convertToChatMessages(msgs []chatMessage) []agents.ChatMessage {
|
||||||
result := make([]agents.ChatMessage, 0, len(msgs))
|
result := make([]agents.ChatMessage, 0, len(msgs))
|
||||||
for _, m := range 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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToOpenAIMessages(msgs []agents.ChatMessage) []openaiMessage {
|
func convertToOpenAIMessages(msgs []agents.ChatMessage) ([]openaiMessage, error) {
|
||||||
result := make([]openaiMessage, 0, len(msgs))
|
result := make([]openaiMessage, 0, len(msgs))
|
||||||
for _, m := range 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 {
|
func buildToolConfigs(configs []models.ConfigsAIChatTool_) []agents.ToolConfig {
|
||||||
@@ -390,13 +727,14 @@ func selectOpenAIProfile(cfg models.ConfigsAIChat_, name string) (models.Configs
|
|||||||
return models.ConfigsAIChatOpenAI_{}, false
|
return models.ConfigsAIChatOpenAI_{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router models.ConfigsAIChatToolRouter_, messages []agents.ChatMessage) ([]string, error) {
|
func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router models.ConfigsAIChatToolRouter_, messages []agents.ChatMessage) (*toolRoutingResult, error) {
|
||||||
openaiMsgs := []openaiMessage{}
|
lastUserContent := strings.TrimSpace(agents.LastUserContent(messages))
|
||||||
lastUserContent := agents.LastUserContent(messages)
|
if lastUserContent == "" {
|
||||||
if lastUserContent != "" {
|
return nil, nil
|
||||||
openaiMsgs = append(openaiMsgs, openaiMessage{Role: "user", Content: lastUserContent})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openaiMsgs := []openaiMessage{{Role: "user", Content: lastUserContent}}
|
||||||
|
|
||||||
toolNames := make([]string, 0, len(router.Tools))
|
toolNames := make([]string, 0, len(router.Tools))
|
||||||
for _, t := range router.Tools {
|
for _, t := range router.Tools {
|
||||||
if t.Enabled {
|
if t.Enabled {
|
||||||
@@ -418,7 +756,10 @@ func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router
|
|||||||
Temperature: 0.1,
|
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"
|
url := strings.TrimRight(profile.BaseUrl, "/") + "/chat/completions"
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -433,34 +774,42 @@ func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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
|
var result openaiChatResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(result.Choices) == 0 {
|
if len(result.Choices) == 0 {
|
||||||
return nil, nil
|
return &toolRoutingResult{Messages: openaiMsgs, Usage: result.Usage}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
response := result.Choices[0].Message.Content
|
response := result.Choices[0].Message.Content
|
||||||
toolRouteResponse := extractToolsFromResponse(response)
|
decision := extractToolRoutingDecision(response)
|
||||||
return toolRouteResponse, nil
|
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, "{")
|
start := strings.Index(response, "{")
|
||||||
end := strings.LastIndex(response, "}")
|
end := strings.LastIndex(response, "}")
|
||||||
if start == -1 || end == -1 || end <= start {
|
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 {
|
if err := json.Unmarshal([]byte(response[start:end+1]), &parsed); err != nil {
|
||||||
return nil
|
return toolRoutingDecision{}
|
||||||
}
|
}
|
||||||
tools := make([]string, 0, len(parsed.Tools))
|
return parsed
|
||||||
for _, t := range parsed.Tools {
|
|
||||||
tools = append(tools, t.Name)
|
|
||||||
}
|
|
||||||
return tools
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterToolConfigs(configs []agents.ToolConfig, selected []string) []agents.ToolConfig {
|
func filterToolConfigs(configs []agents.ToolConfig, selected []string) []agents.ToolConfig {
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export async function streamChat(messages, options = {}, handlers = {}) {
|
|||||||
switch (frame.type) {
|
switch (frame.type) {
|
||||||
case 'delta':
|
case 'delta':
|
||||||
handlers.onDelta?.(frame.text || '')
|
handlers.onDelta?.(frame.text || '')
|
||||||
|
if (frame.stats) handlers.onStats?.(frame.stats)
|
||||||
|
break
|
||||||
|
case 'reasoning':
|
||||||
|
handlers.onReasoning?.(frame.text || '', frame)
|
||||||
break
|
break
|
||||||
case 'trace':
|
case 'trace':
|
||||||
handlers.onTrace?.(frame)
|
handlers.onTrace?.(frame)
|
||||||
|
|||||||
@@ -54,7 +54,29 @@
|
|||||||
"default_profile": "Default profile",
|
"default_profile": "Default profile",
|
||||||
"tool_router": "Tool router",
|
"tool_router": "Tool router",
|
||||||
"enter_hint": "Enter to send, Shift + Enter for a new line",
|
"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": {
|
"aiconfig": {
|
||||||
"title": "AI Config",
|
"title": "AI Config",
|
||||||
|
|||||||
@@ -54,7 +54,29 @@
|
|||||||
"default_profile": "默认接口",
|
"default_profile": "默认接口",
|
||||||
"tool_router": "工具路由",
|
"tool_router": "工具路由",
|
||||||
"enter_hint": "Enter 发送,Shift + Enter 换行",
|
"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": {
|
"aiconfig": {
|
||||||
"title": "AI 配置",
|
"title": "AI 配置",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, ref } from 'vue'
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { IconRobot, IconSend, IconTrash, IconUser, IconLoader2 } from '@tabler/icons-vue'
|
import { IconLoader2, IconPhoto, IconRobot, IconSend, IconTrash, IconUser, IconX } from '@tabler/icons-vue'
|
||||||
import { fetchOpenAIProfiles, streamChat } from '@/api/aichat'
|
import { fetchOpenAIProfiles, streamChat } from '@/api/aichat'
|
||||||
import { usePageTitle } from '@/composables/usePageTitle'
|
import { usePageTitle } from '@/composables/usePageTitle'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
@@ -13,13 +13,19 @@ usePageTitle('appname.aichat')
|
|||||||
|
|
||||||
const messages = ref([])
|
const messages = ref([])
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
|
const selectedImage = ref(null)
|
||||||
const pending = ref(false)
|
const pending = ref(false)
|
||||||
const traces = ref([])
|
const traces = ref([])
|
||||||
|
const reasoning = ref('')
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const profiles = ref([])
|
const profiles = ref([])
|
||||||
const activeProfile = ref('')
|
const activeProfile = ref('')
|
||||||
const toolRouter = ref(null)
|
const toolRouter = ref(null)
|
||||||
const messageListRef = ref(null)
|
const messageListRef = ref(null)
|
||||||
|
const fileInputRef = ref(null)
|
||||||
|
|
||||||
|
const MAX_IMAGE_SIZE = 4 * 1024 * 1024
|
||||||
|
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||||
|
|
||||||
onMounted(loadProfiles)
|
onMounted(loadProfiles)
|
||||||
|
|
||||||
@@ -57,18 +63,114 @@ function clearChat() {
|
|||||||
if (pending.value) return
|
if (pending.value) return
|
||||||
messages.value = []
|
messages.value = []
|
||||||
traces.value = []
|
traces.value = []
|
||||||
|
reasoning.value = ''
|
||||||
stats.value = null
|
stats.value = null
|
||||||
|
clearSelectedImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerImagePicker() {
|
||||||
|
if (pending.value) return
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageSelected(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
event.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||||
|
toast.error(t('aichat.image_type_error'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_SIZE) {
|
||||||
|
toast.error(t('aichat.image_size_error'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (loadEvent) => {
|
||||||
|
selectedImage.value = {
|
||||||
|
dataUrl: loadEvent.target?.result || '',
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
toast.error(t('aichat.image_read_error'))
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedImage() {
|
||||||
|
selectedImage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size) {
|
||||||
|
if (size >= 1024 * 1024) {
|
||||||
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
return `${Math.max(1, Math.round(size / 1024))} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageImage(message) {
|
||||||
|
return message.image_url || message.imageURL || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTraceData(data) {
|
||||||
|
if (!data) return []
|
||||||
|
const parts = []
|
||||||
|
if (data.database) parts.push(`${t('aichat.trace_database')}: ${data.database}`)
|
||||||
|
if (data.sql) parts.push(data.sql)
|
||||||
|
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}`)
|
||||||
|
if (Array.isArray(data.tools)) parts.push(`${t('aichat.trace_tools')}: ${data.tools.join(', ') || '-'}`)
|
||||||
|
if (Array.isArray(data.selections) && data.selections.length) {
|
||||||
|
parts.push(data.selections.map((item) => `${item.name}: ${item.reason || '-'}`).join('\n'))
|
||||||
|
}
|
||||||
|
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'))
|
||||||
|
if (data.max_rows) parts.push(`max_rows: ${data.max_rows}`)
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFixed(value) {
|
||||||
|
return typeof value === 'number' ? value.toFixed(1) : '0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenStats(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
const toolTokens = (value.tool_prompt_tokens || 0) + (value.tool_completion_tokens || 0)
|
||||||
|
const parts = [
|
||||||
|
`${t('aichat.tokens_avg_speed')}: ${formatFixed(value.completion_tokens_per_sec)} tokens/sec`,
|
||||||
|
`${t('aichat.tokens_peak_speed')}: ${formatFixed(value.peak_completion_tokens_per_sec)} tokens/sec`,
|
||||||
|
`${t('aichat.tokens_total')}: ${value.total_tokens || 0}`,
|
||||||
|
`${t('aichat.tokens_prompt')}: ${value.prompt_tokens || 0}`,
|
||||||
|
`${t('aichat.tokens_completion')}: ${value.completion_tokens || 0}`,
|
||||||
|
]
|
||||||
|
if (toolTokens) parts.push(`${t('aichat.tokens_tool')}: ${toolTokens}`)
|
||||||
|
if (value.estimated) parts.push(t('aichat.tokens_estimated'))
|
||||||
|
return parts.join(' | ')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
if (!text || pending.value) return
|
const image = selectedImage.value
|
||||||
|
if ((!text && !image) || pending.value) return
|
||||||
|
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
|
clearSelectedImage()
|
||||||
traces.value = []
|
traces.value = []
|
||||||
|
reasoning.value = ''
|
||||||
stats.value = null
|
stats.value = null
|
||||||
|
|
||||||
messages.value.push({ role: 'user', content: text })
|
const userMessage = { role: 'user', content: text }
|
||||||
|
if (image) {
|
||||||
|
userMessage.image_url = image.dataUrl
|
||||||
|
}
|
||||||
|
messages.value.push(userMessage)
|
||||||
const assistantMessage = { role: 'assistant', content: '' }
|
const assistantMessage = { role: 'assistant', content: '' }
|
||||||
messages.value.push(assistantMessage)
|
messages.value.push(assistantMessage)
|
||||||
pending.value = true
|
pending.value = true
|
||||||
@@ -76,7 +178,11 @@ async function sendMessage() {
|
|||||||
|
|
||||||
const history = messages.value
|
const history = messages.value
|
||||||
.filter((message) => message.role === 'user' || message.role === 'assistant')
|
.filter((message) => message.role === 'user' || message.role === 'assistant')
|
||||||
.map((message) => ({ role: message.role, content: message.content }))
|
.map((message) => {
|
||||||
|
const item = { role: message.role, content: message.content || '' }
|
||||||
|
if (message.image_url) item.image_url = message.image_url
|
||||||
|
return item
|
||||||
|
})
|
||||||
.slice(0, -1)
|
.slice(0, -1)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -89,6 +195,10 @@ async function sendMessage() {
|
|||||||
traces.value.push(frame)
|
traces.value.push(frame)
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
},
|
},
|
||||||
|
onReasoning(delta) {
|
||||||
|
reasoning.value += delta
|
||||||
|
scrollToBottom()
|
||||||
|
},
|
||||||
onStats(value) {
|
onStats(value) {
|
||||||
stats.value = value
|
stats.value = value
|
||||||
},
|
},
|
||||||
@@ -174,10 +284,31 @@ async function sendMessage() {
|
|||||||
<div v-if="trace.message" class="mt-1 opacity-90">
|
<div v-if="trace.message" class="mt-1 opacity-90">
|
||||||
{{ trace.message }}
|
{{ trace.message }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="formatTraceData(trace.data).length" class="mt-2 space-y-1 rounded-md border border-blue-100 bg-white/70 px-2 py-1 font-mono text-[11px] leading-5 text-blue-800 dark:border-blue-900/40 dark:bg-dk-card/70 dark:text-blue-100">
|
||||||
|
<div v-for="(line, dataIndex) in formatTraceData(trace.data)" :key="dataIndex" class="whitespace-pre-wrap break-words">
|
||||||
|
{{ line }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="whitespace-pre-wrap break-words">
|
<div v-if="message.role !== 'user' && index === messages.length - 1 && reasoning" class="mb-3 rounded-lg border border-purple-100 bg-purple-50 px-3 py-2 text-xs text-purple-800 dark:border-purple-900/40 dark:bg-purple-900/20 dark:text-purple-100">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ t('aichat.reasoning') }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 whitespace-pre-wrap break-words">
|
||||||
|
{{ reasoning }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-if="messageImage(message)"
|
||||||
|
:src="messageImage(message)"
|
||||||
|
:alt="message.content || t('aichat.attach_image')"
|
||||||
|
class="mb-2 max-h-64 max-w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="message.content || (message.role === 'assistant' && pending)" class="whitespace-pre-wrap break-words">
|
||||||
{{ message.content || (message.role === 'assistant' && pending ? t('aichat.thinking') : '') }}
|
{{ message.content || (message.role === 'assistant' && pending ? t('aichat.thinking') : '') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -187,7 +318,7 @@ async function sendMessage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && stats" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
|
<div v-if="message.role !== 'user' && index === messages.length - 1 && stats" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
{{ t('aichat.tokens') }}: {{ stats.total_tokens || 0 }}
|
{{ formatTokenStats(stats) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -199,7 +330,46 @@ async function sendMessage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 bg-gray-50 p-4 dark:border-dk-muted dark:bg-dk-base">
|
<div class="border-t border-gray-200 bg-gray-50 p-4 dark:border-dk-muted dark:bg-dk-base">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
class="hidden"
|
||||||
|
:disabled="pending"
|
||||||
|
@change="onImageSelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="selectedImage" class="mb-3 flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-2 dark:border-dk-muted dark:bg-dk-card">
|
||||||
|
<img :src="selectedImage.dataUrl" :alt="selectedImage.name" class="h-14 w-14 rounded object-cover" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-sm font-medium text-gray-800 dark:text-dk-text">
|
||||||
|
{{ selectedImage.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ formatFileSize(selectedImage.size) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dk-subtle dark:hover:bg-dk-muted"
|
||||||
|
:title="t('aichat.remove_image')"
|
||||||
|
:disabled="pending"
|
||||||
|
@click="clearSelectedImage"
|
||||||
|
>
|
||||||
|
<IconX :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-end gap-3">
|
<div class="flex items-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-subtle dark:hover:bg-dk-muted"
|
||||||
|
:title="t('aichat.attach_image')"
|
||||||
|
:disabled="pending"
|
||||||
|
@click="triggerImagePicker"
|
||||||
|
>
|
||||||
|
<IconPhoto :size="20" />
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
rows="2"
|
rows="2"
|
||||||
@@ -211,7 +381,7 @@ async function sendMessage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-[52px] items-center gap-2 rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
class="inline-flex h-[52px] items-center gap-2 rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
:disabled="pending || !inputText.trim()"
|
:disabled="pending || (!inputText.trim() && !selectedImage)"
|
||||||
@click="sendMessage"
|
@click="sendMessage"
|
||||||
>
|
>
|
||||||
<IconLoader2 v-if="pending" :size="18" class="animate-spin" />
|
<IconLoader2 v-if="pending" :size="18" class="animate-spin" />
|
||||||
|
|||||||
Reference in New Issue
Block a user