Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions i18n/en_US.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,9 @@ ui:
ask_placeholder: Ask a question
thinking: Thinking…
thoughts: Thoughts
image_upload: Add images
image_too_large: Image exceeds the 4MB limit
image_max_count: You can attach up to 4 images
notifications:
title: Notifications
inbox: Inbox
Expand Down Expand Up @@ -2360,6 +2363,12 @@ ui:
model:
label: Model
msg: Model is required
thinking_mode:
label: Deep thinking mode
tip: Sends the enable_thinking flag so reasoning-capable models show their thought process.
vision_enabled:
label: Allow image input
tip: Let users attach up to 4 PNG/JPEG/WebP images (max 4MB each) to AI conversations.
add_success: AI settings updated successfully.
conversations:
topic: Topic
Expand Down
9 changes: 9 additions & 0 deletions i18n/zh_CN.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,9 @@ ui:
copy: 复制
ask_a_follow_up: 提出后续问题
ask_placeholder: 提问
image_upload: 添加图片
image_too_large: 图片超过 4MB 限制
image_max_count: 最多只能添加 4 张图片
notifications:
title: 通知
inbox: 收件箱
Expand Down Expand Up @@ -2318,6 +2321,12 @@ ui:
model:
label: 模型
msg: 模型是必需的
thinking_mode:
label: 深度思考模式
tip: 开启后请求将携带 enable_thinking 参数,支持推理的模型将输出思考过程。
vision_enabled:
label: 允许图片输入
tip: 允许用户在与 AI 对话时附带最多 4 张 PNG/JPEG/WebP 图片(每张不超过 4MB)。
add_success: AI 设置更新成功。
conversations:
topic: 主题
Expand Down
85 changes: 72 additions & 13 deletions internal/controller/ai_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ type ChatCompletionsRequest struct {
type Message struct {
Role string `json:"role" binding:"required"`
Content string `json:"content" binding:"required"`
// Images carries optional attachments (base64 data URLs or HTTPS URLs)
// for vision-capable providers. Only the first user message accepts them.
Images []string `json:"images,omitempty"`
}

type ChatCompletionsResponse struct {
Expand Down Expand Up @@ -161,6 +164,9 @@ type ConversationContext struct {
Messages []*ai_conversation.ConversationMessage
IsNewConversation bool
Model string
// Images holds the attachments of the current user turn only; history
// records keep a textual placeholder instead of persisting image data.
Images []string
}

func (c *ConversationContext) GetOpenAIMessages() []openai.ChatCompletionMessage {
Expand All @@ -174,6 +180,22 @@ func (c *ConversationContext) GetOpenAIMessages() []openai.ChatCompletionMessage
return messages
}

// getVisionMessages returns the OpenAI messages with the current turn's images
// attached to the final user message as MultiContent parts.
func (c *ConversationContext) getVisionMessages() ([]openai.ChatCompletionMessage, error) {
messages := c.GetOpenAIMessages()
last := len(messages) - 1
if last < 0 || messages[last].Role != openai.ChatMessageRoleUser {
return messages, nil
}
vmsg, err := ValidateAndPrepareImages(messages[last].Content, c.Images)
if err != nil {
return nil, err
}
messages[last] = *vmsg
return messages, nil
}

// sendStreamData
func sendStreamData(w http.ResponseWriter, data StreamResponse) {
jsonData, err := json.Marshal(data)
Expand Down Expand Up @@ -211,8 +233,31 @@ func (c *AIController) ChatCompletions(ctx *gin.Context) {
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)

data, _ := json.Marshal(req)
log.Infof("ai chat request data: %s", string(data))
// Reject or validate image attachments before the SSE stream starts, so
// clients still receive a proper JSON error with the right status code.
if len(req.Messages) > 0 && len(req.Messages[0].Images) > 0 {
if !aiProvider.VisionEnabled {
handler.HandleResponse(ctx, errors.BadRequest("image input is not enabled for the current AI provider"), nil)
return
}
if _, verr := ValidateAndPrepareImages(req.Messages[0].Content, req.Messages[0].Images); verr != nil {
handler.HandleResponse(ctx, errors.BadRequest(verr.Error()), nil)
return
}
}

// Never dump image data into logs; summarize attachments as a count.
if len(req.Messages) > 0 && len(req.Messages[0].Images) > 0 {
logReq := *req
logReq.Messages = make([]Message, len(req.Messages))
copy(logReq.Messages, req.Messages)
logReq.Messages[0].Images = []string{fmt.Sprintf("<%d images>", len(req.Messages[0].Images))}
data, _ := json.Marshal(logReq)
log.Infof("ai chat request data: %s", string(data))
} else {
data, _ := json.Marshal(req)
log.Infof("ai chat request data: %s", string(data))
}

ctx.Header("Content-Type", "text/event-stream")
ctx.Header("Cache-Control", "no-cache")
Expand Down Expand Up @@ -271,32 +316,34 @@ func (c *AIController) ChatCompletions(ctx *gin.Context) {

func (c *AIController) redirectRequestToAI(ctx *gin.Context, w http.ResponseWriter, id string, conversationCtx *ConversationContext) {
client := c.createOpenAIClient()
if client == nil {
c.sendErrorResponse(w, id, conversationCtx.Model, "AI service is not properly configured")
return
}

c.handleAIConversation(ctx, w, id, client, conversationCtx)
}

// createOpenAIClient
// createOpenAIClient builds an OpenAI-compatible client from the site AI
// provider config. Returns nil when AI is disabled or config cannot be read.
func (c *AIController) createOpenAIClient() *openai.Client {
config := openai.DefaultConfig("")
config.BaseURL = ""

aiConfig, err := c.siteInfoService.GetSiteAI(context.Background())
if err != nil {
log.Errorf("Failed to get AI config: %v", err)
return openai.NewClientWithConfig(config)
return nil
}

if !aiConfig.Enabled {
log.Warn("AI feature is disabled")
return openai.NewClientWithConfig(config)
return nil
}

aiProvider := aiConfig.GetProvider()

config = openai.DefaultConfig(aiProvider.APIKey)
config.BaseURL = aiProvider.APIHost
if !strings.HasSuffix(config.BaseURL, "/v1") {
config.BaseURL += "/v1"
config := openai.DefaultConfig(aiProvider.APIKey)
config.BaseURL = schema.NormalizeAPIHost(aiProvider.APIHost)
if aiProvider.ThinkingMode == "on" {
config.HTTPClient = newThinkingHTTPClient()
}
return openai.NewClientWithConfig(config)
}
Expand Down Expand Up @@ -350,6 +397,9 @@ func (c *AIController) initializeConversationContext(ctx *gin.Context, model str
ConversationID: req.ConversationID,
Model: model,
}
if len(req.Messages) > 0 {
conversationCtx.Images = req.Messages[0].Images
}

conversationDetail, exist, err := c.aiConversationService.GetConversationDetail(ctx, &schema.AIConversationDetailReq{
ConversationID: req.ConversationID,
Expand All @@ -361,6 +411,10 @@ func (c *AIController) initializeConversationContext(ctx *gin.Context, model str
}
if !exist {
conversationCtx.UserQuestion = req.Messages[0].Content
if len(conversationCtx.Images) > 0 {
// Persist a placeholder only; image data is not stored.
conversationCtx.UserQuestion += "\n[图片]"
}
conversationCtx.Messages = c.buildInitialMessages(ctx, req)
conversationCtx.IsNewConversation = true
return conversationCtx
Expand Down Expand Up @@ -432,7 +486,12 @@ func (c *AIController) saveConversationRecord(ctx context.Context, chatcmplID st

func (c *AIController) handleAIConversation(ctx *gin.Context, w http.ResponseWriter, id string, client *openai.Client, conversationCtx *ConversationContext) {
maxRounds := 10
messages := conversationCtx.GetOpenAIMessages()
messages, err := conversationCtx.getVisionMessages()
if err != nil {
log.Errorf("Failed to prepare vision messages: %v", err)
c.sendErrorResponse(w, id, conversationCtx.Model, err.Error())
return
}

for round := range maxRounds {
log.Debugf("AI conversation round: %d", round+1)
Expand Down
71 changes: 71 additions & 0 deletions internal/controller/ai_thinking_transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package controller

import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
)

// ThinkingParamKey is the OpenAI-compatible request flag understood by
// reasoning-capable model gateways (DeepSeek, Qwen/DashScope, SenseNova, ...).
const ThinkingParamKey = "enable_thinking"

// thinkingTransport merges the thinking flag into chat completion request
// bodies before they leave the process. The openai SDK has no generic extra
// body hook, so an http.Client with this transport is attached to the client
// config when the provider enables thinking mode.
type thinkingTransport struct{ base http.RoundTripper }

func (t *thinkingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Body != nil && req.ContentLength != 0 &&
strings.HasSuffix(req.URL.Path, "/chat/completions") {
b, err := io.ReadAll(req.Body)
_ = req.Body.Close()
if err == nil {
var payload map[string]any
if json.Unmarshal(b, &payload) == nil && payload != nil {
payload[ThinkingParamKey] = true
nb, mErr := json.Marshal(payload)
if mErr == nil {
req.Body = io.NopCloser(bytes.NewReader(nb))
req.ContentLength = int64(len(nb))
req.Header.Set("Content-Length", strconv.Itoa(len(nb)))
} else {
req.Body = io.NopCloser(bytes.NewReader(b))
}
} else {
req.Body = io.NopCloser(bytes.NewReader(b))
}
}
}
if t.base == nil {
return http.DefaultTransport.RoundTrip(req)
}
return t.base.RoundTrip(req)
}

func newThinkingHTTPClient() *http.Client {
return &http.Client{Transport: &thinkingTransport{}}
}
80 changes: 80 additions & 0 deletions internal/controller/ai_thinking_transport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package controller

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestThinkingTransportInjectsFlag(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &got)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

client := newThinkingHTTPClient()
body := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"stream":true}`
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
srv.URL+"/v1/chat/completions", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()

if got["enable_thinking"] != true {
t.Fatalf("enable_thinking not injected: %v", got["enable_thinking"])
}
if got["model"] != "deepseek-v4-flash" {
t.Fatalf("original fields lost: %v", got)
}
}

func TestThinkingTransportLeavesOtherPaths(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

client := newThinkingHTTPClient()
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
srv.URL+"/v1/models", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
Loading