feat: 模型思维链展示

This commit is contained in:
2025-06-29 16:09:46 +08:00
parent 0f03841f44
commit 867454eb84
6 changed files with 140 additions and 23 deletions

View File

@@ -19,7 +19,8 @@ MODEL_DATA = [
{ {
"vendor": "Anthropic", "vendor": "Anthropic",
"models": [ "models": [
{"model_id": "claude-sonnet-4-thinking", "model_name": "Claude Sonnet 4 thinking", "model_type": "reasoning"}, {"model_id": "claude-sonnet-4-thinking", "model_name": "Claude Sonnet 4 thinking",
"model_type": "reasoning"},
{"model_id": "claude-sonnet-4", "model_name": "Claude Sonnet 4", "model_type": "text"}, {"model_id": "claude-sonnet-4", "model_name": "Claude Sonnet 4", "model_type": "text"},
] ]
}, },
@@ -27,6 +28,7 @@ MODEL_DATA = [
"vendor": "硅基流动", "vendor": "硅基流动",
"models": [ "models": [
{"model_id": "deepseek-v3", "model_name": "DeepSeek V3", "model_type": "text"}, {"model_id": "deepseek-v3", "model_name": "DeepSeek V3", "model_type": "text"},
{"model_id": "deepseek-r1", "model_name": "DeepSeek R1", "model_type": "reasoning"},
] ]
} }
] ]

2
web/components.d.ts vendored
View File

@@ -10,6 +10,8 @@ declare module 'vue' {
Avatar: typeof import('./src/components/avatar.vue')['default'] Avatar: typeof import('./src/components/avatar.vue')['default']
Markdown: typeof import('./src/components/markdown.vue')['default'] Markdown: typeof import('./src/components/markdown.vue')['default']
NButton: typeof import('naive-ui')['NButton'] NButton: typeof import('naive-ui')['NButton']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDivider: typeof import('naive-ui')['NDivider'] NDivider: typeof import('naive-ui')['NDivider']
NImage: typeof import('naive-ui')['NImage'] NImage: typeof import('naive-ui')['NImage']

View File

@@ -8,6 +8,7 @@ export interface IChatWithLLMRequest {
export interface Message { export interface Message {
content?: string; content?: string;
thinking?: string;
role?: string; role?: string;
usage?: UsageInfo; usage?: UsageInfo;
[property: string]: any; [property: string]: any;

View File

@@ -11,11 +11,14 @@ export class ChatService {
accessToken: string, accessToken: string,
request: IChatWithLLMRequest, request: IChatWithLLMRequest,
onProgress: (content: string) => void, onProgress: (content: string) => void,
getUsageInfo: (object: UsageInfo) => void = () => {} getUsageInfo: (object: UsageInfo) => void = () => {},
getThinking: (thinkingContent: string) => void
) { ) {
let response; let response;
let buffer = ""; let buffer = "";
let accumulatedContent = ""; let accumulatedContent = "";
let thinking = false;
let thinkingContent = "";
try { try {
response = await fetch("/v1/chat/completions", { response = await fetch("/v1/chat/completions", {
method: "POST", method: "POST",
@@ -67,10 +70,46 @@ export class ChatService {
// 处理内容 // 处理内容
if (data.choices?.[0]?.delta?.content) { if (data.choices?.[0]?.delta?.content) {
// 累积内容 const content = data.choices[0].delta.content;
accumulatedContent += data.choices[0].delta.content;
// 触发回调 if (content.includes("<think>")) {
onProgress(accumulatedContent); thinking = true;
// 只处理<think>前的内容
const [beforeThink, afterThink] = content.split("<think>");
if (beforeThink) {
accumulatedContent += beforeThink;
onProgress(accumulatedContent);
}
thinkingContent = "";
if (afterThink) {
thinkingContent += afterThink;
getThinking(thinkingContent);
}
continue;
}
if (content.includes("</think>")) {
thinking = false;
// 只处理</think>后的内容
const [beforeEndThink, afterEndThink] =
content.split("</think>");
thinkingContent += beforeEndThink;
getThinking(thinkingContent);
if (afterEndThink) {
accumulatedContent += afterEndThink;
onProgress(accumulatedContent);
}
thinkingContent = "";
continue;
}
if (thinking) {
thinkingContent += content;
getThinking(thinkingContent);
} else {
accumulatedContent += content;
onProgress(accumulatedContent);
}
} }
// 处理使用信息 // 处理使用信息

View File

@@ -14,14 +14,17 @@ export const useChatStore = defineStore("chat", () => {
const historyMessages = ref<IChatWithLLMRequest["messages"]>([]); const historyMessages = ref<IChatWithLLMRequest["messages"]>([]);
// 是否正在响应 // 是否正在响应
const completing = ref<boolean>(false); const completing = ref<boolean>(false);
// 是否正在思考
const thinking = ref<boolean>(false);
// 在线人数 // 在线人数
const onlineCount = ref<number>(0); const onlineCount = ref<number>(0);
// 与 LLM 聊天 // 与 LLM 聊天
const chatWithLLM = async ( const chatWithLLM = async (
request: IChatWithLLMRequest, request: IChatWithLLMRequest,
onProgress: (content: string) => void, // 接收进度回调 onProgress: (content: string) => void, // 接收内容进度回调
getUsageInfo: (object: UsageInfo) => void = () => {} getUsageInfo: (object: UsageInfo) => void = () => {}, // 接收使用信息回调
getThinking: (thinkingContent: string) => void = () => {} // 接收思维链内容回调
) => { ) => {
if (completing.value) throw new Error("正在响应中"); if (completing.value) throw new Error("正在响应中");
@@ -35,6 +38,9 @@ export const useChatStore = defineStore("chat", () => {
}, },
(object: UsageInfo) => { (object: UsageInfo) => {
getUsageInfo(object); getUsageInfo(object);
},
(thinkingContent: string) => {
getThinking(thinkingContent);
} }
); );
} catch (error) { } catch (error) {
@@ -60,6 +66,20 @@ export const useChatStore = defineStore("chat", () => {
historyMessages.value = []; historyMessages.value = [];
}; };
// 确保最后一条消息是助手消息,如果最后一条消息不是,就加一条空的占位,不然后面的思维链会丢失
const ensureAssistantMessage = () => {
if (
historyMessages.value.length === 0 ||
historyMessages.value[historyMessages.value.length - 1].role !==
"assistant"
) {
historyMessages.value.push({
role: "assistant",
content: ""
});
}
};
watch( watch(
historyMessages, historyMessages,
(newVal) => { (newVal) => {
@@ -72,23 +92,15 @@ export const useChatStore = defineStore("chat", () => {
messages: newVal, messages: newVal,
model: modelInfo.value?.model_id model: modelInfo.value?.model_id
}, },
// 处理进度回调,文本
(content) => { (content) => {
// 处理进度回调 ensureAssistantMessage();
if ( thinking.value = false;
historyMessages.value.length === 0 ||
historyMessages.value[historyMessages.value.length - 1].role !==
"assistant"
) {
historyMessages.value.push({
role: "assistant",
content: ""
});
}
historyMessages.value[historyMessages.value.length - 1].content = historyMessages.value[historyMessages.value.length - 1].content =
content; content;
}, },
// 处理使用usage信息回调
(usageInfo: UsageInfo) => { (usageInfo: UsageInfo) => {
// 处理使用usage信息回调
// 如果最后一条消息是助手的回复,则更新使用信息 // 如果最后一条消息是助手的回复,则更新使用信息
if ( if (
historyMessages.value.length > 0 && historyMessages.value.length > 0 &&
@@ -98,6 +110,13 @@ export const useChatStore = defineStore("chat", () => {
historyMessages.value[historyMessages.value.length - 1].usage = historyMessages.value[historyMessages.value.length - 1].usage =
usageInfo; usageInfo;
} }
},
// 处理思维链内容回调
(thinkingContent: string) => {
ensureAssistantMessage();
thinking.value = true;
historyMessages.value[historyMessages.value.length - 1].thinking =
thinkingContent;
} }
); );
} }
@@ -129,6 +148,7 @@ export const useChatStore = defineStore("chat", () => {
getModelList, getModelList,
modelList, modelList,
modelInfo, modelInfo,
onlineCount onlineCount,
thinking
}; };
}); });

View File

@@ -13,7 +13,7 @@ import markdown from "@/components/markdown.vue";
import { useAsrStore, useChatStore, useLayoutStore } from "@/stores"; import { useAsrStore, useChatStore, useLayoutStore } from "@/stores";
const chatStore = useChatStore(); const chatStore = useChatStore();
const { historyMessages, completing, modelList, modelInfo } = const { historyMessages, completing, modelList, modelInfo, thinking } =
storeToRefs(chatStore); storeToRefs(chatStore);
const asrStore = useAsrStore(); const asrStore = useAsrStore();
const { isRecording } = storeToRefs(asrStore); const { isRecording } = storeToRefs(asrStore);
@@ -23,6 +23,38 @@ const { hiddenLeftSidebar, simpleMode } = storeToRefs(layoutStore);
const inputData = ref(""); const inputData = ref("");
const scrollbarRef = ref<HTMLElement | null>(null); const scrollbarRef = ref<HTMLElement | null>(null);
const options = ref<Array<SelectGroupOption | SelectOption>>([]); const options = ref<Array<SelectGroupOption | SelectOption>>([]);
// NCollapse 组件的折叠状态
const collapseActive = ref<string[]>(
historyMessages.value.map((_, idx) => String(idx))
);
const getName = (idx: number) => String(idx);
watch(
historyMessages,
(newVal, oldVal) => {
// 取所有name
const newNames = newVal.map((_, idx) => getName(idx));
const oldNames = oldVal ? oldVal.map((_, idx) => getName(idx)) : [];
// 找出新增的name
const addedNames = newNames.filter((name) => !oldNames.includes(name));
// 保留原有已展开项
const currentActive = collapseActive.value.filter((name) =>
newNames.includes(name)
);
// 新增的默认展开
collapseActive.value = [...currentActive, ...addedNames];
},
{ deep: true }
);
const handleItemHeaderClick = (name: string) => {
if (collapseActive.value.includes(name)) {
collapseActive.value = collapseActive.value.filter((n) => n !== name);
} else {
collapseActive.value.push(name);
}
};
// 处理选中模型的 ID // 处理选中模型的 ID
const selectedModelId = computed({ const selectedModelId = computed({
@@ -111,11 +143,13 @@ onMounted(() => {
<NDivider /> <NDivider />
</div> </div>
</div> </div>
<!-- 默认消息 历史消息 -->
<div <div
v-for="(msg, idx) in historyMessages" v-for="(msg, idx) in historyMessages"
:key="idx" :key="idx"
class="flex items-start mb-4" class="flex items-start mb-4"
> >
<!-- 头像 -->
<span <span
v-if="msg.role === 'user'" v-if="msg.role === 'user'"
class="rounded-lg overflow-hidden !w-16 !min-w-16 !h-16" class="rounded-lg overflow-hidden !w-16 !min-w-16 !h-16"
@@ -125,10 +159,12 @@ onMounted(() => {
<span v-else class="rounded-lg overflow-hidden"> <span v-else class="rounded-lg overflow-hidden">
<avatar :avatar="AIAvatar" /> <avatar :avatar="AIAvatar" />
</span> </span>
<!-- 头像 名称 -->
<div class="text-base w-full max-w-full ml-2 flex flex-col items-start"> <div class="text-base w-full max-w-full ml-2 flex flex-col items-start">
<span class="text-base font-bold">{{ <span class="text-base font-bold">{{
msg.role === "user" ? "你:" : "助手:" msg.role === "user" ? "你:" : "助手:"
}}</span> }}</span>
<!-- 使用信息 -->
<div <div
v-if="msg.role !== 'user'" v-if="msg.role !== 'user'"
class="text-[12px] text-[#7A7A7A] mb-[2px]" class="text-[12px] text-[#7A7A7A] mb-[2px]"
@@ -136,6 +172,23 @@ onMounted(() => {
Tokens: <span class="mr-1">{{ msg.usage?.total_tokens }}</span> Tokens: <span class="mr-1">{{ msg.usage?.total_tokens }}</span>
</div> </div>
<div class="w-full max-w-full"> <div class="w-full max-w-full">
<NCollapse
v-if="msg.thinking?.trim()"
:expanded-names="collapseActive[idx]"
>
<NCollapseItem
:title="thinking && idx === historyMessages.length - 1 ? '思考中...' : '已深度思考'"
:name="getName(idx)"
@item-header-click="() => handleItemHeaderClick(getName(idx))"
>
<div
class="text-[#7A7A7A] mb-4 border-l-2 border-[#E5E5E5] ml-2 pl-2"
>
<markdown :content="msg.thinking || ''" />
</div>
</NCollapseItem>
</NCollapse>
<!-- 内容↓ 思维链↑ -->
<markdown :content="msg.content || ''" /> <markdown :content="msg.content || ''" />
<NDivider /> <NDivider />
</div> </div>
@@ -184,7 +237,7 @@ onMounted(() => {
</template> </template>
<template #trigger> <template #trigger>
<NButton :disabled="isRecording || completing" type="warning"> <NButton :disabled="isRecording || completing" type="warning">
<template v-if="!simpleMode">清除历史</template> <template v-if="!simpleMode"> 清除历史 </template>
<TrashIcon <TrashIcon
class="!w-4 !h-4" class="!w-4 !h-4"
:class="{ :class="{