From c1cd5627bb9967262fed809a27c5b80674347589 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 23 Apr 2026 12:00:10 +0800 Subject: [PATCH 01/41] chore: bump version to 4.23.3 --- astrbot/cli/__init__.py | 2 +- astrbot/core/config/default.py | 2 +- changelogs/v4.23.3.md | 62 ++++++++++++++++++++++++++++++++++ docs/en/use/webui.md | 19 ++++++++++- docs/zh/use/webui.md | 19 ++++++++++- pyproject.toml | 2 +- 6 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 changelogs/v4.23.3.md diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index f09cd959a..f71bb949a 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1 @@ -__version__ = "4.23.2" +__version__ = "4.23.3" diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 09976f7c4..1cdd1d2e4 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ from typing import Any, TypedDict from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.23.2" +VERSION = "4.23.3" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") PERSONAL_WECHAT_CONFIG_METADATA = { "weixin_oc_base_url": { diff --git a/changelogs/v4.23.3.md b/changelogs/v4.23.3.md new file mode 100644 index 000000000..16150d4b2 --- /dev/null +++ b/changelogs/v4.23.3.md @@ -0,0 +1,62 @@ +- [更新日志(简体中文)](#chinese) +- [Changelog(English)](#english) + + + +## What's Changed + +### 新增 + +- WebUI ChatUI 新增消息重新编辑、从重新生成 AI 回复、分支询问面板(划选 AI 回复内容即可看到)与会话 checkpoint 支持。([#7673](https://github.com/AstrBotDevs/AstrBot/pull/7673)) +- WebUI 聊天附件处理新增预览与文件签名校验,改善上传前的附件识别与展示体验,修复用户消息气泡无法正常显示图片的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/0748f0a42)) +- 知识库文档上传新增 EPUB 支持,并补充 EPUB 解析、文件读取与相关测试。([#7594](https://github.com/AstrBotDevs/AstrBot/pull/7594)) +- 非流式 Agent Loop 新增中间工具调用消息过程折叠发送功能。([#7627](https://github.com/AstrBotDevs/AstrBot/pull/7627)) +- 重新内置 `/provider` 命令,支持通过命令管理与查看 Provider 相关信息。([#7691](https://github.com/AstrBotDevs/AstrBot/pull/7691)) +- 插件页新增“有新版本时置顶显示”能力,并支持保存该偏好设置。([#7665](https://github.com/AstrBotDevs/AstrBot/pull/7665)) + +### 优化 + +- 优化 FileReadTool 描述,明确图片、PDF 与 DOCX 支持,并增强工具结果的模态能力检查。([#7506](https://github.com/AstrBotDevs/AstrBot/pull/7506)) +- 统一多处文档链接,修正 CLI、配置、Dashboard 与知识库页面中的文档入口。([#7709](https://github.com/AstrBotDevs/AstrBot/pull/7709)) + +### 修复 + +- 修复部分国内或自定义 OpenAI/Anthropic 兼容模型提供商因 TLS 证书链、代理或模型列表能力差异导致配置不可用的问题。([#7685](https://github.com/AstrBotDevs/AstrBot/pull/7685)) +- 修复模型回复包含 reasoning content 时可能漏发普通回复的问题。([#7715](https://github.com/AstrBotDevs/AstrBot/pull/7715)) +- 修复 QQ 官方 API 在 DNS 或连接瞬时异常下缺少重试导致请求失败的问题。([#7718](https://github.com/AstrBotDevs/AstrBot/pull/7718)) +- 修复 `RegexFilter` 不能同时接受 `str` 与 `re.Pattern` 的问题。([#7633](https://github.com/AstrBotDevs/AstrBot/pull/7633)) +- 修复备份导入器存在路径穿越风险的问题。([#7681](https://github.com/AstrBotDevs/AstrBot/pull/7681)) +- 修复旧版 `documents_fts` 表残留时可能影响知识库 FTS 迁移的问题。([#7706](https://github.com/AstrBotDevs/AstrBot/pull/7706)) +- 修复 MiniMax Token Plan Provider 在已配置模型不在硬编码列表中时直接阻塞的问题,改为警告提示。([#7692](https://github.com/AstrBotDevs/AstrBot/pull/7692)) +- 修复 MCP schema 中无效 `required` 字段未被规范化导致工具 schema 异常的问题。([#6077](https://github.com/AstrBotDevs/AstrBot/pull/6077)) +- 修复视频附件传给 LLM 时的处理问题,并补充相关测试。([#7679](https://github.com/AstrBotDevs/AstrBot/pull/7679)) + + + +## What's Changed (EN) + +### New Features + +- Added inline message editing, regeneration from a selected message, a thread panel, and conversation checkpoint support to the WebUI chat page. ([#7673](https://github.com/AstrBotDevs/AstrBot/pull/7673)) +- Added chat attachment previews and file signature checks in WebUI to improve attachment recognition and display before upload. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/0748f0a42)) +- Added buffered intermediate messages for the non-streaming Agent Loop, allowing process messages such as tool calls to be surfaced in non-streaming scenarios. ([#7627](https://github.com/AstrBotDevs/AstrBot/pull/7627)) +- Reintroduced `/provider` as a built-in command for viewing and managing Provider-related information. ([#7691](https://github.com/AstrBotDevs/AstrBot/pull/7691)) +- Added an option to pin installed plugins with available updates to the top of the plugin page, with persisted preference storage. ([#7665](https://github.com/AstrBotDevs/AstrBot/pull/7665)) +- Added EPUB support for knowledge-base document upload, including EPUB parsing, file reading support, and tests. ([#7594](https://github.com/AstrBotDevs/AstrBot/pull/7594)) + +### Improvements + +- Updated the FileReadTool description to mention image, PDF, and DOCX support, and improved modality checks for tool results. ([#7506](https://github.com/AstrBotDevs/AstrBot/pull/7506)) +- Unified documentation links across CLI output, configuration, Dashboard, and knowledge-base pages. ([#7709](https://github.com/AstrBotDevs/AstrBot/pull/7709)) + +### Bug Fixes + +- Fixed missing normal replies when model output contains reasoning content. ([#7715](https://github.com/AstrBotDevs/AstrBot/pull/7715)) +- Added retries for transient DNS and connection errors in the QQ Official API flow. ([#7718](https://github.com/AstrBotDevs/AstrBot/pull/7718)) +- Fixed `RegexFilter` so it accepts both `str` and `re.Pattern`. ([#7633](https://github.com/AstrBotDevs/AstrBot/pull/7633)) +- Fixed a path traversal vulnerability in the backup importer. ([#7681](https://github.com/AstrBotDevs/AstrBot/pull/7681)) +- Dropped legacy `documents_fts` tables when present to avoid knowledge-base FTS migration issues. ([#7706](https://github.com/AstrBotDevs/AstrBot/pull/7706)) +- Fixed configuration failures for some domestic or custom OpenAI/Anthropic-compatible model providers caused by TLS certificate chains, proxy handling, or model-list capability differences. ([#7685](https://github.com/AstrBotDevs/AstrBot/pull/7685)) +- Changed MiniMax Token Plan Provider behavior to warn instead of blocking when a configured model is not in the hardcoded model list. ([#7692](https://github.com/AstrBotDevs/AstrBot/pull/7692)) +- Normalized invalid `required` flags in MCP schemas to avoid tool schema errors. ([#6077](https://github.com/AstrBotDevs/AstrBot/pull/6077)) +- Fixed video attachment handling for LLM requests and added related tests. ([#7679](https://github.com/AstrBotDevs/AstrBot/pull/7679)) diff --git a/docs/en/use/webui.md b/docs/en/use/webui.md index 986946370..dd17994c0 100644 --- a/docs/en/use/webui.md +++ b/docs/en/use/webui.md @@ -1,4 +1,4 @@ -# Admin Panel +# WebUI The AstrBot admin panel features plugin management, log viewing, visual configuration, statistics viewing, and more. @@ -15,6 +15,23 @@ After starting AstrBot, you can access the admin panel by visiting `http://local The default username and password are both `astrbot`. +## ChatUI + +AstrBot includes a built-in ChatUI for talking to configured models directly in your browser. + +ChatUI supports these common workflows: + +- Create, rename, and delete conversations, and switch previous conversations from the sidebar. +- Select the config profile, model provider, and model on the chat page; if Provider session separation is enabled, you can also choose a model for the current session only. +- Send text, images, files, and voice input; uploaded attachments show previews and use file signature checks to help identify file types. +- View model thinking, tool-call status, knowledge-base or web-search references, and per-message token and latency statistics. +- Copy or regenerate existing replies, including regenerating with another model. +- Edit a user message and continue generation from that point, or start a thread from a specific excerpt. +- Switch between streaming/normal response modes and SSE/WebSocket transport modes. + +> [!NOTE] +> To keep message delivery ordered, keep only one ChatUI page open for the same browser session. If you open chat in multiple tabs, the system may ask you to reconnect. + ## Visual Configuration In the admin panel, you can configure AstrBot's plugins through visual configuration. Click `Configuration` in the left sidebar to enter the configuration page. diff --git a/docs/zh/use/webui.md b/docs/zh/use/webui.md index f52f4a3ff..972538056 100644 --- a/docs/zh/use/webui.md +++ b/docs/zh/use/webui.md @@ -1,4 +1,4 @@ -# 管理面板 +# WebUI AstrBot 管理面板具有管理插件、查看日志、可视化配置、查看统计信息等功能。 @@ -15,6 +15,23 @@ AstrBot 管理面板具有管理插件、查看日志、可视化配置、查看 默认用户名和密码是 `astrbot` 和 `astrbot`。 +## ChatUI + +AstrBot 内置 ChatUI,可在浏览器中直接与已配置的模型对话。 + +ChatUI 支持以下常用能力: + +- 创建、重命名、删除对话,并在左侧会话列表中切换历史对话。 +- 在聊天页选择配置文件、模型提供商与模型;如果启用了 Provider 会话隔离,也可以为当前会话单独选择模型。 +- 发送文本、图片、文件与语音输入;上传附件时会展示预览,并根据文件签名辅助识别文件类型。 +- 查看模型思考过程、工具调用状态、知识库或网页搜索引用来源,以及每条回复的 Token 和耗时统计。 +- 对已有回复执行复制、重新生成,或使用其他模型重新生成。 +- 编辑用户消息后从该消息继续生成,也可以针对某段内容开启分支追问。 +- 切换流式/普通响应模式,以及 SSE/WebSocket 通信模式。 + +> [!NOTE] +> 为保证消息接收顺序,同一浏览器会话建议只保留一个 ChatUI 页面。如果你在多个标签页中同时打开聊天页面,系统可能会提示需要重新建立连接。 + ## 可视化配置 在管理面板中,你可以通过可视化配置来配置 AstrBot 的插件。点击左栏 `配置` 即可进入配置页面。 diff --git a/pyproject.toml b/pyproject.toml index ce59ac9ac..459b59cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "AstrBot" -version = "4.23.2" +version = "4.23.3" description = "Easy-to-use multi-platform LLM chatbot and development framework" readme = "README.md" license = { text = "AGPL-3.0-or-later" } From 56435b5c1763aef8d9a12d9a528cee90f96b1932 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:03:52 +0800 Subject: [PATCH 02/41] chore: delete video-fix.patch --- video-fix.patch | Bin 5236 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 video-fix.patch diff --git a/video-fix.patch b/video-fix.patch deleted file mode 100644 index 77f879683edd8351a4bf078a3b55d5a8c5786bf8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5236 zcmd6rTTdHD6vxkVrG5u1eF1?TU%(hzmypC(qXY#7niqstz5*>V#C94`o3GyX_n+fo zZDX*3RH;Vx%+8!S_uKA2f7h*V=k~!e*0nV&THeZxwzX{CnpUw6qs3X%Hf+-xz%&?L zD*#hurwLqxb(#MT@MU%@z!t!`3+5j4J|`E<1@<#g$lKP~eL2u7#cTu-1OXUWT1A|C?YE%ud)DhY@5A9SSDTzqI`a`VfBcHk)=E zUeCi#$0bUe0ZWbhk(=j8n_?I3O5nH#tl&j^GpuWz=$1=Tzox!xudsLxsoz7fZv!}! z{iXK;HpvGbUxYo`qlf;o=;;a@xOKF9$?j8j&aj#+{Uf(qSi2E0b~vq$X;)I;#MlSA z2mNl~BMVQx;1TVS?}(!cRy+lpFzA;J!?oDp?htxScojGDrariyg4a(HyvzAZn2$M= zG&%Ub0{34*;x+zPZXMZ?#pGdKI5`7HlvMuRA*S3A)QM;NpJ~QdGx-4zbU7b(0z^}ODgNM z73J@b z_vO&QUEY@jA02?@6RQ?C22ef4Q!j%5%Hm5SugkF3IIy>jHMxlyLr*oK-^}j~Vwh`SN@@6i+=emuCrOXZLAE)iL*{x%LRZ2`|do zg(!Xq+8B&tHP#2|rN@b4_olAuaHXhJ6ut`auBg=0J7&)b9;13z#<~i+SovRZB!1r` ziB|!))gm@hPF4<=h8oygPqJ*EwY<^gown4pe|bJvK326%xHXBjg|Et|<@u8|CM}j+ z>vS4N^uOrQ%94+xB#Fg~{7s(W6k-gk)7YSTtde__-_z$p=oTDKk$T1L7BJGhGH(-E z+i+h1wH4q&~;0vV#=EV1pAVyHu)yvaFsn_9u57I=SBc5`6sv zm?8Cd5+?3`q2qZnl~WewH)f3c!LPu{-9vRy)lR;t9z>N>(#skhGN2+?IdL*Fmg!Rz z9rN)=>gv2as^pVVOI7GL>@z>Fv5}hRoS60Gq4Ik9b<}&ru{5gNi*urQRM(qETN2_* zv6cjR<;QM!=T&{gAXJo#5R60Nr?3CtKd+cQD7})BNb#m$#|D$Ttq>+>Kg| zexr)2tgGJ8{Z@Xhx2iNl<)*k!fYdw6^uBH`78hwna}B21uSAwBk7d77cvo6wSAFWY z%YIRBah_-S2sdwjEFQnVxW`6*=4G*2KK9s*7HP+MRvfw)k7u}9ocx#BE^-%V(f@YY zFLDv*o7wPwRVCuNZC0MTPi~Fb;;t;GUSP5O`*mb_XiP+%#F+GMHtyawE63N#<7bQ} zbIX2bXHp+}ONtpkt)l8#{mmrJ!l}1sIcxL&p?*=lzw7Xd^*OlOWPHV==kCvRP^!O+ zbKHR=eer5DyfUSf<97nD11Wt~2vvIjGNFp&-%-@}oN%Vz)BA6|3F?jVn4fA=J@Bcn zr4Nk0-0AHxR>7kn$>G>r&=EaKE=Z&ACYi7*va?PV(3hSZ9gx4%pR-b@Q3^1U|6eu3 B=2ZXy From 2f479b52043fade09db94649047d84ee4de6ce0f Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:42:45 +0800 Subject: [PATCH 03/41] fix: add missing platform adapter filter types (#7738) --- .../core/star/filter/platform_adapter_type.py | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/astrbot/core/star/filter/platform_adapter_type.py b/astrbot/core/star/filter/platform_adapter_type.py index 55043cbe3..48c5bc8ff 100644 --- a/astrbot/core/star/filter/platform_adapter_type.py +++ b/astrbot/core/star/filter/platform_adapter_type.py @@ -9,6 +9,7 @@ from . import HandlerFilter class PlatformAdapterType(enum.Flag): AIOCQHTTP = enum.auto() QQOFFICIAL = enum.auto() + QQOFFICIAL_WEBHOOK = enum.auto() TELEGRAM = enum.auto() WECOM = enum.auto() WECOM_AI_BOT = enum.auto() @@ -23,29 +24,16 @@ class PlatformAdapterType(enum.Flag): MISSKEY = enum.auto() LINE = enum.auto() MATRIX = enum.auto() - ALL = ( - AIOCQHTTP - | QQOFFICIAL - | TELEGRAM - | WECOM - | WECOM_AI_BOT - | LARK - | DINGTALK - | DISCORD - | SLACK - | KOOK - | VOCECHAT - | WEIXIN_OFFICIAL_ACCOUNT - | SATORI - | MISSKEY - | LINE - | MATRIX - ) + WEIXIN_OC = enum.auto() + MATTERMOST = enum.auto() + WEBCHAT = enum.auto() + ALL = enum.auto() ADAPTER_NAME_2_TYPE = { "aiocqhttp": PlatformAdapterType.AIOCQHTTP, "qq_official": PlatformAdapterType.QQOFFICIAL, + "qq_official_webhook": PlatformAdapterType.QQOFFICIAL_WEBHOOK, "telegram": PlatformAdapterType.TELEGRAM, "wecom": PlatformAdapterType.WECOM, "wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT, @@ -60,6 +48,9 @@ ADAPTER_NAME_2_TYPE = { "misskey": PlatformAdapterType.MISSKEY, "line": PlatformAdapterType.LINE, "matrix": PlatformAdapterType.MATRIX, + "weixin_oc": PlatformAdapterType.WEIXIN_OC, + "mattermost": PlatformAdapterType.MATTERMOST, + "webchat": PlatformAdapterType.WEBCHAT, } @@ -71,6 +62,12 @@ class PlatformAdapterTypeFilter(HandlerFilter): self.platform_type = platform_adapter_type_or_str def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool: + if ( + self.platform_type is not None + and self.platform_type & PlatformAdapterType.ALL + ): + return True + adapter_name = event.get_platform_name() if adapter_name in ADAPTER_NAME_2_TYPE and self.platform_type is not None: return bool(ADAPTER_NAME_2_TYPE[adapter_name] & self.platform_type) From bb6619f38c065fb068cafa126e07576e9fc38885 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:46:53 +0800 Subject: [PATCH 04/41] perf: improve tool calls in reasoning and multiple tool calls display (#7742) * perf: improve tool calls in reasoning and multiple tool calls display - Updated LiveChatRoute and OpenApiRoute to replace manual message accumulation with BotMessageAccumulator. - Simplified message saving logic by using build_bot_history_content and collect_plain_text_from_message_parts. - Enhanced message processing to handle various message types (plain, image, record, file, video) more efficiently. - Improved reasoning handling by extracting thinking parts and displaying them correctly in the UI components. - Refactored message normalization and reasoning extraction logic in useMessages composable for better clarity and maintainability. - Updated ChatMessageList, MessageList, StandaloneChat, and ReasoningBlock components to accommodate new message structure and rendering logic. * feat(chat): reasoning activity panel - Introduced a new ReasoningSidebar component for displaying reasoning details. - Refactored MessageList and StandaloneChat components to utilize renderBlocks for improved message part handling. - Added ReasoningTimeline component to visualize reasoning steps. - Updated message handling logic to differentiate between thinking and content blocks. - Enhanced localization for reasoning-related terms in English, Russian, and Chinese. - Improved styling for various components to ensure consistency and readability. * Update astrbot/dashboard/routes/chat.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../agent/runners/tool_loop_agent_runner.py | 89 +++--- astrbot/core/astr_agent_run_util.py | 6 + .../core/provider/sources/openai_source.py | 2 + astrbot/dashboard/routes/chat.py | 294 +++++++++++++----- astrbot/dashboard/routes/live_chat.py | 105 ++----- astrbot/dashboard/routes/open_api.py | 85 +++-- dashboard/src/components/chat/Chat.vue | 63 ++++ .../src/components/chat/ChatMessageList.vue | 256 ++++++++------- dashboard/src/components/chat/MessageList.vue | 249 ++++++++------- .../src/components/chat/ReasoningSidebar.vue | 119 +++++++ .../src/components/chat/StandaloneChat.vue | 197 +++++++----- dashboard/src/components/chat/ThreadPanel.vue | 103 ++---- .../message_list_comps/IPythonToolBlock.vue | 8 +- .../message_list_comps/ReasoningBlock.vue | 187 ++++++----- .../message_list_comps/ReasoningTimeline.vue | 265 ++++++++++++++++ .../chat/message_list_comps/ToolCallItem.vue | 5 +- dashboard/src/composables/useMessages.ts | 205 ++++++++++-- .../src/i18n/locales/en-US/features/chat.json | 4 +- .../src/i18n/locales/ru-RU/features/chat.json | 4 +- .../src/i18n/locales/zh-CN/features/chat.json | 4 +- 20 files changed, 1500 insertions(+), 750 deletions(-) create mode 100644 dashboard/src/components/chat/ReasoningSidebar.vue create mode 100644 dashboard/src/components/chat/message_list_comps/ReasoningTimeline.vue diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 81b82403e..cf70b4150 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -717,6 +717,15 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): if self.stats.time_to_first_token == 0: self.stats.time_to_first_token = time.time() - self.stats.start_time + if llm_response.reasoning_content: + yield AgentResponse( + type="streaming_delta", + data=AgentResponseData( + chain=MessageChain(type="reasoning").message( + llm_response.reasoning_content, + ), + ), + ) if llm_response.result_chain: yield AgentResponse( type="streaming_delta", @@ -729,15 +738,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): chain=MessageChain().message(llm_response.completion_text), ), ) - if llm_response.reasoning_content: - yield AgentResponse( - type="streaming_delta", - data=AgentResponseData( - chain=MessageChain(type="reasoning").message( - llm_response.reasoning_content, - ), - ), - ) if self._is_stop_requested(): llm_resp_result = LLMResponse( role="assistant", @@ -791,6 +791,15 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): await self._complete_with_assistant_response(llm_resp) # 返回 LLM 结果 + if llm_resp.reasoning_content: + yield AgentResponse( + type="llm_result", + data=AgentResponseData( + chain=MessageChain(type="reasoning").message( + llm_resp.reasoning_content, + ), + ), + ) if llm_resp.result_chain: yield AgentResponse( type="llm_result", @@ -803,15 +812,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): chain=MessageChain().message(llm_resp.completion_text), ), ) - if llm_resp.reasoning_content: - yield AgentResponse( - type="llm_result", - data=AgentResponseData( - chain=MessageChain(type="reasoning").message( - llm_resp.reasoning_content, - ), - ), - ) # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: @@ -821,6 +821,15 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): logger.warning( "skills_like tool re-query returned no tool calls; fallback to assistant response." ) + if llm_resp.reasoning_content: + yield AgentResponse( + type="llm_result", + data=AgentResponseData( + chain=MessageChain(type="reasoning").message( + llm_resp.reasoning_content, + ), + ), + ) if llm_resp.result_chain: yield AgentResponse( type="llm_result", @@ -833,15 +842,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): chain=MessageChain().message(llm_resp.completion_text), ), ) - if llm_resp.reasoning_content: - yield AgentResponse( - type="llm_result", - data=AgentResponseData( - chain=MessageChain(type="reasoning").message( - llm_resp.reasoning_content, - ), - ), - ) + await self._complete_with_assistant_response(llm_resp) return @@ -988,6 +989,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): llm_response.tools_call_args, llm_response.tools_call_ids, ): + tool_result_blocks_start = len(tool_call_result_blocks) tool_call_streak = self._track_tool_call_streak(func_tool_name) yield _HandleFunctionToolsResult.from_message_chain( MessageChain( @@ -1201,24 +1203,23 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ), ) - # yield the last tool call result - if tool_call_result_blocks: - last_tcr_content = str(tool_call_result_blocks[-1].content) - yield _HandleFunctionToolsResult.from_message_chain( - MessageChain( - type="tool_call_result", - chain=[ - Json( - data={ - "id": func_tool_id, - "ts": time.time(), - "result": last_tcr_content, - } - ) - ], + if len(tool_call_result_blocks) > tool_result_blocks_start: + tool_result_content = str(tool_call_result_blocks[-1].content) + yield _HandleFunctionToolsResult.from_message_chain( + MessageChain( + type="tool_call_result", + chain=[ + Json( + data={ + "id": func_tool_id, + "ts": time.time(), + "result": tool_result_content, + } + ) + ], + ) ) - ) - logger.info(f"Tool `{func_tool_name}` Result: {last_tcr_content}") + logger.info(f"Tool `{func_tool_name}` Result: {tool_result_content}") # 处理函数调用响应 if tool_call_result_blocks: diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index 62c60a436..6bdf3011b 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -235,6 +235,12 @@ async def run_agent( ) await astr_event.send(chain) continue + elif resp.type == "llm_result": + chain = resp.data["chain"] + if chain.type == "reasoning": + # For non-streaming mode, we handle reasoning in astrbot/core/astr_agent_hooks.py. + # For streaming mode, we yield content immediately when received a reasoning chunk but not in here, see below. + continue if stream_to_general and resp.type == "streaming_delta": continue diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 67971a2a9..f2d947490 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -652,6 +652,8 @@ class ProviderOpenAIOfficial(Provider): reasoning = self._extract_reasoning_content(chunk) _y = False llm_response.id = chunk.id + llm_response.reasoning_content = "" + llm_response.completion_text = "" if reasoning: llm_response.reasoning_content = reasoning _y = True diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index d7d4777ac..d4174ffb5 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -5,7 +5,7 @@ import re import uuid from contextlib import asynccontextmanager from copy import deepcopy -from typing import cast +from typing import Any, cast from quart import Response as QuartResponse from quart import g, make_response, request, send_file @@ -58,6 +58,179 @@ async def _poll_webchat_stream_result(back_queue, username: str): return result, False +def normalize_legacy_reasoning_message_parts( + message_parts: list[dict] | None, + reasoning: str = "", +) -> list[dict]: + parts: list[dict] = [] + for part in message_parts or []: + if not isinstance(part, dict): + continue + copied = dict(part) + if copied.get("type") == "reasoning": + copied = {"type": "think", "think": copied.get("text", "")} + parts.append(copied) + if reasoning and not any(part.get("type") == "think" for part in parts): + parts.insert(0, {"type": "think", "think": reasoning}) + return parts + + +def extract_reasoning_from_message_parts(message_parts: list[dict]) -> str: + reasoning_parts: list[str] = [] + for part in message_parts: + if part.get("type") != "think": + continue + think = part.get("think") + if isinstance(think, str) and think: + reasoning_parts.append(think) + return "".join(reasoning_parts) + + +def collect_plain_text_from_message_parts(message_parts: list[dict]) -> str: + text_parts: list[str] = [] + for part in message_parts: + if part.get("type") != "plain": + continue + text = part.get("text") + if isinstance(text, str) and text: + text_parts.append(text) + return "".join(text_parts) + + +def build_bot_history_content( + message_parts: list[dict], + *, + agent_stats: dict | None = None, + refs: dict | None = None, + include_legacy_reasoning_field: bool = True, +) -> dict[str, Any]: + normalized_parts = normalize_legacy_reasoning_message_parts(message_parts) + content: dict[str, Any] = {"type": "bot", "message": normalized_parts} + reasoning = extract_reasoning_from_message_parts(normalized_parts) + if reasoning and include_legacy_reasoning_field: + # Keep the legacy field for old clients while the canonical structure + # moves to message parts. + content["reasoning"] = reasoning + if agent_stats: + content["agent_stats"] = agent_stats + if refs: + content["refs"] = refs + return content + + +class BotMessageAccumulator: + def __init__(self) -> None: + self.parts: list[dict] = [] + self.pending_text = "" + self.pending_tool_calls: dict[str, dict] = {} + + def has_content(self) -> bool: + return bool(self.parts or self.pending_text or self.pending_tool_calls) + + def add_plain( + self, + result_text: str, + *, + chain_type: str | None, + streaming: bool, + ) -> None: + if chain_type == "tool_call": + self._flush_pending_text() + self._store_tool_call(result_text) + return + + if chain_type == "tool_call_result": + self._flush_pending_text() + self._store_tool_call_result(result_text) + return + + if chain_type == "reasoning": + self._flush_pending_text() + self._append_think_part(result_text) + return + + if streaming: + self.pending_text += result_text + else: + self.pending_text = result_text + + def add_attachment(self, part: dict | None) -> None: + if not part: + return + self._flush_pending_text() + self.parts.append(part) + + def build_message_parts( + self, *, include_pending_tool_calls: bool = False + ) -> list[dict]: + self._flush_pending_text() + if include_pending_tool_calls and self.pending_tool_calls: + for tool_call in self.pending_tool_calls.values(): + self.parts.append({"type": "tool_call", "tool_calls": [tool_call]}) + self.pending_tool_calls = {} + return self.parts + + def plain_text(self) -> str: + return collect_plain_text_from_message_parts(self.build_message_parts()) + + def reasoning_text(self) -> str: + return extract_reasoning_from_message_parts(self.build_message_parts()) + + def _flush_pending_text(self) -> None: + if not self.pending_text: + return + + if self.parts and self.parts[-1].get("type") == "plain": + last_text = self.parts[-1].get("text") + self.parts[-1]["text"] = f"{last_text or ''}{self.pending_text}" + else: + self.parts.append({"type": "plain", "text": self.pending_text}) + self.pending_text = "" + + def _append_think_part(self, text: str) -> None: + if not text: + return + + if self.parts and self.parts[-1].get("type") == "think": + last_text = self.parts[-1].get("think") + self.parts[-1]["think"] = f"{last_text or ''}{text}" + else: + self.parts.append({"type": "think", "think": text}) + + def _store_tool_call(self, result_text: str) -> None: + tool_call = self._parse_json_object(result_text) + if not tool_call: + return + tool_call_id = str(tool_call.get("id") or "") + if not tool_call_id: + return + self.pending_tool_calls[tool_call_id] = tool_call + + def _store_tool_call_result(self, result_text: str) -> None: + tool_result = self._parse_json_object(result_text) + if not tool_result: + return + + tool_call_id = str(tool_result.get("id") or "") + if not tool_call_id: + return + + tool_call = self.pending_tool_calls.pop(tool_call_id, None) or { + "id": tool_call_id + } + tool_call["result"] = tool_result.get("result") + tool_call["finished_ts"] = tool_result.get("ts") + self.parts.append({"type": "tool_call", "tool_calls": [tool_call]}) + + @staticmethod + def _parse_json_object(raw_text: str) -> dict | None: + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + + class ChatRoute(Route): def __init__( self, @@ -519,27 +692,18 @@ class ChatRoute(Route): async def _save_bot_message( self, webchat_conv_id: str, - text: str, - media_parts: list, - reasoning: str, + message_parts: list[dict], agent_stats: dict, refs: dict, llm_checkpoint_id: str | None = None, platform_history_id: str = "webchat", ): """保存 bot 消息到历史记录,返回保存的记录""" - bot_message_parts = [] - bot_message_parts.extend(media_parts) - if text: - bot_message_parts.append({"type": "plain", "text": text}) - - new_his = {"type": "bot", "message": bot_message_parts} - if reasoning: - new_his["reasoning"] = reasoning - if agent_stats: - new_his["agent_stats"] = agent_stats - if refs: - new_his["refs"] = refs + new_his = build_bot_history_content( + message_parts, + agent_stats=agent_stats, + refs=refs, + ) record = await self.platform_history_mgr.insert( platform_id=platform_history_id, @@ -599,10 +763,7 @@ class ChatRoute(Route): async def stream(): client_disconnected = False - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" - tool_calls = {} + message_accumulator = BotMessageAccumulator() agent_stats = {} refs = {} try: @@ -683,76 +844,61 @@ class ChatRoute(Route): # 累积消息部分 if msg_type == "plain": - chain_type = result.get("chain_type") - if chain_type == "tool_call": - tool_call = json.loads(result_text) - tool_calls[tool_call.get("id")] = tool_call - if accumulated_text: - # 如果累积了文本,则先保存文本 - accumulated_parts.append( - {"type": "plain", "text": accumulated_text} - ) - accumulated_text = "" - elif chain_type == "tool_call_result": - tcr = json.loads(result_text) - tc_id = tcr.get("id") - if tc_id in tool_calls: - tool_calls[tc_id]["result"] = tcr.get("result") - tool_calls[tc_id]["finished_ts"] = tcr.get("ts") - accumulated_parts.append( - { - "type": "tool_call", - "tool_calls": [tool_calls[tc_id]], - } - ) - tool_calls.pop(tc_id, None) - elif chain_type == "reasoning": - accumulated_reasoning += result_text - elif streaming: - accumulated_text += result_text - else: - accumulated_text = result_text + message_accumulator.add_plain( + result_text, + chain_type=chain_type, + streaming=streaming, + ) elif msg_type == "image": filename = result_text.replace("[IMAGE]", "") part = await self._create_attachment_from_file( filename, "image" ) - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) elif msg_type == "record": filename = result_text.replace("[RECORD]", "") part = await self._create_attachment_from_file( filename, "record" ) - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) elif msg_type == "file": # 格式: [FILE]filename filename = result_text.replace("[FILE]", "") part = await self._create_attachment_from_file( filename, "file" ) - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) + elif msg_type == "video": + filename = result_text.replace("[VIDEO]", "") + part = await self._create_attachment_from_file( + filename, "video" + ) + message_accumulator.add_attachment(part) - # 消息结束处理 + should_save = False if msg_type == "end": - break - elif ( - (streaming and msg_type == "complete") or not streaming - # or msg_type == "break" - ): - if ( - chain_type == "tool_call" - or chain_type == "tool_call_result" - ): - continue + should_save = message_accumulator.has_content() or bool( + refs or agent_stats + ) + elif (streaming and msg_type == "complete") or not streaming: + if chain_type not in ("tool_call", "tool_call_result"): + should_save = True + + if should_save: + message_parts_to_save = ( + message_accumulator.build_message_parts( + include_pending_tool_calls=True + ) + ) + plain_text = collect_plain_text_from_message_parts( + message_parts_to_save + ) # 提取 web_search_tavily 引用 try: refs = self._extract_web_search_refs( - accumulated_text, - accumulated_parts, + plain_text, + message_parts_to_save, ) except Exception as e: logger.exception( @@ -762,9 +908,7 @@ class ChatRoute(Route): saved_record = await self._save_bot_message( webchat_conv_id, - accumulated_text, - accumulated_parts, - accumulated_reasoning, + message_parts_to_save, agent_stats, refs, llm_checkpoint_id, @@ -786,12 +930,12 @@ class ChatRoute(Route): yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n" except Exception: pass - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" - # tool_calls = {} + message_accumulator = BotMessageAccumulator() agent_stats = {} refs = {} + + if msg_type == "end": + break except BaseException as e: logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) finally: diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py index 16c605848..8f4dc26fa 100644 --- a/astrbot/dashboard/routes/live_chat.py +++ b/astrbot/dashboard/routes/live_chat.py @@ -23,6 +23,11 @@ from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queu from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path from astrbot.core.utils.datetime_utils import to_utc_isoformat +from .chat import ( + BotMessageAccumulator, + build_bot_history_content, + collect_plain_text_from_message_parts, +) from .route import Route, RouteContext @@ -250,26 +255,17 @@ class LiveChatRoute(Route): async def _save_bot_message( self, webchat_conv_id: str, - text: str, - media_parts: list, - reasoning: str, + message_parts: list[dict], agent_stats: dict, refs: dict, llm_checkpoint_id: str | None = None, ): """保存 bot 消息到历史记录。""" - bot_message_parts = [] - bot_message_parts.extend(media_parts) - if text: - bot_message_parts.append({"type": "plain", "text": text}) - - new_his = {"type": "bot", "message": bot_message_parts} - if reasoning: - new_his["reasoning"] = reasoning - if agent_stats: - new_his["agent_stats"] = agent_stats - if refs: - new_his["refs"] = refs + new_his = build_bot_history_content( + message_parts, + agent_stats=agent_stats, + refs=refs, + ) return await self.platform_history_mgr.insert( platform_id="webchat", @@ -499,10 +495,7 @@ class LiveChatRoute(Route): }, ) - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" - tool_calls = {} + message_accumulator = BotMessageAccumulator() agent_stats = {} refs = {} @@ -545,68 +538,32 @@ class LiveChatRoute(Route): await self._send_chat_payload(session, outgoing) if msg_type == "plain": - if chain_type == "tool_call": - try: - tool_call = json.loads(result_text) - tool_calls[tool_call.get("id")] = tool_call - if accumulated_text: - accumulated_parts.append( - {"type": "plain", "text": accumulated_text} - ) - accumulated_text = "" - except Exception: - pass - elif chain_type == "tool_call_result": - try: - tcr = json.loads(result_text) - tc_id = tcr.get("id") - if tc_id in tool_calls: - tool_calls[tc_id]["result"] = tcr.get("result") - tool_calls[tc_id]["finished_ts"] = tcr.get("ts") - accumulated_parts.append( - { - "type": "tool_call", - "tool_calls": [tool_calls[tc_id]], - } - ) - tool_calls.pop(tc_id, None) - except Exception: - pass - elif chain_type == "reasoning": - accumulated_reasoning += result_text - elif streaming: - accumulated_text += result_text - else: - accumulated_text = result_text + message_accumulator.add_plain( + result_text, + chain_type=chain_type, + streaming=streaming, + ) elif msg_type == "image": filename = str(result_text).replace("[IMAGE]", "") part = await self._create_attachment_from_file(filename, "image") - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) elif msg_type == "record": filename = str(result_text).replace("[RECORD]", "") part = await self._create_attachment_from_file(filename, "record") - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) elif msg_type == "file": filename = str(result_text).replace("[FILE]", "").split("|", 1)[0] part = await self._create_attachment_from_file(filename, "file") - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) elif msg_type == "video": filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0] part = await self._create_attachment_from_file(filename, "video") - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) should_save = False if msg_type == "end": should_save = bool( - accumulated_parts - or accumulated_text - or accumulated_reasoning - or refs - or agent_stats + message_accumulator.has_content() or refs or agent_stats ) elif (streaming and msg_type == "complete") or not streaming: if chain_type not in ( @@ -617,10 +574,16 @@ class LiveChatRoute(Route): should_save = True if should_save: + message_parts_to_save = message_accumulator.build_message_parts( + include_pending_tool_calls=True + ) + plain_text = collect_plain_text_from_message_parts( + message_parts_to_save + ) try: refs = self._extract_web_search_refs( - accumulated_text, - accumulated_parts, + plain_text, + message_parts_to_save, ) except Exception as e: logger.exception( @@ -630,9 +593,7 @@ class LiveChatRoute(Route): saved_record = await self._save_bot_message( session_id, - accumulated_text, - accumulated_parts, - accumulated_reasoning, + message_parts_to_save, agent_stats, refs, llm_checkpoint_id, @@ -653,9 +614,7 @@ class LiveChatRoute(Route): }, ) - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" + message_accumulator = BotMessageAccumulator() agent_stats = {} refs = {} diff --git a/astrbot/dashboard/routes/open_api.py b/astrbot/dashboard/routes/open_api.py index 8f2047326..52b412b2b 100644 --- a/astrbot/dashboard/routes/open_api.py +++ b/astrbot/dashboard/routes/open_api.py @@ -18,7 +18,11 @@ from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queu from astrbot.core.utils.datetime_utils import to_utc_isoformat from .api_key import ALL_OPEN_API_SCOPES -from .chat import ChatRoute +from .chat import ( + BotMessageAccumulator, + ChatRoute, + collect_plain_text_from_message_parts, +) from .route import Response, Route, RouteContext @@ -363,10 +367,7 @@ class OpenApiRoute(Route): } ) - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" - tool_calls = {} + message_accumulator = BotMessageAccumulator() agent_stats = {} refs = {} while True: @@ -402,68 +403,56 @@ class OpenApiRoute(Route): await websocket.send_json(result) if msg_type == "plain": - if chain_type == "tool_call": - tool_call = json.loads(result_text) - tool_calls[tool_call.get("id")] = tool_call - if accumulated_text: - accumulated_parts.append( - {"type": "plain", "text": accumulated_text} - ) - accumulated_text = "" - elif chain_type == "tool_call_result": - tcr = json.loads(result_text) - tc_id = tcr.get("id") - if tc_id in tool_calls: - tool_calls[tc_id]["result"] = tcr.get("result") - tool_calls[tc_id]["finished_ts"] = tcr.get("ts") - accumulated_parts.append( - {"type": "tool_call", "tool_calls": [tool_calls[tc_id]]} - ) - tool_calls.pop(tc_id, None) - elif chain_type == "reasoning": - accumulated_reasoning += result_text - elif streaming: - accumulated_text += result_text - else: - accumulated_text = result_text + message_accumulator.add_plain( + result_text, + chain_type=chain_type, + streaming=streaming, + ) elif msg_type == "image": filename = str(result_text).replace("[IMAGE]", "") part = await self.chat_route._create_attachment_from_file( filename, "image" ) - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) elif msg_type == "record": filename = str(result_text).replace("[RECORD]", "") part = await self.chat_route._create_attachment_from_file( filename, "record" ) - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) elif msg_type == "file": filename = str(result_text).replace("[FILE]", "") part = await self.chat_route._create_attachment_from_file( filename, "file" ) - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) elif msg_type == "video": filename = str(result_text).replace("[VIDEO]", "") part = await self.chat_route._create_attachment_from_file( filename, "video" ) - if part: - accumulated_parts.append(part) + message_accumulator.add_attachment(part) + should_save = False if msg_type == "end": - break - if (streaming and msg_type == "complete") or not streaming: - if chain_type in ("tool_call", "tool_call_result"): - continue + should_save = bool( + message_accumulator.has_content() or refs or agent_stats + ) + elif (streaming and msg_type == "complete") or not streaming: + if chain_type not in ("tool_call", "tool_call_result"): + should_save = True + + if should_save: + message_parts_to_save = message_accumulator.build_message_parts( + include_pending_tool_calls=True + ) + plain_text = collect_plain_text_from_message_parts( + message_parts_to_save + ) try: refs = self.chat_route._extract_web_search_refs( - accumulated_text, - accumulated_parts, + plain_text, + message_parts_to_save, ) except Exception as e: logger.exception( @@ -473,9 +462,7 @@ class OpenApiRoute(Route): saved_record = await self.chat_route._save_bot_message( session_id, - accumulated_text, - accumulated_parts, - accumulated_reasoning, + message_parts_to_save, agent_stats, refs, ) @@ -492,11 +479,11 @@ class OpenApiRoute(Route): "session_id": session_id, } ) - accumulated_parts = [] - accumulated_text = "" - accumulated_reasoning = "" + message_accumulator = BotMessageAccumulator() agent_stats = {} refs = {} + if msg_type == "end": + break except Exception as e: logger.exception(f"Open API WS chat failed: {e}", exc_info=True) await self._send_chat_ws_error( diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index dba47fa05..1e465887d 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -368,6 +368,7 @@ @regenerate-with-model="handleRegenerateMessage" @select-bot-text="handleBotTextSelection" @open-thread="openThreadPanel" + @open-reasoning="openReasoningPanel" @open-refs="openRefsSidebar" /> @@ -467,6 +468,11 @@ :deleting="deletingThread" @delete="deleteThread" /> + @@ -495,10 +501,12 @@ import ProjectView from "@/components/chat/ProjectView.vue"; import ChatInput from "@/components/chat/ChatInput.vue"; import ChatMessageList from "@/components/chat/ChatMessageList.vue"; import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue"; +import ReasoningSidebar from "@/components/chat/ReasoningSidebar.vue"; import ThreadPanel from "@/components/chat/ThreadPanel.vue"; import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue"; import { useSessions, type Session } from "@/composables/useSessions"; import { + messageBlocks as buildMessageBlocks, useMessages, type ChatRecord, type ChatThread, @@ -587,6 +595,11 @@ const shouldStickToBottom = ref(true); const replyTarget = ref(null); const threadPanelOpen = ref(false); const activeThread = ref(null); +const reasoningPanelOpen = ref(false); +const activeReasoningTarget = ref<{ + message: ChatRecord; + blockIndex: number; +} | null>(null); const deletingThread = ref(false); const refsSidebarOpen = ref(false); const selectedRefs = ref | null>(null); @@ -617,6 +630,20 @@ const chatSidebarDrawer = computed({ const isSidebarCollapsed = computed(() => lgAndUp.value ? sidebarCollapsed.value : !customizer.chatSidebarOpen, ); +const activeReasoningParts = computed(() => { + if (!activeReasoningTarget.value) return []; + const blocks = buildMessageBlocks( + activeReasoningTarget.value.message.content || { type: "bot", message: [] }, + ); + const block = blocks[activeReasoningTarget.value.blockIndex]; + return block?.kind === "thinking" ? block.parts : []; +}); + +watch(reasoningPanelOpen, (open) => { + if (!open) { + activeReasoningTarget.value = null; + } +}); const { loadingMessages, @@ -1146,16 +1173,35 @@ async function createThreadFromSelection() { } function openThreadPanel(thread: ChatThread) { + reasoningPanelOpen.value = false; + activeReasoningTarget.value = null; + refsSidebarOpen.value = false; activeThread.value = thread; threadPanelOpen.value = true; } function openRefsSidebar(refs: unknown) { + threadPanelOpen.value = false; + activeThread.value = null; + reasoningPanelOpen.value = false; + activeReasoningTarget.value = null; selectedRefs.value = refs && typeof refs === "object" ? (refs as Record) : null; refsSidebarOpen.value = true; } +function openReasoningPanel(payload: { + message: ChatRecord; + blockIndex: number; +}) { + threadPanelOpen.value = false; + activeThread.value = null; + refsSidebarOpen.value = false; + selectedRefs.value = null; + activeReasoningTarget.value = payload; + reasoningPanelOpen.value = true; +} + async function deleteThread(thread: ChatThread) { if (deletingThread.value) return; if (!(await askForConfirmation(tm("thread.confirmDelete"), confirmDialog))) return; @@ -1584,6 +1630,23 @@ kbd { font: inherit; } +:deep(.hr-node) { + margin-top: 1.25rem; + margin-bottom: 1.25rem; + opacity: 0.5; + border-top-width: .3px; +} + +:deep(.paragraph-node) { + margin: .5rem 0; + line-height: 1.7; +} + +:deep(.list-node) { + margin-top: .5rem; + margin-bottom: .5rem; +} + @media (max-width: 760px) { .messages-panel { padding: 18px 14px; diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue index a0e70d65f..dd940b248 100644 --- a/dashboard/src/components/chat/ChatMessageList.vue +++ b/dashboard/src/components/chat/ChatMessageList.vue @@ -119,127 +119,141 @@