feat: support folding CoT (#327)

This commit is contained in:
Sh1n3zZ 2025-02-17 22:51:01 +08:00
parent 37fd6cd399
commit 24ffd86f77
No known key found for this signature in database
GPG Key ID: 696702CF723B0452
4 changed files with 127 additions and 33 deletions

View File

@ -6,7 +6,6 @@ import (
"chat/utils"
"errors"
"fmt"
"strings"
)
type ChatInstance struct {
@ -51,9 +50,18 @@ func (c *ChatInstance) GetChatEndpoint() string {
}
func (c *ChatInstance) GetChatBody(props *adaptercommon.ChatProps, stream bool) interface{} {
messages := props.Message
// because of deepseek first message must be user role
// convert assistant message to user message
if len(messages) > 0 && messages[0].Role == globals.Assistant {
messages = make([]globals.Message, len(props.Message))
copy(messages, props.Message)
messages[0].Role = globals.User
}
return ChatRequest{
Model: props.Model,
Messages: props.Message,
Messages: messages,
MaxTokens: props.MaxTokens,
Stream: stream,
Temperature: props.Temperature,
@ -92,28 +100,23 @@ func (c *ChatInstance) ProcessLine(data string) (string, error) {
delta := form.Choices[0].Delta
if delta.ReasoningContent != nil {
if *delta.ReasoningContent == "" && delta.Content != "" {
if !c.isReasonOver {
if c.isFirstReasoning == false && !c.isReasonOver && delta.ReasoningContent == nil {
c.isReasonOver = true
return fmt.Sprintf("\n\n%s", delta.Content), nil
}
if delta.Content != "" {
return fmt.Sprintf("\n</think>\n\n%s", delta.Content), nil
}
return "\n</think>\n\n", nil
}
if delta.ReasoningContent != nil && delta.Content == "" {
if delta.ReasoningContent != nil {
content := *delta.ReasoningContent
// replace double newlines with single newlines for markdown
if strings.Contains(content, "\n\n") {
content = strings.ReplaceAll(content, "\n\n", "\n")
}
if c.isFirstReasoning {
c.isFirstReasoning = false
return fmt.Sprintf(">%s", content), nil
return fmt.Sprintf("<think>\n%s", content), nil
}
return content, nil
}
return delta.Content, nil
}
@ -150,7 +153,7 @@ func (c *ChatInstance) CreateChatRequest(props *adaptercommon.ChatProps) (string
message := data.Choices[0].Message
content := message.Content
if message.ReasoningContent != nil {
content = fmt.Sprintf(">%s\n\n%s", *message.ReasoningContent, content)
content = fmt.Sprintf("<think>\n%s\n</think>\n\n%s", *message.ReasoningContent, content)
}
return content, nil

View File

@ -1,9 +0,0 @@
package deepseek
import "reflect"
var _ = reflect.TypeOf(ChatInstance{})
var _ = reflect.TypeOf(ChatRequest{})
var _ = reflect.TypeOf(ChatResponse{})
var _ = reflect.TypeOf(ChatStreamResponse{})
var _ = reflect.TypeOf(ChatStreamErrorResponse{})

View File

@ -19,7 +19,6 @@ import {
copyClipboard,
isContainDom,
saveAsFile,
useInputValue,
} from "@/utils/dom.ts";
import { useTranslation } from "react-i18next";
import React, { Ref, useRef, useState } from "react";
@ -35,6 +34,7 @@ import Avatar from "@/components/Avatar.tsx";
import { useSelector } from "react-redux";
import { selectUsername } from "@/store/auth.ts";
import { appLogo } from "@/conf/env.ts";
import { ThinkContent } from "@/components/ThinkContent";
type MessageProps = {
index: number;
@ -180,7 +180,13 @@ function MessageMenu({
{t("message.copy")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => useInputValue("input", filterMessage(message.content))}
onClick={() => {
const input = document.getElementById("input") as HTMLInputElement;
if (input) {
input.value = filterMessage(message.content);
input.focus();
}
}}
>
<MousePointerSquare className={`h-4 w-4 mr-1.5`} />
{t("message.use")}
@ -223,12 +229,34 @@ function MessageContent({
username,
}: MessageProps) {
const isUser = message.role === "user";
const user = useSelector(selectUsername);
const [open, setOpen] = useState(false);
const [editedMessage, setEditedMessage] = useState<string | undefined>("");
// 解析思考内容
const parseThinkContent = (content: string) => {
if (message.role !== "assistant") return null;
const startMatch = content.match(/<think>\n?(.*?)(?:<\/think>|$)/s);
if (startMatch) {
const thinkContent = startMatch[1];
const hasEndTag = content.includes('</think>');
const restContent = hasEndTag ?
content.replace(startMatch[0], "").trim() :
content.substring(content.indexOf('<think>') + 7).trim();
return {
thinkContent,
restContent: hasEndTag ? restContent : '',
isComplete: hasEndTag
};
}
return null;
};
const parsedContent = message.content.length ? parseThinkContent(message.content) : null;
return (
<div className={"content-wrapper"}>
<EditorProvider
@ -274,11 +302,29 @@ function MessageContent({
</div>
<div className={`message-content`}>
{message.content.length ? (
<>
{parsedContent ? (
<>
<ThinkContent
content={parsedContent.thinkContent}
isComplete={parsedContent.isComplete}
/>
{parsedContent.restContent && (
<Markdown
loading={message.end === false}
children={parsedContent.restContent}
acceptHtml={false}
/>
)}
</>
) : (
<Markdown
loading={message.end === false}
children={message.content}
acceptHtml={false}
/>
)}
</>
) : message.end === true ? (
<CircleSlash className={`h-5 w-5 m-1`} />
) : (

View File

@ -0,0 +1,54 @@
import { useState } from "react";
import { ChevronDown, ChevronUp, Brain, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/components/ui/lib/utils";
import Markdown from "@/components/Markdown";
import { useTranslation } from "react-i18next";
interface ThinkContentProps {
content: string;
isComplete?: boolean;
}
export function ThinkContent({ content, isComplete = true }: ThinkContentProps) {
const [isExpanded, setIsExpanded] = useState(true);
const { t } = useTranslation();
const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
return (
<div className="think-content-wrapper my-2 rounded-lg border bg-muted/40 dark:bg-muted/20">
<Button
variant="ghost"
onClick={toggleExpand}
className="w-full flex items-center justify-between p-2 hover:bg-muted/60"
>
<div className="flex items-center gap-2">
<Brain className="h-4 w-4" />
<span className="text-sm font-medium">{t("message.thinking-process")}</span>
{!isComplete && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isExpanded ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
)}
>
<div className={cn("p-3 pt-0 text-sm", !isComplete && "opacity-80")}>
<Markdown>{content}</Markdown>
</div>
</div>
</div>
);
}