mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +08:00
✨ feat(ai): 发布全新 AI Copilot 助手面板与工作区智能打通
- 核心架构:新增独立 AI 会话中枢,集成主流大模型生态(含私有部署中继版)的无感衔接发问 - 智能诊断:打破信息孤岛,大模型可通过关联工作区实时数据表 DDL 和错误栈,充当专属 DBA 排错及代码编写 - 视觉与多模态:支持极简发图读图交互体验,智能补全模型所需的缺省预警 Prompt,并兼容不规范中转端点图文并茂 - UI 与性能:重构聊天浮层挂靠逻辑与渲染阻断,应对长时间巨量问答引发的卡段内存泄漏,会话自动保存归档
This commit is contained in:
@@ -208,6 +208,7 @@ func buildGeneralChatPrompt() string {
|
||||
|
||||
互动守则:
|
||||
- 永远使用专业、具有合作感且充满信心的中文与用户探讨问题。
|
||||
- 当被要求提供任何数据库代码时,需结合相关数据库引擎的最佳实践。如果不清楚当前方言版本,请以标准实现为主基调并好心指出版别差异(如 MySQL 8 窗口函数 等)。`
|
||||
- 当被要求提供任何数据库代码时,需结合相关数据库引擎的最佳实践。如果不清楚当前方言版本,请以标准实现为主基调并好心指出版别差异(如 MySQL 8 窗口函数 等)。
|
||||
- 绝不轻易拒绝:如果用户要求写 SQL 但并未显式挂载任何表的详细 DDL,请尽最大努力根据对话上下文中带入的【纯表名列表】去推测他要查询哪个表。如果实在无法推断,请温柔且专业地向用户解释目前已知的表有哪些,并询问到底想查哪张表。`
|
||||
}
|
||||
|
||||
|
||||
@@ -82,8 +82,42 @@ type anthropicRequest struct {
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
func buildAnthropicMessages(reqMessages []ai.Message) []anthropicMessage {
|
||||
messages := make([]anthropicMessage, 0, len(reqMessages))
|
||||
for _, m := range reqMessages {
|
||||
if len(m.Images) > 0 {
|
||||
var contentParts []map[string]interface{}
|
||||
for _, img := range m.Images {
|
||||
mimeType, rawBase64, err := ParseDataURI(img)
|
||||
if err == nil {
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "image",
|
||||
"source": map[string]interface{}{
|
||||
"type": "base64",
|
||||
"media_type": mimeType,
|
||||
"data": rawBase64,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
text := m.Content
|
||||
if text == "" {
|
||||
text = "请描述和分析这张图片。" // 防止强 System Prompt 下模型仅看到空文本且忽略图片直接回复打招呼
|
||||
}
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": text,
|
||||
})
|
||||
messages = append(messages, anthropicMessage{Role: m.Role, Content: contentParts})
|
||||
} else {
|
||||
messages = append(messages, anthropicMessage{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
@@ -112,10 +146,7 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
|
||||
}
|
||||
|
||||
systemMsg, messages := extractSystemMessage(req.Messages)
|
||||
anthropicMsgs := make([]anthropicMessage, len(messages))
|
||||
for i, m := range messages {
|
||||
anthropicMsgs[i] = anthropicMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
anthropicMsgs := buildAnthropicMessages(messages)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
@@ -167,10 +198,7 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
}
|
||||
|
||||
systemMsg, messages := extractSystemMessage(req.Messages)
|
||||
anthropicMsgs := make([]anthropicMessage, len(messages))
|
||||
for i, m := range messages {
|
||||
anthropicMsgs[i] = anthropicMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
anthropicMsgs := buildAnthropicMessages(messages)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
@@ -253,6 +281,12 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io
|
||||
httpReq.Header.Set("x-api-key", p.config.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
|
||||
|
||||
if strings.Contains(string(jsonBody), `"stream":true`) || strings.Contains(string(jsonBody), `"stream": true`) {
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("Cache-Control", "no-cache")
|
||||
httpReq.Header.Set("Connection", "keep-alive")
|
||||
}
|
||||
|
||||
// 仅官方 API 发 beta 特性头(代理不发,避免触发 Claude Code 验证)
|
||||
isOfficialAPI := p.baseURL == defaultAnthropicBaseURL || strings.Contains(p.baseURL, "anthropic.com")
|
||||
if isOfficialAPI {
|
||||
|
||||
@@ -105,8 +105,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Process started, PID: %d\n", cmd.Process.Pid)
|
||||
|
||||
// 立即通知前端:AI 正在思考(避免用户以为卡死)
|
||||
callback(ai.StreamChunk{Content: "💭 *正在思考...*\n\n"})
|
||||
// 前端已有 loading 动画,无需在 content 中注入"正在思考"
|
||||
|
||||
// 逐行读取流式 JSON 输出
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
@@ -131,14 +130,18 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
|
||||
// 助手消息开始或文本内容
|
||||
if event.Message.Content != nil {
|
||||
for _, block := range event.Message.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
if block.Type == "thinking" && block.Thinking != "" {
|
||||
callback(ai.StreamChunk{Thinking: block.Thinking})
|
||||
} else if block.Type == "text" && block.Text != "" {
|
||||
callback(ai.StreamChunk{Content: block.Text})
|
||||
}
|
||||
}
|
||||
}
|
||||
case "content_block_delta":
|
||||
// 增量文本
|
||||
if event.Delta.Text != "" {
|
||||
// 增量文本或增量思考
|
||||
if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
|
||||
callback(ai.StreamChunk{Thinking: event.Delta.Thinking})
|
||||
} else if event.Delta.Text != "" {
|
||||
callback(ai.StreamChunk{Content: event.Delta.Text})
|
||||
}
|
||||
case "result":
|
||||
@@ -213,12 +216,15 @@ type cliStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
} `json:"content"`
|
||||
} `json:"message,omitempty"`
|
||||
Delta struct {
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Thinking string `json:"thinking"`
|
||||
} `json:"delta,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error struct {
|
||||
|
||||
@@ -83,7 +83,13 @@ type geminiContent struct {
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text"`
|
||||
Text string `json:"text,omitempty"`
|
||||
InlineData *geminiBlob `json:"inlineData,omitempty"`
|
||||
}
|
||||
|
||||
type geminiBlob struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type geminiGenConfig struct {
|
||||
@@ -205,10 +211,6 @@ func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest {
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
var systemInstruction *geminiContent
|
||||
var contents []geminiContent
|
||||
@@ -224,9 +226,29 @@ func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest {
|
||||
if role == "assistant" {
|
||||
role = "model"
|
||||
}
|
||||
var parts []geminiPart
|
||||
text := m.Content
|
||||
if text == "" && len(m.Images) > 0 {
|
||||
text = "请描述和分析这张图片。" // 同样避免 Gemini 认为意图不明确
|
||||
}
|
||||
if text != "" {
|
||||
parts = append(parts, geminiPart{Text: text})
|
||||
}
|
||||
for _, img := range m.Images {
|
||||
mimeType, rawBase64, err := ParseDataURI(img)
|
||||
if err == nil {
|
||||
parts = append(parts, geminiPart{
|
||||
InlineData: &geminiBlob{
|
||||
MimeType: mimeType,
|
||||
Data: rawBase64,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
contents = append(contents, geminiContent{
|
||||
Role: role,
|
||||
Parts: []geminiPart{{Text: m.Content}},
|
||||
Parts: parts,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -235,7 +257,6 @@ func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest {
|
||||
SystemInstruction: systemInstruction,
|
||||
GenerationConfig: geminiGenConfig{
|
||||
Temperature: temperature,
|
||||
MaxOutputTokens: maxTokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -252,6 +273,12 @@ func (p *GeminiProvider) doRequest(ctx context.Context, url string, body interfa
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if strings.Contains(url, "alt=sse") {
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("Cache-Control", "no-cache")
|
||||
httpReq.Header.Set("Connection", "keep-alive")
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求到 Gemini 失败: %w", err)
|
||||
|
||||
26
internal/ai/provider/helper.go
Normal file
26
internal/ai/provider/helper.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseDataURI 解析前端传递的 Data URI,返回 mimeType 和去掉前缀的 rawBase64
|
||||
func ParseDataURI(dataURI string) (mimeType, rawBase64 string, err error) {
|
||||
if !strings.HasPrefix(dataURI, "data:") {
|
||||
// 如果前端漏了前缀,默认容错当做 jpeg 处理
|
||||
return "image/jpeg", dataURI, nil
|
||||
}
|
||||
parts := strings.SplitN(dataURI, ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmt.Errorf("invalid data URI format")
|
||||
}
|
||||
meta := strings.TrimPrefix(parts[0], "data:")
|
||||
metaParts := strings.Split(meta, ";")
|
||||
mimeType = metaParts[0]
|
||||
if mimeType == "" {
|
||||
mimeType = "image/jpeg" // fallback
|
||||
}
|
||||
rawBase64 = parts[1]
|
||||
return mimeType, rawBase64, nil
|
||||
}
|
||||
@@ -88,18 +88,67 @@ type openAIChatRequest struct {
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []ai.Tool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
type openAIChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content,omitempty"`
|
||||
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
func buildOpenAIMessages(reqMessages []ai.Message, modelName string, baseURL string) []openAIChatMessage {
|
||||
messages := make([]openAIChatMessage, len(reqMessages))
|
||||
for i, m := range reqMessages {
|
||||
if m.Role == "tool" {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID}
|
||||
continue
|
||||
}
|
||||
if len(m.ToolCalls) > 0 {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(m.Images) > 0 {
|
||||
var contentParts []map[string]interface{}
|
||||
text := m.Content
|
||||
if text == "" {
|
||||
text = "请描述和分析这张图片。" // 兼容部分模型(如 ZhipuAI/GLM-4V)强制要求图片必须伴随有效文本块,同时防止强 System Prompt 下模型当成空消息处理
|
||||
}
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": text,
|
||||
})
|
||||
for _, img := range m.Images {
|
||||
imgURL := img
|
||||
// 仅当直接请求智谱官方 API 域名时(它原生不接受 data 协议前缀),才截取裸 Base64
|
||||
if strings.Contains(strings.ToLower(baseURL), "bigmodel") {
|
||||
if _, raw, err := ParseDataURI(img); err == nil {
|
||||
imgURL = raw
|
||||
}
|
||||
}
|
||||
contentParts = append(contentParts, map[string]interface{}{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]interface{}{
|
||||
"url": imgURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: contentParts}
|
||||
} else {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// openAIChatResponse OpenAI API 响应体
|
||||
type openAIChatResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ai.ToolCall `json:"tool_calls,omitempty"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
@@ -114,10 +163,22 @@ type openAIChatResponse struct {
|
||||
}
|
||||
|
||||
// openAIStreamChunk SSE 流式响应片段
|
||||
type openAIToolCallDelta struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Function *struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
} `json:"function,omitempty"`
|
||||
}
|
||||
|
||||
type openAIStreamChunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []openAIToolCallDelta `json:"tool_calls,omitempty"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
@@ -131,26 +192,19 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messages := make([]openAIChatMessage, len(req.Messages))
|
||||
for i, m := range req.Messages {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := openAIChatRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: messages,
|
||||
Temperature: temperature,
|
||||
MaxTokens: maxTokens,
|
||||
Stream: false,
|
||||
Tools: req.Tools,
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
@@ -177,6 +231,7 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat
|
||||
CompletionTokens: result.Usage.CompletionTokens,
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
},
|
||||
ToolCalls: result.Choices[0].Message.ToolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -185,26 +240,19 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
|
||||
return err
|
||||
}
|
||||
|
||||
messages := make([]openAIChatMessage, len(req.Messages))
|
||||
for i, m := range req.Messages {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL)
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := openAIChatRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: messages,
|
||||
Temperature: temperature,
|
||||
MaxTokens: maxTokens,
|
||||
Stream: true,
|
||||
Tools: req.Tools,
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
@@ -214,6 +262,8 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
|
||||
defer respBody.Close()
|
||||
|
||||
receivedContent := false
|
||||
var activeToolCalls []ai.ToolCall
|
||||
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
// 增大 scanner buffer,防止长行被截断
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
@@ -245,12 +295,49 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
|
||||
return nil
|
||||
}
|
||||
if len(chunk.Choices) > 0 {
|
||||
content := chunk.Choices[0].Delta.Content
|
||||
choice := chunk.Choices[0]
|
||||
|
||||
// Handle ToolCalls delta
|
||||
if len(choice.Delta.ToolCalls) > 0 {
|
||||
receivedContent = true
|
||||
for _, tcDelta := range choice.Delta.ToolCalls {
|
||||
// Expand activeToolCalls slice if index is larger
|
||||
for len(activeToolCalls) <= tcDelta.Index {
|
||||
activeToolCalls = append(activeToolCalls, ai.ToolCall{Type: "function"})
|
||||
}
|
||||
if tcDelta.ID != "" {
|
||||
activeToolCalls[tcDelta.Index].ID = tcDelta.ID
|
||||
}
|
||||
if tcDelta.Function != nil {
|
||||
if tcDelta.Function.Name != "" {
|
||||
activeToolCalls[tcDelta.Index].Function.Name += tcDelta.Function.Name
|
||||
}
|
||||
if tcDelta.Function.Arguments != "" {
|
||||
activeToolCalls[tcDelta.Index].Function.Arguments += tcDelta.Function.Arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
// 实时推送目前已解析的 ToolCalls 状态
|
||||
callback(ai.StreamChunk{ToolCalls: activeToolCalls})
|
||||
}
|
||||
|
||||
content := choice.Delta.Content
|
||||
if content != "" {
|
||||
receivedContent = true
|
||||
callback(ai.StreamChunk{Content: content})
|
||||
}
|
||||
if chunk.Choices[0].FinishReason != nil {
|
||||
|
||||
// 支持 DeepSeek/千问等模型的 reasoning_content 字段
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
receivedContent = true
|
||||
callback(ai.StreamChunk{Thinking: choice.Delta.ReasoningContent})
|
||||
}
|
||||
|
||||
if choice.FinishReason != nil {
|
||||
if *choice.FinishReason == "tool_calls" {
|
||||
callback(ai.StreamChunk{ToolCalls: activeToolCalls, Done: true})
|
||||
return nil
|
||||
}
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
@@ -296,6 +383,13 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.Re
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
|
||||
|
||||
// 仅在流式请求时明确声明 SSE,防止代理缓冲
|
||||
if strings.Contains(string(jsonBody), `"stream":true`) || strings.Contains(string(jsonBody), `"stream": true`) {
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
httpReq.Header.Set("Cache-Control", "no-cache")
|
||||
httpReq.Header.Set("Connection", "keep-alive")
|
||||
}
|
||||
|
||||
// 自定义 headers(用于兼容各类 OpenAI 兼容服务)
|
||||
for k, v := range p.config.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
|
||||
@@ -114,7 +114,7 @@ func (s *Service) AIDeleteProvider(id string) error {
|
||||
return s.saveConfig()
|
||||
}
|
||||
|
||||
// AITestProvider 测试 Provider 配置是否可用
|
||||
// AITestProvider 测试 Provider 配置是否可用,仅测试端点连通性与密钥,不实际调用对话
|
||||
func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{} {
|
||||
// 如果传入脱敏的 key,使用已保存的 key
|
||||
s.mu.RLock()
|
||||
@@ -128,30 +128,84 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
p, err := provider.NewProvider(config)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error()}
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error()}
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
providerType := config.Type
|
||||
if providerType == "custom" && config.APIFormat != "" {
|
||||
providerType = config.APIFormat
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*1000*1000*1000) // 30s
|
||||
defer cancel()
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
var err error
|
||||
|
||||
switch providerType {
|
||||
case "openai":
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") {
|
||||
baseURL = baseURL + "/v1"
|
||||
}
|
||||
// 使用 /models 端点验证连通性和鉴权
|
||||
req, _ := http.NewRequest("GET", baseURL+"/models", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||||
for k, v := range config.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, reqErr := client.Do(req)
|
||||
if reqErr != nil {
|
||||
err = reqErr
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
err = fmt.Errorf("API Key 验证失败 (HTTP %d)", resp.StatusCode)
|
||||
} else if resp.StatusCode >= 500 {
|
||||
err = fmt.Errorf("上游服务器内部错误 (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
case "anthropic":
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.anthropic.com"
|
||||
}
|
||||
req, _ := http.NewRequest("GET", baseURL, nil)
|
||||
resp, reqErr := client.Do(req)
|
||||
if reqErr != nil {
|
||||
err = reqErr
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
case "gemini":
|
||||
if baseURL == "" {
|
||||
baseURL = "https://generativelanguage.googleapis.com"
|
||||
}
|
||||
req, _ := http.NewRequest("GET", baseURL+"/v1beta/models?key="+config.APIKey, nil)
|
||||
resp, reqErr := client.Do(req)
|
||||
if reqErr != nil {
|
||||
err = reqErr
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest {
|
||||
err = fmt.Errorf("API Key 无效或请求错误 (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
default:
|
||||
if baseURL != "" {
|
||||
req, _ := http.NewRequest("GET", baseURL, nil)
|
||||
resp, reqErr := client.Do(req)
|
||||
if reqErr != nil {
|
||||
err = reqErr
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := p.Chat(ctx, ai.ChatRequest{
|
||||
Messages: []ai.Message{
|
||||
{Role: "user", Content: "Hi, please respond with 'OK' to confirm the connection is working."},
|
||||
},
|
||||
MaxTokens: 10,
|
||||
})
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": fmt.Sprintf("连接测试失败: %s", err.Error())}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("连接成功!模型响应: %s", truncateString(resp.Content, 100)),
|
||||
"message": "端点连通性测试成功!",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,19 +418,14 @@ func (s *Service) AISetContextLevel(level string) {
|
||||
|
||||
// --- AI 对话 ---
|
||||
|
||||
// AIChatSend 同步发送 AI 对话(非流式)
|
||||
func (s *Service) AIChatSend(messages []map[string]string) map[string]interface{} {
|
||||
// AIChatSend 非流式发送 AI 对话
|
||||
func (s *Service) AIChatSend(messages []ai.Message, tools []ai.Tool) map[string]interface{} {
|
||||
p, err := s.getActiveProvider()
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "error": err.Error()}
|
||||
}
|
||||
|
||||
var aiMessages []ai.Message
|
||||
for _, m := range messages {
|
||||
aiMessages = append(aiMessages, ai.Message{Role: m["role"], Content: m["content"]})
|
||||
}
|
||||
|
||||
resp, err := p.Chat(context.Background(), ai.ChatRequest{Messages: aiMessages})
|
||||
resp, err := p.Chat(context.Background(), ai.ChatRequest{Messages: messages, Tools: tools})
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "error": err.Error()}
|
||||
}
|
||||
@@ -384,6 +433,7 @@ func (s *Service) AIChatSend(messages []map[string]string) map[string]interface{
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"content": resp.Content,
|
||||
"tool_calls": resp.ToolCalls,
|
||||
"tokensUsed": map[string]int{
|
||||
"promptTokens": resp.TokensUsed.PromptTokens,
|
||||
"completionTokens": resp.TokensUsed.CompletionTokens,
|
||||
@@ -393,7 +443,7 @@ func (s *Service) AIChatSend(messages []map[string]string) map[string]interface{
|
||||
}
|
||||
|
||||
// AIChatStream 流式发送 AI 对话(通过 EventsEmit 推送)
|
||||
func (s *Service) AIChatStream(sessionID string, messages []map[string]string) {
|
||||
func (s *Service) AIChatStream(sessionID string, messages []ai.Message, tools []ai.Tool) {
|
||||
streamCtx, cancel := context.WithCancel(context.Background())
|
||||
s.mu.Lock()
|
||||
s.cancelFuncs[sessionID] = cancel
|
||||
@@ -416,16 +466,13 @@ func (s *Service) AIChatStream(sessionID string, messages []map[string]string) {
|
||||
return
|
||||
}
|
||||
|
||||
var aiMessages []ai.Message
|
||||
for _, m := range messages {
|
||||
aiMessages = append(aiMessages, ai.Message{Role: m["role"], Content: m["content"]})
|
||||
}
|
||||
|
||||
err = p.ChatStream(streamCtx, ai.ChatRequest{Messages: aiMessages}, func(chunk ai.StreamChunk) {
|
||||
err = p.ChatStream(streamCtx, ai.ChatRequest{Messages: messages, Tools: tools}, func(chunk ai.StreamChunk) {
|
||||
wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{
|
||||
"content": chunk.Content,
|
||||
"done": chunk.Done,
|
||||
"error": chunk.Error,
|
||||
"content": chunk.Content,
|
||||
"thinking": chunk.Thinking,
|
||||
"tool_calls": chunk.ToolCalls,
|
||||
"done": chunk.Done,
|
||||
"error": chunk.Error,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
package ai
|
||||
|
||||
// ToolCall 表示 AI 发出的工具调用
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "function"
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
// ToolFunction 表示可使用的函数定义
|
||||
type ToolFunction struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters any `json:"parameters"` // JSON Schema definitions
|
||||
}
|
||||
|
||||
// Tool 工具申明
|
||||
type Tool struct {
|
||||
Type string `json:"type"` // "function"
|
||||
Function ToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
// Message 表示一条对话消息
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "system" | "user" | "assistant"
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
|
||||
Content string `json:"content"`
|
||||
Images []string `json:"images,omitempty"` // base64 encoded images with data:image/png;base64,... prefix
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // 当 role 为 "tool" 时必须传递
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 当 role 为 "assistant" 并试图调工具时传递
|
||||
}
|
||||
|
||||
// ChatRequest AI 对话请求
|
||||
@@ -11,12 +37,14 @@ type ChatRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// ChatResponse AI 对话响应
|
||||
type ChatResponse struct {
|
||||
Content string `json:"content"`
|
||||
TokensUsed TokenUsage `json:"tokensUsed"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// TokenUsage token 用量统计
|
||||
@@ -28,9 +56,11 @@ type TokenUsage struct {
|
||||
|
||||
// StreamChunk 流式响应片段
|
||||
type StreamChunk struct {
|
||||
Content string `json:"content"`
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderConfig AI Provider 配置
|
||||
|
||||
Reference in New Issue
Block a user