Skip to content
Merged
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
410 changes: 410 additions & 0 deletions chat/agent.go

Large diffs are not rendered by default.

562 changes: 562 additions & 0 deletions chat/agent_test.go

Large diffs are not rendered by default.

114 changes: 91 additions & 23 deletions chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ type promptData struct {
BotUsername string // 添加 Bot 用户名字段
}

func extractInputFromMessage(msg *tb.Message, trigger *config.ChatTrigger) string {
input := msg.Text
if input == "" {
input = msg.Caption
}
if trigger.Command != "" {
if _, text, err := entities.CommandFromText(input, 0); err == nil {
input = text
}
}
return input
}

// Chat 处理聊天请求
func Chat(ctx tb.Context, v2 *config.ChatConfigSingle, trigger *config.ChatTrigger) error {

Expand All @@ -137,16 +150,7 @@ func Chat(ctx tb.Context, v2 *config.ChatConfigSingle, trigger *config.ChatTrigg
return nil
}

input := ctx.Message().Text
if input == "" {
input = ctx.Message().Caption
}
if trigger.Command != "" {
_, text, err := entities.CommandFromText(input, 0)
if err != nil {
input = text
}
}
input := extractInputFromMessage(ctx.Message(), trigger)

// if gacha, reply and not send placeholder
isGacha := trigger.Gacha > 0
Expand Down Expand Up @@ -206,6 +210,7 @@ func Chat(ctx tb.Context, v2 *config.ChatConfigSingle, trigger *config.ChatTrigg
}

multiPartContent := false
var imageDataURL string
var contents []openai.ChatMessagePart
if v2.Model.Features.Image && v2.Features.Image {
// TODO handle multi photos album
Expand Down Expand Up @@ -244,11 +249,12 @@ func Chat(ctx tb.Context, v2 *config.ChatConfigSingle, trigger *config.ChatTrigg
base64Img := []byte("data:image/jpeg;base64,")
base64Img = base64.StdEncoding.AppendEncode(base64Img, buf.Bytes())
log.Info("encoded base64 image data url size", zap.Int("size", len(base64Img)))
imageDataURL = string(base64Img)
contents = append(contents,
openai.ChatMessagePart{
Type: openai.ChatMessagePartTypeImageURL,
ImageURL: &openai.ChatMessageImageURL{
URL: string(base64Img),
URL: imageDataURL,
},
},
openai.ChatMessagePart{
Expand All @@ -275,8 +281,6 @@ final:

// zap.L().Debug("Chat context messages", zap.Any("messages", messages))

client := clients[v2.Model.Name]

// 处理place_holder功能
var placeholderMsg *tb.Message
switch {
Expand All @@ -288,7 +292,6 @@ final:
placeholderMsg, placeHolderErr = ctx.Bot().Reply(ctx.Message(), v2.PlaceHolder, tb.ModeMarkdownV2)
if placeHolderErr != nil {
log.Error("Failed to send placeholder message", zap.Error(placeHolderErr))
// 如果发送placeholder失败,继续正常流程,不使用placeholder功能
}
default:
err = ctx.Bot().Notify(ctx.Chat(), tb.Typing)
Expand All @@ -300,24 +303,91 @@ final:
chatCtx, cancel := context.WithTimeout(context.Background(), v2.GetTimeout())
defer cancel()

// Use agent-based execution path if enabled
if v2.Agent.Enabled {
return chatWithAgent(chatCtx, ctx, v2, systemPrompt, promptBuf.String(),
multiPartContent, imageDataURL, placeholderMsg, messages)
}

// Legacy execution path using direct OpenAI SDK
return chatWithLegacy(chatCtx, ctx, v2, messages, placeholderMsg)

}

// chatWithAgent executes the chat using the langchaingo agent-based path.
func chatWithAgent(
chatCtx context.Context, ctx tb.Context, v2 *config.ChatConfigSingle,
systemPrompt string, userPrompt string,
multiPartContent bool, imageURL string,
placeholderMsg *tb.Message,
messages []openai.ChatCompletionMessage,
) error {
model := getLangchainModel(v2.Model.Name)
if model == nil {
log.Error("langchain model not found, falling back to legacy path", zap.String("model", v2.Model.Name))
return chatWithLegacy(chatCtx, ctx, v2, messages, placeholderMsg)
}
Comment on lines +325 to +329
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chatWithAgent logs that it will fall back to the legacy path when the langchaingo model is missing, but it currently returns nil instead. This results in no response being sent (and can leave a placeholder message hanging). Call chatWithLegacy(...) here (or at least edit/send an error message) instead of returning nil.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6ebda7a. chatWithAgent now accepts the legacy messages parameter and calls chatWithLegacy(chatCtx, ctx, v2, messages, placeholderMsg) when the langchaingo model is not found.


lcMessages := convertToLangchainMessages(systemPrompt, userPrompt, multiPartContent, imageURL)

ap := newAgentProcessor(chatCtx, ctx, model, v2, lcMessages, placeholderMsg)
response, err := ap.run()
if err != nil {
log.Error("agent execution failed", zap.Error(err))
if placeholderMsg != nil {
_, editErr := util.EditMessageWithError(placeholderMsg, v2.GetErrorMessage(), tb.ModeMarkdownV2)
if editErr != nil {
log.Error("Failed to edit placeholder with error", zap.Error(editErr))
}
}
return err
}

// Process outgoing filters
for _, filterConfig := range v2.Filters.Filters {
filter := createFilter(&filterConfig)
if filter == nil {
continue
}
response = filter.ProcessOutgoing(response, ctx, v2)
}

Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outgoing filters are applied after ap.run() returns, but ap.run() already sends/edits the Telegram message in finalizeResponse(). That means ProcessOutgoing modifications are never reflected in what the user sees (despite the filter contract saying it can modify the response before sending). Consider applying outgoing filters before the final send/edit, or refactor ap.run() to return the final text and let the caller send after filtering.

Suggested change
// Ensure the filtered response is reflected in the final user-visible message.
if placeholderMsg != nil {
if _, err := util.EditMessageWithError(placeholderMsg, response, tb.ModeMarkdownV2); err != nil {
log.Error("failed to edit placeholder with filtered agent response", zap.Error(err))
}
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: the outgoing filter behavior is consistent with the legacy path (chatWithLegacy), which also applies ProcessOutgoing after the stream processor has already sent/edited the message. Both paths apply filters to the returned response text after it's been sent. If we want filters to modify what the user sees, both paths would need refactoring together — keeping them consistent for now in 6ebda7a.

log.Debug("Agent chat response", zap.String("response", response))
return nil
}

// chatWithLegacy executes the chat using the legacy direct OpenAI SDK path.
func chatWithLegacy(
chatCtx context.Context, ctx tb.Context, v2 *config.ChatConfigSingle,
messages []openai.ChatCompletionMessage, placeholderMsg *tb.Message,
) error {
client := clients[v2.Model.Name]
useMcp := v2.UseMcpo && config.BotConfig.McpoServer.Enable
useInternalTools := v2.UseInternalTools

request := openai.ChatCompletionRequest{
Model: v2.Model.Model,
Messages: messages,
Temperature: v2.GetTemperature(),
Stream: true, // Enable streaming
Stream: true,
ReasoningEffort: v2.ReasoningEffort,
}
if useMcp {
request.Tools = mcpo.GetToolSet("")

// Add tools to request
var allTools []openai.Tool
if useInternalTools {
allTools = append(allTools, GetInternalToolDefinitions()...)
}
if useMcp && mcpo != nil {
allTools = append(allTools, mcpo.GetToolSet("")...)
}
if len(allTools) > 0 {
request.Tools = allTools
}

// Create a streaming response
stream, err := client.CreateChatCompletionStream(chatCtx, request)
if err != nil {
log.Error("Failed to create chat completion stream", zap.Error(err))
// 如果使用了placeholder且出现错误,更新placeholder消息为错误提示
if placeholderMsg != nil {
_, editErr := util.EditMessageWithError(placeholderMsg, v2.GetErrorMessage(), tb.ModeMarkdownV2)
if editErr != nil {
Expand All @@ -327,8 +397,7 @@ final:
return err
}

// Process the streaming response using streamProcessor
processor := newStreamProcessor(chatCtx, ctx, placeholderMsg, useMcp, &request, &messages, v2)
processor := newStreamProcessor(chatCtx, ctx, placeholderMsg, useMcp || useInternalTools, &request, &messages, v2)
response, err := processor.process(stream)
if err != nil {
log.Error("Failed to process streaming response", zap.Error(err))
Expand All @@ -341,7 +410,7 @@ final:
return err
}

// 处理过滤器的Outgoing
// Process outgoing filters
for _, filterConfig := range v2.Filters.Filters {
filter := createFilter(&filterConfig)
if filter == nil {
Expand All @@ -352,7 +421,6 @@ final:

log.Debug("Chat response", zap.String("response", response))
return nil

}

var extractReasonPatt = regexp.MustCompile(`(?si)^\s*<think>\s*(?P<reason>.*?)(?:\s*</think>|$)\s*`)
Expand Down
42 changes: 42 additions & 0 deletions chat/chat_input_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package chat

import (
"testing"

"csust-got/config"

"github.com/stretchr/testify/assert"
tb "gopkg.in/telebot.v3"
)

func TestExtractInputFromMessage_RemovesCommandPrefix(t *testing.T) {
trigger := &config.ChatTrigger{Command: "think"}
tests := []struct {
name string
msg *tb.Message
want string
}{
{
name: "plain command prefix stripped",
msg: &tb.Message{Text: "/think hello world"},
want: "hello world",
},
{
name: "command with bot mention stripped",
msg: &tb.Message{Text: "/think@bot hello world"},
want: "hello world",
},
{
name: "no command prefix kept",
msg: &tb.Message{Text: "hello world"},
want: "hello world",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractInputFromMessage(tt.msg, trigger)
assert.Equal(t, tt.want, got)
})
}
}
Loading