Compare commits

...

3 Commits

Author SHA1 Message Date
Soulter
d899b9a1da Update astrbot/dashboard/routes/chat.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-23 17:46:40 +08:00
Soulter
41a35cee2c 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.
2026-04-23 17:41:05 +08:00
Soulter
749e2fd57b 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.
2026-04-23 16:22:14 +08:00
20 changed files with 1500 additions and 750 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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 = {}

View File

@@ -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(

View File

@@ -368,6 +368,7 @@
@regenerate-with-model="handleRegenerateMessage"
@select-bot-text="handleBotTextSelection"
@open-thread="openThreadPanel"
@open-reasoning="openReasoningPanel"
@open-refs="openRefsSidebar"
/>
</div>
@@ -467,6 +468,11 @@
:deleting="deletingThread"
@delete="deleteThread"
/>
<ReasoningSidebar
v-model="reasoningPanelOpen"
:parts="activeReasoningParts"
:is-dark="isDark"
/>
<RefsSidebar v-model="refsSidebarOpen" :refs="selectedRefs" />
</div>
</template>
@@ -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<ChatRecord | null>(null);
const threadPanelOpen = ref(false);
const activeThread = ref<ChatThread | null>(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<Record<string, unknown> | null>(null);
@@ -617,6 +630,20 @@ const chatSidebarDrawer = computed({
const isSidebarCollapsed = computed(() =>
lgAndUp.value ? sidebarCollapsed.value : !customizer.chatSidebarOpen,
);
const activeReasoningParts = computed<MessagePart[]>(() => {
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<string, unknown>) : 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;

View File

@@ -119,127 +119,141 @@
</template>
<template v-else>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(part, partIndex) in bubbleParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
>
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{ replyPreview(part.message_id, part.selected_text) }}</span>
</button>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<div
v-else-if="part.type === 'plain' && messageThreads(msg).length"
class="threaded-message-content"
>
<ThreadedMarkdownMessagePart
:text="part.text || ''"
:threads="messageThreads(msg)"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
@open-thread="emit('openThread', $event)"
/>
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
:open-in-sidebar="variant === 'main'"
@open="emit('openReasoning', { message: msg, blockIndex })"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<template v-else>
<template
v-for="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
>
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{ replyPreview(part.message_id, part.selected_text) }}</span>
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div
v-else-if="part.type === 'plain' && messageThreads(msg).length"
class="threaded-message-content"
>
<ThreadedMarkdownMessagePart
:text="part.text || ''"
:threads="messageThreads(msg)"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
@open-thread="emit('openThread', $event)"
/>
</div>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<v-btn
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<div v-else-if="part.type === 'tool_call'" class="tool-call-block">
<template v-for="tool in part.tool_calls || []" :key="tool.id || tool.name">
<ToolCallItem v-if="isIPythonToolCall(tool)" :is-dark="isDark">
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<v-btn
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
<div v-else-if="part.type === 'tool_call'" class="tool-call-block">
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<ToolCallItem v-if="isIPythonToolCall(tool)" :is-dark="isDark">
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</div>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</template>
</template>
</template>
</template>
</div>
@@ -381,6 +395,11 @@ import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import {
displayParts as displayMessageParts,
messageBlocks as buildMessageBlocks,
type MessageDisplayBlock,
} from "@/composables/useMessages";
import type {
ChatContent,
ChatRecord,
@@ -431,6 +450,7 @@ const emit = defineEmits<{
];
selectBotText: [event: MouseEvent, message: ChatRecord];
openThread: [thread: ChatThread];
openReasoning: [payload: { message: ChatRecord; blockIndex: number }];
openRefs: [refs: unknown];
}>();
@@ -483,7 +503,7 @@ function hasImageOnlyAttachments(message: ChatRecord) {
}
function bubbleParts(message: ChatRecord) {
if (!isUserMessage(message)) return messageParts(message);
if (!isUserMessage(message)) return displayMessageParts(messageContent(message));
return messageParts(message).filter((part) => !isAttachmentPart(part));
}
@@ -554,11 +574,21 @@ function showMessageMeta(message: ChatRecord, messageIndex: number) {
}
function hasNonReasoningContent(message: ChatRecord) {
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
return renderBlocks(message).some((block) => block.kind === "content");
}
function renderBlocks(message: ChatRecord): MessageDisplayBlock[] {
if (isUserMessage(message)) {
const parts = bubbleParts(message);
return parts.length ? [{ kind: "content", parts }] : [];
}
return buildMessageBlocks(messageContent(message));
}
function hasFollowingContentBlock(message: ChatRecord, blockIndex: number) {
return renderBlocks(message)
.slice(blockIndex + 1)
.some((block) => block.kind === "content");
}
const attachmentTypeStyles: Record<

View File

@@ -35,124 +35,133 @@
</div>
<template v-else>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(part, partIndex) in messageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
>
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{
replyPreview(part.message_id, part.selected_text)
}}</span>
</button>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<v-btn
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template v-else>
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
v-for="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
>
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
<v-icon size="15">mdi-reply</v-icon>
<span>{{
replyPreview(part.message_id, part.selected_text)
}}</span>
</button>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<v-btn
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
>
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</div>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</template>
</template>
</template>
</template>
</div>
@@ -248,6 +257,11 @@ import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue"
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import {
displayParts as displayMessageParts,
messageBlocks as buildMessageBlocks,
type MessageDisplayBlock,
} from "@/composables/useMessages";
import type {
ChatContent,
ChatRecord,
@@ -293,10 +307,7 @@ function messageContent(message: ChatRecord): ChatContent {
}
function messageParts(message: ChatRecord): MessagePart[] {
const parts = messageContent(message).message;
if (Array.isArray(parts)) return parts;
if (typeof parts === "string") return [{ type: "plain", text: parts }];
return [];
return displayMessageParts(messageContent(message));
}
function isMessageStreaming(messageIndex: number) {
@@ -304,11 +315,21 @@ function isMessageStreaming(messageIndex: number) {
}
function hasNonReasoningContent(message: ChatRecord) {
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
return renderBlocks(message).some((block) => block.kind === "content");
}
function renderBlocks(message: ChatRecord): MessageDisplayBlock[] {
if (isUserMessage(message)) {
const parts = messageParts(message);
return parts.length ? [{ kind: "content", parts }] : [];
}
return buildMessageBlocks(messageContent(message));
}
function hasFollowingContentBlock(message: ChatRecord, blockIndex: number) {
return renderBlocks(message)
.slice(blockIndex + 1)
.some((block) => block.kind === "content");
}
function partUrl(part: MessagePart) {

View File

@@ -0,0 +1,119 @@
<template>
<transition name="slide-left">
<aside v-if="modelValue" class="reasoning-sidebar">
<div class="reasoning-sidebar-header">
<div class="reasoning-sidebar-title">{{ tm("reasoning.thinking") }}</div>
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
</div>
<div class="reasoning-sidebar-body">
<ReasoningTimeline
v-if="parts.length || reasoning"
:parts="parts"
:reasoning="reasoning"
:is-dark="isDark"
/>
<div v-else class="reasoning-sidebar-empty">
{{ tm("reasoning.thinking") }}
</div>
</div>
</aside>
</transition>
</template>
<script setup lang="ts">
import type { MessagePart } from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import ReasoningTimeline from "@/components/chat/message_list_comps/ReasoningTimeline.vue";
defineProps<{
modelValue: boolean;
parts: MessagePart[];
reasoning?: string;
isDark?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
}>();
const { tm } = useModuleI18n("features/chat");
function close() {
emit("update:modelValue", false);
}
</script>
<style scoped>
.reasoning-sidebar {
width: 380px;
height: 100%;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.1);
background: rgb(var(--v-theme-surface));
color: rgb(var(--v-theme-on-surface));
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.2s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
}
.reasoning-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 8px;
}
.reasoning-sidebar-title {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
color: rgb(var(--v-theme-on-surface));
}
.reasoning-sidebar-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 14px 12px;
font-size: 14.5px;
line-height: 1.62;
}
.reasoning-sidebar-empty {
padding: 12px 2px;
color: rgba(var(--v-theme-on-surface), 0.54);
font-size: 13px;
}
@media (max-width: 760px) {
.reasoning-sidebar {
position: fixed;
inset: 0;
z-index: 1300;
width: 100vw;
height: 100dvh;
border-left: 0;
}
.reasoning-sidebar-header {
min-height: 52px;
padding: calc(10px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid rgba(var(--v-border-color), 0.12);
}
.reasoning-sidebar-body {
padding: 0 12px calc(12px + env(safe-area-inset-bottom));
}
}
</style>

View File

@@ -26,99 +26,108 @@
</div>
<template v-else>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(part, partIndex) in messageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
>
<div
v-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="messageRefs(msg)"
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template v-else>
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
v-for="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
>
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
<div
v-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="messageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
>
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</div>
</div>
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
</template>
</template>
</template>
</template>
</div>
@@ -186,6 +195,9 @@ import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue"
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import { useMediaHandling } from "@/composables/useMediaHandling";
import {
displayParts as displayMessageParts,
messageBlocks as buildMessageBlocks,
type MessageDisplayBlock,
useMessages,
type ChatRecord,
type MessagePart,
@@ -242,7 +254,6 @@ const {
isMessageStreaming,
isUserMessage,
messageContent,
messageParts,
createLocalExchange,
sendMessageStream,
stopSession,
@@ -336,11 +347,25 @@ function buildOutgoingParts(text: string): MessagePart[] {
}
function hasNonReasoningContent(message: ChatRecord) {
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
return renderBlocks(message).some((block) => block.kind === "content");
}
function bubbleParts(message: ChatRecord) {
return displayMessageParts(messageContent(message));
}
function renderBlocks(message: ChatRecord): MessageDisplayBlock[] {
if (isUserMessage(message)) {
const parts = bubbleParts(message);
return parts.length ? [{ kind: "content", parts }] : [];
}
return buildMessageBlocks(messageContent(message));
}
function hasFollowingContentBlock(message: ChatRecord, blockIndex: number) {
return renderBlocks(message)
.slice(blockIndex + 1)
.some((block) => block.kind === "content");
}
async function stopCurrentSession() {

View File

@@ -57,7 +57,20 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import axios from "axios";
import type { ChatRecord, ChatThread, MessagePart } from "@/composables/useMessages";
import {
appendPlain,
appendReasoningPart,
extractReasoningText,
finishToolCall,
hasPlainText,
markMessageStarted,
normalizeMessageParts,
parseJsonSafe,
payloadText,
upsertToolCall,
type ChatRecord,
type ChatThread,
} from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import ChatMessageList from "@/components/chat/ChatMessageList.vue";
@@ -127,7 +140,7 @@ async function send() {
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [{ type: "plain", text: "" }],
message: [],
reasoning: "",
isLoading: true,
},
@@ -173,31 +186,22 @@ async function send() {
function normalizeRecord(record: any): ChatRecord {
const content = record.content || {};
const normalizedMessage = normalizeMessageParts(
content.message || [],
content.reasoning || "",
);
return {
...record,
content: {
type: content.type || (record.sender_id === "bot" ? "bot" : "user"),
message: normalizeParts(content.message || []),
reasoning: content.reasoning || "",
message: normalizedMessage,
reasoning: extractReasoningText(normalizedMessage, content.reasoning || ""),
agentStats: content.agentStats || content.agent_stats,
refs: content.refs,
},
};
}
function normalizeParts(parts: unknown): MessagePart[] {
if (typeof parts === "string") {
return parts ? [{ type: "plain", text: parts }] : [];
}
if (!Array.isArray(parts)) return [];
return parts.map((part: any) => {
if (!part || typeof part !== "object") {
return { type: "plain", text: String(part ?? "") };
}
return part;
});
}
async function readSseStream(
stream: ReadableStream<Uint8Array>,
onPayload: (payload: any) => void,
@@ -287,7 +291,7 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload:
if (type === "plain") {
markMessageStarted(botRecord);
if (chainType === "reasoning") {
botRecord.content.reasoning = `${botRecord.content.reasoning || ""}${payloadText(data)}`;
appendReasoningPart(botRecord, payloadText(data));
return;
}
if (chainType === "tool_call") {
@@ -314,69 +318,6 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload:
}
}
function appendPlain(record: ChatRecord, text: string, append = true) {
markMessageStarted(record);
let last = record.content.message[record.content.message.length - 1];
if (!last || last.type !== "plain") {
last = { type: "plain", text: "" };
record.content.message.push(last);
}
last.text = append ? `${last.text || ""}${text}` : text;
}
function upsertToolCall(record: ChatRecord, toolCall: any) {
markMessageStarted(record);
if (!toolCall || typeof toolCall !== "object") return;
record.content.message.push({ type: "tool_call", tool_calls: [toolCall] });
}
function finishToolCall(record: ChatRecord, result: any) {
markMessageStarted(record);
if (!result || typeof result !== "object") return;
const targetId = result.id;
for (const part of record.content.message) {
if (part.type !== "tool_call" || !Array.isArray(part.tool_calls)) continue;
const tool = part.tool_calls.find((item) => item.id === targetId);
if (tool) {
tool.result = result.result;
tool.finished_ts = result.ts || Date.now() / 1000;
return;
}
}
}
function markMessageStarted(record: ChatRecord) {
record.content.isLoading = false;
}
function hasPlainText(record: ChatRecord) {
return record.content.message.some(
(part) =>
part.type === "plain" && typeof part.text === "string" && part.text,
);
}
function payloadText(value: unknown) {
if (typeof value === "string") return value;
if (value == null) return "";
if (typeof value === "object") {
const payload = value as Record<string, unknown>;
if (typeof payload.text === "string") return payload.text;
if (typeof payload.content === "string") return payload.content;
if (typeof payload.message === "string") return payload.message;
}
return String(value);
}
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {

View File

@@ -115,6 +115,8 @@ onMounted(async () => {
.ipython-tool-block {
margin-bottom: 12px;
margin-top: 6px;
font-size: inherit;
line-height: inherit;
}
.ipython-tool-block.compact {
@@ -133,7 +135,7 @@ onMounted(async () => {
.code-highlighted {
border-radius: 6px;
overflow: hidden;
font-size: 14px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
}
@@ -157,7 +159,7 @@ onMounted(async () => {
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
font-size: 12px;
line-height: 1.5;
background-color: #f5f5f5;
}
@@ -183,7 +185,7 @@ onMounted(async () => {
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
font-size: 12px;
line-height: 1.5;
background-color: #f5f5f5;
max-height: 300px;

View File

@@ -1,132 +1,154 @@
<template>
<div class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
<button class="reasoning-header" type="button" @click="toggleExpanded">
<button
class="reasoning-header"
:class="{ 'reasoning-header--trigger': openInSidebar }"
type="button"
@click="handlePrimaryAction"
>
<span class="reasoning-title">
{{ tm("reasoning.thinking") }}
</span>
<v-icon
size="22"
class="reasoning-icon"
:class="{ 'rotate-90': isExpanded }"
:class="{ 'rotate-90': !openInSidebar && isExpanded }"
>
mdi-chevron-right
</v-icon>
</button>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender
:key="`reasoning-${isDark ? 'dark' : 'light'}`"
:content="reasoning"
class="reasoning-text markdown-content"
:typewriter="false"
<div
v-if="!openInSidebar && isExpanded"
class="reasoning-content animate-fade-in"
>
<ReasoningTimeline
:parts="renderParts"
:reasoning="reasoning"
:is-dark="isDark"
/>
</div>
<transition :name="previewTransitionName" mode="out-in">
<div
v-if="showStreamingPreview"
:key="previewKey"
class="reasoning-preview"
>
<div v-if="showStreamingPreview" :key="previewKey" class="reasoning-preview">
{{ previewText }}
</div>
</transition>
</div>
</template>
<script setup>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import type { MessagePart } from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import { MarkdownRender } from "markstream-vue";
import ReasoningTimeline from "@/components/chat/message_list_comps/ReasoningTimeline.vue";
const props = defineProps({
reasoning: {
type: String,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
initialExpanded: {
type: Boolean,
default: false,
},
isStreaming: {
type: Boolean,
default: false,
},
hasNonReasoningContent: {
type: Boolean,
default: false,
},
});
const props = defineProps<{
parts?: MessagePart[];
reasoning?: string;
isDark?: boolean;
initialExpanded?: boolean;
isStreaming?: boolean;
hasNonReasoningContent?: boolean;
openInSidebar?: boolean;
}>();
const emit = defineEmits<{
open: [];
}>();
const { tm } = useModuleI18n("features/chat");
const isExpanded = ref(props.initialExpanded);
const isExpanded = ref(Boolean(props.initialExpanded));
const previewText = ref("");
const previewKey = ref(0);
let previewTimer = null;
let previewStartTimer = null;
let previewTimer: ReturnType<typeof setInterval> | null = null;
let previewStartTimer: ReturnType<typeof setTimeout> | null = null;
const renderParts = computed<MessagePart[]>(() => {
if (props.parts?.length) return props.parts;
if (props.reasoning) {
return [{ type: "think", think: props.reasoning }];
}
return [];
});
const openInSidebar = computed(() => Boolean(props.openInSidebar));
const thinkingText = computed(() =>
renderParts.value
.filter((part) => part.type === "think")
.map((part) => String(part.think || ""))
.join(""),
);
const showStreamingPreview = computed(
() =>
props.isStreaming &&
!isExpanded.value &&
(openInSidebar.value || !isExpanded.value) &&
!props.hasNonReasoningContent &&
previewText.value,
);
const previewTransitionName = computed(() =>
props.hasNonReasoningContent
? "reasoning-preview-collapse"
: "reasoning-preview-fade",
);
const toggleExpanded = () => {
function handlePrimaryAction() {
if (openInSidebar.value) {
emit("open");
return;
}
isExpanded.value = !isExpanded.value;
};
}
const latestReasoningPreview = () => {
const lines = props.reasoning
function latestReasoningPreview() {
const lines = thinkingText.value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return lines.slice(-3).join("\n");
};
}
const updatePreviewLine = () => {
function updatePreviewLine() {
const nextText = latestReasoningPreview();
if (!nextText || nextText === previewText.value) return;
previewText.value = nextText;
previewKey.value += 1;
};
}
const stopPreviewTimer = () => {
function stopPreviewTimer() {
if (!previewTimer) return;
clearInterval(previewTimer);
previewTimer = null;
};
}
const stopPreviewStartTimer = () => {
function stopPreviewStartTimer() {
if (!previewStartTimer) return;
clearTimeout(previewStartTimer);
previewStartTimer = null;
};
}
const startPreviewTimer = () => {
function startPreviewTimer() {
updatePreviewLine();
if (!previewTimer) {
previewTimer = setInterval(updatePreviewLine, 2000);
}
};
}
const syncPreviewTimer = () => {
if (props.isStreaming && !isExpanded.value && !props.hasNonReasoningContent) {
function syncPreviewTimer() {
if (
props.isStreaming &&
(openInSidebar.value || !isExpanded.value) &&
!props.hasNonReasoningContent
) {
if (!previewTimer && !previewStartTimer) {
previewStartTimer = setTimeout(() => {
previewStartTimer = null;
if (
props.isStreaming &&
!isExpanded.value &&
(openInSidebar.value || !isExpanded.value) &&
!props.hasNonReasoningContent
) {
startPreviewTimer();
@@ -141,10 +163,16 @@ const syncPreviewTimer = () => {
if (!props.isStreaming) {
previewText.value = "";
}
};
}
watch(
() => [props.isStreaming, isExpanded.value, props.hasNonReasoningContent],
() => [
props.isStreaming,
isExpanded.value,
props.hasNonReasoningContent,
thinkingText.value,
openInSidebar.value,
],
syncPreviewTimer,
{
immediate: true,
@@ -185,6 +213,10 @@ onBeforeUnmount(() => {
color: rgba(var(--v-theme-on-surface), 0.88);
}
.reasoning-header--trigger {
align-items: flex-start;
}
.reasoning-icon {
color: currentcolor;
transition: transform 0.2s ease;
@@ -199,11 +231,14 @@ onBeforeUnmount(() => {
}
.reasoning-content {
margin-top: 8px;
padding: 0;
color: rgba(var(--v-theme-on-surface), 0.7);
margin-top: 10px;
padding: 12px 14px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 18px;
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), 0.72);
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
font-style: normal;
}
.reasoning-preview {
@@ -216,13 +251,9 @@ onBeforeUnmount(() => {
-webkit-line-clamp: 3;
white-space: pre-line;
font: inherit;
font-style: italic;
}
.reasoning-text {
font-size: inherit;
line-height: inherit;
color: inherit;
font-size: 14.5px;
line-height: 1.62;
font-style: normal;
}
.animate-fade-in {
@@ -261,12 +292,11 @@ onBeforeUnmount(() => {
}
.reasoning-preview-collapse-leave-active {
overflow: hidden;
transition:
max-height 0.45s cubic-bezier(0.55, 0, 1, 0.45),
margin-top 0.45s cubic-bezier(0.55, 0, 1, 0.45),
opacity 0.35s ease-in,
transform 0.45s cubic-bezier(0.55, 0, 1, 0.45);
opacity 0.18s ease,
max-height 0.18s ease,
margin-top 0.18s ease;
overflow: hidden;
}
.reasoning-preview-collapse-enter-from {
@@ -274,15 +304,14 @@ onBeforeUnmount(() => {
}
.reasoning-preview-collapse-leave-from {
max-height: 5rem;
opacity: 1;
transform: translateY(0);
max-height: 6.5em;
margin-top: 4px;
}
.reasoning-preview-collapse-leave-to {
opacity: 0;
max-height: 0;
margin-top: 0;
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,265 @@
<template>
<div v-if="timelineEntries.length" class="reasoning-timeline">
<div
v-for="(entry, entryIndex) in timelineEntries"
:key="entry.key"
class="reasoning-timeline-item"
>
<div class="reasoning-timeline-rail" aria-hidden="true">
<span class="reasoning-timeline-dot"></span>
<span
v-if="entryIndex < timelineEntries.length - 1"
class="reasoning-timeline-line"
></span>
</div>
<div class="reasoning-step">
<div class="reasoning-step-meta">
<span class="reasoning-step-title">{{ entry.title }}</span>
</div>
<MarkdownRender
v-if="entry.kind === 'think'"
:content="entry.think || ''"
class="reasoning-text markdown-content"
:typewriter="false"
:is-dark="isDark"
/>
<div v-else-if="entry.tool" class="reasoning-tool-call-block">
<ToolCallItem v-if="isIPythonToolCall(entry.tool)" :is-dark="isDark">
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ entry.tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(entry.tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="entry.tool"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="entry.tool"
:is-dark="isDark"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { MarkdownRender } from "markstream-vue";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import type { MessagePart } from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
const props = defineProps<{
parts?: MessagePart[];
reasoning?: string;
isDark?: boolean;
}>();
const { tm } = useModuleI18n("features/chat");
type NormalizedToolCall = Record<string, unknown>;
type TimelineEntry =
| {
key: string;
kind: "think";
title: string;
think: string;
}
| {
key: string;
kind: "tool_call";
title: string;
tool: NormalizedToolCall;
};
const renderParts = computed<MessagePart[]>(() => {
if (props.parts?.length) return props.parts;
if (props.reasoning) {
return [{ type: "think", think: props.reasoning }];
}
return [];
});
const timelineEntries = computed<TimelineEntry[]>(() => {
const entries: TimelineEntry[] = [];
renderParts.value.forEach((part, partIndex) => {
if (part.type === "think") {
const think = String(part.think || "");
if (!think.trim()) return;
entries.push({
key: `think-${partIndex}`,
kind: "think",
title: tm("reasoning.think"),
think,
});
return;
}
if (part.type !== "tool_call" || !Array.isArray(part.tool_calls)) return;
part.tool_calls.forEach((tool, toolIndex) => {
const normalizedTool = normalizeToolCall(tool);
entries.push({
key: `tool-${String(tool.id || tool.name || `${partIndex}-${toolIndex}`)}`,
kind: "tool_call",
title: tm("reasoning.toolUsed"),
tool: normalizedTool,
});
});
});
return entries;
});
function normalizeToolCall(tool: Record<string, unknown>) {
const normalized = { ...tool };
normalized.args = parseJsonSafe(normalized.args ?? normalized.arguments ?? {});
normalized.result = parseJsonSafe(normalized.result);
normalized.ts = normalized.ts ?? Date.now() / 1000;
if (normalized.result && typeof normalized.result === "object") {
normalized.result = JSON.stringify(normalized.result, null, 2);
}
return normalized;
}
function isIPythonToolCall(tool: Record<string, unknown>) {
const name = String(tool.name || "").toLowerCase();
return name.includes("python") || name.includes("ipython");
}
function toolCallStatusText(tool: Record<string, unknown>) {
if (tool.finished_ts) return tm("toolStatus.done");
return tm("toolStatus.running");
}
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
</script>
<style scoped>
.reasoning-timeline {
display: flex;
flex-direction: column;
gap: 0;
padding-top: 4px;
}
.reasoning-timeline-item {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
column-gap: 10px;
align-items: flex-start;
padding-bottom: 10px;
}
.reasoning-timeline-item:last-child {
padding-bottom: 0;
}
.reasoning-timeline-rail {
position: relative;
display: flex;
justify-content: center;
min-height: 100%;
padding-top: 6px;
}
.reasoning-timeline-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.18);
}
.reasoning-timeline-line {
position: absolute;
top: 15px;
bottom: -10px;
left: 50%;
width: 1px;
transform: translateX(-50%);
background: rgba(var(--v-theme-on-surface), 0.12);
}
.reasoning-step {
min-width: 0;
font-size: 14.5px;
line-height: 1.62;
}
.reasoning-step-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
color: rgba(var(--v-theme-on-surface), 0.54);
font-size: 12px;
line-height: 1.35;
}
.reasoning-step-title {
color: rgba(var(--v-theme-on-surface), 0.76);
font-weight: 600;
font-size: 12px;
}
.reasoning-tool-call-block {
margin-top: 4px;
font-style: normal;
}
.reasoning-text {
font-size: inherit;
line-height: inherit;
color: rgba(var(--v-theme-on-surface), 0.72);
font-style: normal;
}
.reasoning-step :deep(.tool-call-card),
.reasoning-step :deep(.tool-call-item),
.reasoning-step :deep(.ipython-tool-block) {
font-size: 13.5px;
line-height: 1.56;
}
.reasoning-step :deep(.tool-call-card .detail-label) {
font-size: 11.5px;
}
.reasoning-step :deep(.tool-call-card .detail-value),
.reasoning-step :deep(.ipython-tool-block .code-highlighted),
.reasoning-step :deep(.ipython-tool-block .code-fallback),
.reasoning-step :deep(.ipython-tool-block .result-label),
.reasoning-step :deep(.ipython-tool-block .result-content) {
font-size: 12.5px;
}
.tool-call-inline-status {
margin-left: 4px;
color: rgba(var(--v-theme-on-surface), 0.48);
}
</style>

View File

@@ -33,7 +33,8 @@ const toggleExpanded = () => {
<style scoped>
.tool-call-line {
font-size: 14px;
font: inherit;
line-height: inherit;
color: var(--v-theme-secondaryText);
opacity: 0.85;
cursor: pointer;
@@ -55,6 +56,8 @@ const toggleExpanded = () => {
border-left: 2px solid var(--v-theme-border);
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.02);
font-size: inherit;
line-height: inherit;
}
.tool-call-inline-details.is-dark {

View File

@@ -6,6 +6,7 @@ export type TransportMode = "sse" | "websocket";
export interface MessagePart {
type: string;
text?: string;
think?: string;
message_id?: string | number;
selected_text?: string;
embedded_url?: string;
@@ -35,6 +36,11 @@ export interface ChatContent {
refs?: any;
}
export interface MessageDisplayBlock {
kind: "thinking" | "content";
parts: MessagePart[];
}
export interface ChatRecord {
id?: string | number;
content: ChatContent;
@@ -243,7 +249,7 @@ export function useMessages(options: UseMessagesOptions) {
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [{ type: "plain", text: "" }],
message: [],
reasoning: "",
isLoading: true,
},
@@ -352,7 +358,7 @@ export function useMessages(options: UseMessagesOptions) {
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [{ type: "plain", text: "" }],
message: [],
reasoning: "",
isLoading: true,
},
@@ -386,7 +392,7 @@ export function useMessages(options: UseMessagesOptions) {
botRecord.created_at = new Date().toISOString();
botRecord.content = {
type: "bot",
message: [{ type: "plain", text: "" }],
message: [],
reasoning: "",
isLoading: true,
};
@@ -451,10 +457,14 @@ export function useMessages(options: UseMessagesOptions) {
function normalizeHistoryRecord(record: any): ChatRecord {
const content = record.content || {};
const normalizedMessage = normalizeMessageParts(
content.message || [],
content.reasoning || "",
);
const normalizedContent: ChatContent = {
type: content.type || (record.sender_id === "bot" ? "bot" : "user"),
message: normalizeParts(content.message || []),
reasoning: content.reasoning || "",
message: normalizedMessage,
reasoning: extractReasoningText(normalizedMessage, content.reasoning || ""),
agentStats: content.agentStats || content.agent_stats,
refs: content.refs,
};
@@ -479,18 +489,6 @@ export function useMessages(options: UseMessagesOptions) {
}
}
function normalizeParts(parts: unknown): MessagePart[] {
if (typeof parts === "string") {
return parts ? [{ type: "plain", text: parts }] : [];
}
if (!Array.isArray(parts)) return [];
return parts.map((part: any) => {
if (!part || typeof part !== "object")
return { type: "plain", text: String(part ?? "") };
return part;
});
}
function startSseStream(
sessionId: string,
messageId: string,
@@ -666,9 +664,7 @@ export function useMessages(options: UseMessagesOptions) {
if (msgType === "plain") {
markMessageStarted(botRecord);
if (chainType === "reasoning") {
messageContent(botRecord).reasoning = `${
messageContent(botRecord).reasoning || ""
}${payloadText(data)}`;
appendReasoningPart(botRecord, payloadText(data));
return;
}
if (chainType === "tool_call") {
@@ -773,6 +769,91 @@ function normalizeSessionProject(value: unknown): ChatSessionProject | null {
};
}
export function normalizeMessageParts(
parts: unknown,
legacyReasoning = "",
): MessagePart[] {
const normalizedParts = normalizePartsInternal(parts);
if (legacyReasoning && !normalizedParts.some((part) => part.type === "think")) {
normalizedParts.unshift({ type: "think", think: legacyReasoning });
}
return normalizedParts;
}
export function extractReasoningText(
parts: MessagePart[] | unknown,
legacyReasoning = "",
) {
const normalizedParts = Array.isArray(parts)
? parts
: normalizeMessageParts(parts, legacyReasoning);
const text = normalizedParts
.filter((part) => part.type === "think")
.map((part) => String(part.think || ""))
.join("");
return text || legacyReasoning;
}
export function thinkingParts(content: ChatContent): MessagePart[] {
const firstThinkingBlock = messageBlocks(content).find(
(block) => block.kind === "thinking",
);
if (firstThinkingBlock) return firstThinkingBlock.parts;
const fallbackReasoning = String(content.reasoning || "");
return fallbackReasoning ? [{ type: "think", think: fallbackReasoning }] : [];
}
export function displayParts(content: ChatContent): MessagePart[] {
return messageBlocks(content)
.filter((block) => block.kind === "content")
.flatMap((block) => block.parts);
}
export function messageBlocks(content: ChatContent): MessageDisplayBlock[] {
const parts = Array.isArray(content.message)
? content.message
: normalizeMessageParts(content.message, content.reasoning || "");
const blocks: MessageDisplayBlock[] = [];
let currentKind: MessageDisplayBlock["kind"] | null = null;
let currentParts: MessagePart[] = [];
for (const part of parts) {
if (isEmptyPlainPart(part)) continue;
const nextKind: MessageDisplayBlock["kind"] = isThinkingPart(part)
? "thinking"
: "content";
if (currentKind !== nextKind) {
if (currentKind && currentParts.length) {
blocks.push({ kind: currentKind, parts: currentParts });
}
currentKind = nextKind;
currentParts = [{ ...part }];
continue;
}
currentParts.push({ ...part });
}
if (currentKind && currentParts.length) {
blocks.push({ kind: currentKind, parts: currentParts });
}
if (!blocks.length && content.reasoning) {
return [
{
kind: "thinking",
parts: [{ type: "think", think: String(content.reasoning) }],
},
];
}
return blocks;
}
function partToPayload(part: MessagePart) {
if (part.type === "plain") return { type: "plain", text: part.text || "" };
if (part.type === "reply") {
@@ -820,7 +901,39 @@ async function readSseStream(
}
}
function appendPlain(record: ChatRecord, text: string, append = true) {
function normalizePartsInternal(parts: unknown): MessagePart[] {
if (typeof parts === "string") {
return parts ? [{ type: "plain", text: parts }] : [];
}
if (!Array.isArray(parts)) return [];
return parts.map((part: any) => {
if (!part || typeof part !== "object") {
return { type: "plain", text: String(part ?? "") };
}
if (part.type === "reasoning") {
return {
...part,
type: "think",
think: String(part.think ?? part.text ?? ""),
};
}
return { ...part };
});
}
function isEmptyPlainPart(part: MessagePart) {
return part.type === "plain" && !String(part.text || "");
}
function isThinkingPart(part: MessagePart) {
return part.type === "think" || part.type === "tool_call";
}
function firstNonEmptyPartIndex(parts: MessagePart[]) {
return parts.findIndex((part) => !isEmptyPlainPart(part));
}
export function appendPlain(record: ChatRecord, text: string, append = true) {
markMessageStarted(record);
const content = record.content;
let last = content.message[content.message.length - 1];
@@ -831,13 +944,37 @@ function appendPlain(record: ChatRecord, text: string, append = true) {
last.text = append ? `${last.text || ""}${text}` : text;
}
function upsertToolCall(record: ChatRecord, toolCall: any) {
export function appendReasoningPart(record: ChatRecord, text: string) {
markMessageStarted(record);
if (!toolCall || typeof toolCall !== "object") return;
record.content.message.push({ type: "tool_call", tool_calls: [toolCall] });
if (!text) return;
const content = record.content;
const last = content.message[content.message.length - 1];
if (last?.type === "think") {
last.think = `${String(last.think || "")}${text}`;
} else {
content.message.push({ type: "think", think: text });
}
content.reasoning = extractReasoningText(content.message);
}
function finishToolCall(record: ChatRecord, result: any) {
export function upsertToolCall(record: ChatRecord, toolCall: any) {
markMessageStarted(record);
if (!toolCall || typeof toolCall !== "object") return;
const targetId = toolCall.id;
if (targetId != null) {
for (const part of record.content.message) {
if (part.type !== "tool_call" || !Array.isArray(part.tool_calls)) continue;
const matched = part.tool_calls.find((item) => item.id === targetId);
if (matched) {
Object.assign(matched, toolCall);
return;
}
}
}
record.content.message.push({ type: "tool_call", tool_calls: [{ ...toolCall }] });
}
export function finishToolCall(record: ChatRecord, result: any) {
markMessageStarted(record);
if (!result || typeof result !== "object") return;
const targetId = result.id;
@@ -850,20 +987,30 @@ function finishToolCall(record: ChatRecord, result: any) {
return;
}
}
record.content.message.push({
type: "tool_call",
tool_calls: [
{
id: targetId,
result: result.result,
finished_ts: result.ts || Date.now() / 1000,
},
],
});
}
function markMessageStarted(record: ChatRecord) {
export function markMessageStarted(record: ChatRecord) {
record.content.isLoading = false;
}
function hasPlainText(record: ChatRecord) {
export function hasPlainText(record: ChatRecord) {
return record.content.message.some(
(part) =>
part.type === "plain" && typeof part.text === "string" && part.text,
);
}
function payloadText(value: unknown) {
export function payloadText(value: unknown) {
if (typeof value === "string") return value;
if (value == null) return "";
if (typeof value === "object") {
@@ -875,7 +1022,7 @@ function payloadText(value: unknown) {
return String(value);
}
function parseJsonSafe(value: unknown) {
export function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);

View File

@@ -113,7 +113,9 @@
"title": "Config"
},
"reasoning": {
"thinking": "Thinking Process"
"thinking": "Thinking Process",
"think": "Thinking",
"toolUsed": "Using Tool"
},
"reply": {
"replyTo": "Reply to",

View File

@@ -113,7 +113,9 @@
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
"thinking": "Рассуждение",
"think": "Размышление",
"toolUsed": "Использование инструмента"
},
"reply": {
"replyTo": "В ответ на",

View File

@@ -113,7 +113,9 @@
"title": "配置文件"
},
"reasoning": {
"thinking": "思考过程"
"thinking": "思考过程",
"think": "思考",
"toolUsed": "使用工具"
},
"reply": {
"replyTo": "引用",