Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter
ce14a0f0c9 fix: add missing platform adapter filter types 2026-04-23 13:42:00 +08:00
21 changed files with 749 additions and 1499 deletions

View File

@@ -717,15 +717,6 @@ 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",
@@ -738,6 +729,15 @@ 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,15 +791,6 @@ 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",
@@ -812,6 +803,15 @@ 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,15 +821,6 @@ 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",
@@ -842,7 +833,15 @@ 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
@@ -989,7 +988,6 @@ 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(
@@ -1203,23 +1201,24 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
)
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,
}
)
],
)
# 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,
}
)
],
)
logger.info(f"Tool `{func_tool_name}` Result: {tool_result_content}")
)
logger.info(f"Tool `{func_tool_name}` Result: {last_tcr_content}")
# 处理函数调用响应
if tool_call_result_blocks:

View File

@@ -235,12 +235,6 @@ 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,8 +652,6 @@ 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 Any, cast
from typing import cast
from quart import Response as QuartResponse
from quart import g, make_response, request, send_file
@@ -58,179 +58,6 @@ 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,
@@ -692,18 +519,27 @@ class ChatRoute(Route):
async def _save_bot_message(
self,
webchat_conv_id: str,
message_parts: list[dict],
text: str,
media_parts: list,
reasoning: str,
agent_stats: dict,
refs: dict,
llm_checkpoint_id: str | None = None,
platform_history_id: str = "webchat",
):
"""保存 bot 消息到历史记录,返回保存的记录"""
new_his = build_bot_history_content(
message_parts,
agent_stats=agent_stats,
refs=refs,
)
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
record = await self.platform_history_mgr.insert(
platform_id=platform_history_id,
@@ -763,7 +599,10 @@ class ChatRoute(Route):
async def stream():
client_disconnected = False
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
tool_calls = {}
agent_stats = {}
refs = {}
try:
@@ -844,61 +683,76 @@ class ChatRoute(Route):
# 累积消息部分
if msg_type == "plain":
message_accumulator.add_plain(
result_text,
chain_type=chain_type,
streaming=streaming,
)
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
elif msg_type == "image":
filename = result_text.replace("[IMAGE]", "")
part = await self._create_attachment_from_file(
filename, "image"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "record":
filename = result_text.replace("[RECORD]", "")
part = await self._create_attachment_from_file(
filename, "record"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "file":
# 格式: [FILE]filename
filename = result_text.replace("[FILE]", "")
part = await self._create_attachment_from_file(
filename, "file"
)
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)
if part:
accumulated_parts.append(part)
should_save = False
# 消息结束处理
if msg_type == "end":
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
)
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
# 提取 web_search_tavily 引用
try:
refs = self._extract_web_search_refs(
plain_text,
message_parts_to_save,
accumulated_text,
accumulated_parts,
)
except Exception as e:
logger.exception(
@@ -908,7 +762,9 @@ class ChatRoute(Route):
saved_record = await self._save_bot_message(
webchat_conv_id,
message_parts_to_save,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
agent_stats,
refs,
llm_checkpoint_id,
@@ -930,12 +786,12 @@ class ChatRoute(Route):
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
except Exception:
pass
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
# tool_calls = {}
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,11 +23,6 @@ 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
@@ -255,17 +250,26 @@ class LiveChatRoute(Route):
async def _save_bot_message(
self,
webchat_conv_id: str,
message_parts: list[dict],
text: str,
media_parts: list,
reasoning: str,
agent_stats: dict,
refs: dict,
llm_checkpoint_id: str | None = None,
):
"""保存 bot 消息到历史记录。"""
new_his = build_bot_history_content(
message_parts,
agent_stats=agent_stats,
refs=refs,
)
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
return await self.platform_history_mgr.insert(
platform_id="webchat",
@@ -495,7 +499,10 @@ class LiveChatRoute(Route):
},
)
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
tool_calls = {}
agent_stats = {}
refs = {}
@@ -538,32 +545,68 @@ class LiveChatRoute(Route):
await self._send_chat_payload(session, outgoing)
if msg_type == "plain":
message_accumulator.add_plain(
result_text,
chain_type=chain_type,
streaming=streaming,
)
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
elif msg_type == "image":
filename = str(result_text).replace("[IMAGE]", "")
part = await self._create_attachment_from_file(filename, "image")
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "record":
filename = str(result_text).replace("[RECORD]", "")
part = await self._create_attachment_from_file(filename, "record")
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "file":
filename = str(result_text).replace("[FILE]", "").split("|", 1)[0]
part = await self._create_attachment_from_file(filename, "file")
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "video":
filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0]
part = await self._create_attachment_from_file(filename, "video")
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
should_save = False
if msg_type == "end":
should_save = bool(
message_accumulator.has_content() or refs or agent_stats
accumulated_parts
or accumulated_text
or accumulated_reasoning
or refs
or agent_stats
)
elif (streaming and msg_type == "complete") or not streaming:
if chain_type not in (
@@ -574,16 +617,10 @@ 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(
plain_text,
message_parts_to_save,
accumulated_text,
accumulated_parts,
)
except Exception as e:
logger.exception(
@@ -593,7 +630,9 @@ class LiveChatRoute(Route):
saved_record = await self._save_bot_message(
session_id,
message_parts_to_save,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
agent_stats,
refs,
llm_checkpoint_id,
@@ -614,7 +653,9 @@ class LiveChatRoute(Route):
},
)
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
agent_stats = {}
refs = {}

View File

@@ -18,11 +18,7 @@ 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 (
BotMessageAccumulator,
ChatRoute,
collect_plain_text_from_message_parts,
)
from .chat import ChatRoute
from .route import Response, Route, RouteContext
@@ -367,7 +363,10 @@ class OpenApiRoute(Route):
}
)
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
tool_calls = {}
agent_stats = {}
refs = {}
while True:
@@ -403,56 +402,68 @@ class OpenApiRoute(Route):
await websocket.send_json(result)
if msg_type == "plain":
message_accumulator.add_plain(
result_text,
chain_type=chain_type,
streaming=streaming,
)
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
elif msg_type == "image":
filename = str(result_text).replace("[IMAGE]", "")
part = await self.chat_route._create_attachment_from_file(
filename, "image"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "record":
filename = str(result_text).replace("[RECORD]", "")
part = await self.chat_route._create_attachment_from_file(
filename, "record"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "file":
filename = str(result_text).replace("[FILE]", "")
part = await self.chat_route._create_attachment_from_file(
filename, "file"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "video":
filename = str(result_text).replace("[VIDEO]", "")
part = await self.chat_route._create_attachment_from_file(
filename, "video"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
should_save = False
if msg_type == "end":
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
)
break
if (streaming and msg_type == "complete") or not streaming:
if chain_type in ("tool_call", "tool_call_result"):
continue
try:
refs = self.chat_route._extract_web_search_refs(
plain_text,
message_parts_to_save,
accumulated_text,
accumulated_parts,
)
except Exception as e:
logger.exception(
@@ -462,7 +473,9 @@ class OpenApiRoute(Route):
saved_record = await self.chat_route._save_bot_message(
session_id,
message_parts_to_save,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
agent_stats,
refs,
)
@@ -479,11 +492,11 @@ class OpenApiRoute(Route):
"session_id": session_id,
}
)
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
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,7 +368,6 @@
@regenerate-with-model="handleRegenerateMessage"
@select-bot-text="handleBotTextSelection"
@open-thread="openThreadPanel"
@open-reasoning="openReasoningPanel"
@open-refs="openRefsSidebar"
/>
</div>
@@ -468,11 +467,6 @@
:deleting="deletingThread"
@delete="deleteThread"
/>
<ReasoningSidebar
v-model="reasoningPanelOpen"
:parts="activeReasoningParts"
:is-dark="isDark"
/>
<RefsSidebar v-model="refsSidebarOpen" :refs="selectedRefs" />
</div>
</template>
@@ -501,12 +495,10 @@ 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,
@@ -595,11 +587,6 @@ 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);
@@ -630,20 +617,6 @@ 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,
@@ -1173,35 +1146,16 @@ 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;
@@ -1630,23 +1584,6 @@ 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,141 +119,127 @@
</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="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
v-for="(part, partIndex) in bubbleParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
<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)"
:is-dark="isDark"
: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 })"
:custom-html-tags="customMarkdownTags"
/>
<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>
<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>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<audio
v-else-if="part.type === 'record'"
class="audio-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>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<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 === '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>
<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
<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>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</template>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</template>
</template>
</div>
@@ -395,11 +381,6 @@ 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,
@@ -450,7 +431,6 @@ const emit = defineEmits<{
];
selectBotText: [event: MouseEvent, message: ChatRecord];
openThread: [thread: ChatThread];
openReasoning: [payload: { message: ChatRecord; blockIndex: number }];
openRefs: [refs: unknown];
}>();
@@ -503,7 +483,7 @@ function hasImageOnlyAttachments(message: ChatRecord) {
}
function bubbleParts(message: ChatRecord) {
if (!isUserMessage(message)) return displayMessageParts(messageContent(message));
if (!isUserMessage(message)) return messageParts(message);
return messageParts(message).filter((part) => !isAttachmentPart(part));
}
@@ -574,21 +554,11 @@ function showMessageMeta(message: ChatRecord, messageIndex: number) {
}
function hasNonReasoningContent(message: ChatRecord) {
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");
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
const attachmentTypeStyles: Record<

View File

@@ -35,133 +35,124 @@
</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="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
v-for="(part, partIndex) in messageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
<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)"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
:custom-html-tags="customMarkdownTags"
/>
<template v-else>
<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="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<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)"
<ToolCallItem
v-if="isIPythonToolCall(tool)"
: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
<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>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</template>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</template>
</template>
</div>
@@ -257,11 +248,6 @@ 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,
@@ -307,7 +293,10 @@ function messageContent(message: ChatRecord): ChatContent {
}
function messageParts(message: ChatRecord): MessagePart[] {
return displayMessageParts(messageContent(message));
const parts = messageContent(message).message;
if (Array.isArray(parts)) return parts;
if (typeof parts === "string") return [{ type: "plain", text: parts }];
return [];
}
function isMessageStreaming(messageIndex: number) {
@@ -315,21 +304,11 @@ function isMessageStreaming(messageIndex: number) {
}
function hasNonReasoningContent(message: ChatRecord) {
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");
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
function partUrl(part: MessagePart) {

View File

@@ -1,119 +0,0 @@
<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,108 +26,99 @@
</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="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
v-for="(part, partIndex) in messageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
<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)"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
:custom-html-tags="customMarkdownTags"
/>
<template v-else>
<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="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<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)"
<ToolCallItem
v-if="isIPythonToolCall(tool)"
: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
<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>
</div>
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</template>
</div>
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
</template>
</template>
</div>
@@ -195,9 +186,6 @@ 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,
@@ -254,6 +242,7 @@ const {
isMessageStreaming,
isUserMessage,
messageContent,
messageParts,
createLocalExchange,
sendMessageStream,
stopSession,
@@ -347,25 +336,11 @@ function buildOutgoingParts(text: string): MessagePart[] {
}
function hasNonReasoningContent(message: ChatRecord) {
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");
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
async function stopCurrentSession() {

View File

@@ -57,20 +57,7 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import axios from "axios";
import {
appendPlain,
appendReasoningPart,
extractReasoningText,
finishToolCall,
hasPlainText,
markMessageStarted,
normalizeMessageParts,
parseJsonSafe,
payloadText,
upsertToolCall,
type ChatRecord,
type ChatThread,
} from "@/composables/useMessages";
import type { ChatRecord, ChatThread, MessagePart } from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import ChatMessageList from "@/components/chat/ChatMessageList.vue";
@@ -140,7 +127,7 @@ async function send() {
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [],
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
},
@@ -186,22 +173,31 @@ 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: normalizedMessage,
reasoning: extractReasoningText(normalizedMessage, content.reasoning || ""),
message: normalizeParts(content.message || []),
reasoning: 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,
@@ -291,7 +287,7 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload:
if (type === "plain") {
markMessageStarted(botRecord);
if (chainType === "reasoning") {
appendReasoningPart(botRecord, payloadText(data));
botRecord.content.reasoning = `${botRecord.content.reasoning || ""}${payloadText(data)}`;
return;
}
if (chainType === "tool_call") {
@@ -318,6 +314,69 @@ 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,8 +115,6 @@ onMounted(async () => {
.ipython-tool-block {
margin-bottom: 12px;
margin-top: 6px;
font-size: inherit;
line-height: inherit;
}
.ipython-tool-block.compact {
@@ -135,7 +133,7 @@ onMounted(async () => {
.code-highlighted {
border-radius: 6px;
overflow: hidden;
font-size: 12px;
font-size: 14px;
line-height: 1.5;
overflow-x: auto;
}
@@ -159,7 +157,7 @@ onMounted(async () => {
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
font-size: 13px;
line-height: 1.5;
background-color: #f5f5f5;
}
@@ -185,7 +183,7 @@ onMounted(async () => {
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
font-size: 13px;
line-height: 1.5;
background-color: #f5f5f5;
max-height: 300px;

View File

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

View File

@@ -1,265 +0,0 @@
<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,8 +33,7 @@ const toggleExpanded = () => {
<style scoped>
.tool-call-line {
font: inherit;
line-height: inherit;
font-size: 14px;
color: var(--v-theme-secondaryText);
opacity: 0.85;
cursor: pointer;
@@ -56,8 +55,6 @@ 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,7 +6,6 @@ export type TransportMode = "sse" | "websocket";
export interface MessagePart {
type: string;
text?: string;
think?: string;
message_id?: string | number;
selected_text?: string;
embedded_url?: string;
@@ -36,11 +35,6 @@ export interface ChatContent {
refs?: any;
}
export interface MessageDisplayBlock {
kind: "thinking" | "content";
parts: MessagePart[];
}
export interface ChatRecord {
id?: string | number;
content: ChatContent;
@@ -249,7 +243,7 @@ export function useMessages(options: UseMessagesOptions) {
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [],
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
},
@@ -358,7 +352,7 @@ export function useMessages(options: UseMessagesOptions) {
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [],
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
},
@@ -392,7 +386,7 @@ export function useMessages(options: UseMessagesOptions) {
botRecord.created_at = new Date().toISOString();
botRecord.content = {
type: "bot",
message: [],
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
};
@@ -457,14 +451,10 @@ 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: normalizedMessage,
reasoning: extractReasoningText(normalizedMessage, content.reasoning || ""),
message: normalizeParts(content.message || []),
reasoning: content.reasoning || "",
agentStats: content.agentStats || content.agent_stats,
refs: content.refs,
};
@@ -489,6 +479,18 @@ 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,
@@ -664,7 +666,9 @@ export function useMessages(options: UseMessagesOptions) {
if (msgType === "plain") {
markMessageStarted(botRecord);
if (chainType === "reasoning") {
appendReasoningPart(botRecord, payloadText(data));
messageContent(botRecord).reasoning = `${
messageContent(botRecord).reasoning || ""
}${payloadText(data)}`;
return;
}
if (chainType === "tool_call") {
@@ -769,91 +773,6 @@ 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") {
@@ -901,39 +820,7 @@ async function readSseStream(
}
}
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) {
function appendPlain(record: ChatRecord, text: string, append = true) {
markMessageStarted(record);
const content = record.content;
let last = content.message[content.message.length - 1];
@@ -944,37 +831,13 @@ export function appendPlain(record: ChatRecord, text: string, append = true) {
last.text = append ? `${last.text || ""}${text}` : text;
}
export function appendReasoningPart(record: ChatRecord, text: string) {
markMessageStarted(record);
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);
}
export function upsertToolCall(record: ChatRecord, toolCall: any) {
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 }] });
record.content.message.push({ type: "tool_call", tool_calls: [toolCall] });
}
export function finishToolCall(record: ChatRecord, result: any) {
function finishToolCall(record: ChatRecord, result: any) {
markMessageStarted(record);
if (!result || typeof result !== "object") return;
const targetId = result.id;
@@ -987,30 +850,20 @@ export 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,
},
],
});
}
export function markMessageStarted(record: ChatRecord) {
function markMessageStarted(record: ChatRecord) {
record.content.isLoading = false;
}
export function hasPlainText(record: ChatRecord) {
function hasPlainText(record: ChatRecord) {
return record.content.message.some(
(part) =>
part.type === "plain" && typeof part.text === "string" && part.text,
);
}
export function payloadText(value: unknown) {
function payloadText(value: unknown) {
if (typeof value === "string") return value;
if (value == null) return "";
if (typeof value === "object") {
@@ -1022,7 +875,7 @@ export function payloadText(value: unknown) {
return String(value);
}
export function parseJsonSafe(value: unknown) {
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);

View File

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

View File

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

View File

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

BIN
video-fix.patch Normal file

Binary file not shown.