fix:优化图片识别,优化webui配置和排版,优化聊天流监控,新增mcp显示,新增prompt修改面板,优化插件状态显示,优化长期记忆控制台,

This commit is contained in:
SengokuCola
2026-05-04 16:25:31 +08:00
parent c5cd47adc2
commit 120acb835f
51 changed files with 1764 additions and 493 deletions

View File

@@ -91,7 +91,7 @@ def _should_refresh_image_component(component: ImageComponent) -> bool:
"""判断图片组件当前是否仍处于待补全文本的占位状态。"""
normalized_content = component.content.strip()
return not normalized_content or normalized_content == "[图片]"
return not normalized_content or normalized_content == "[图片,识别中.....]"
def _should_refresh_emoji_component(component: EmojiComponent) -> bool:

View File

@@ -63,6 +63,7 @@ class ChatResponse:
completion_tokens: int
total_tokens: int
prompt_section: Optional[RenderableType] = None
prompt_html_uri: Optional[str] = None
logger = get_logger("maisaka_chat_loop")
@@ -585,8 +586,9 @@ class MaisakaChatLoopService:
all_tools = [item for item in raw_tool_definitions if isinstance(item, dict)]
prompt_section: RenderableType | None = None
prompt_html_uri: str | None = None
if global_config.debug.show_maisaka_thinking:
prompt_section = PromptCLIVisualizer.build_prompt_section(
prompt_section_result = PromptCLIVisualizer.build_prompt_section_result(
built_messages,
category="planner" if request_kind != "timing_gate" else "timing_gate",
chat_id=self._session_id,
@@ -595,6 +597,9 @@ class MaisakaChatLoopService:
folded=global_config.debug.fold_maisaka_thinking,
tool_definitions=list(all_tools),
)
prompt_section = prompt_section_result.panel
if prompt_section_result.preview_access is not None:
prompt_html_uri = prompt_section_result.preview_access.viewer_web_uri
llm_chat = self._get_llm_chat_client(request_kind)
generation_result = await llm_chat.generate_response_with_messages(
@@ -660,6 +665,7 @@ class MaisakaChatLoopService:
completion_tokens=completion_tokens,
total_tokens=total_tokens,
prompt_section=prompt_section,
prompt_html_uri=prompt_html_uri,
)
@staticmethod

View File

@@ -83,7 +83,7 @@ def _append_image_component(
builder.add_text_content(normalized_content)
return True
builder.add_text_content("[图片]")
builder.add_text_content("[图片,识别中.....]")
return True
@@ -147,7 +147,7 @@ def _render_component_for_prompt(component: StandardMessageComponents) -> str:
return (component.text or "").strip()
if isinstance(component, ImageComponent):
return component.content.strip() if component.content else "[图片]"
return component.content.strip() if component.content else "[图片,识别中.....]"
if isinstance(component, EmojiComponent):
return component.content.strip() if component.content else "[表情包]"

View File

@@ -7,6 +7,7 @@ from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Literal
from urllib.parse import quote
import hashlib
import html
@@ -32,6 +33,36 @@ from .prompt_preview_logger import PromptPreviewLogger
DATA_IMAGE_DIR = REPO_ROOT / "data" / "images"
def _build_prompt_preview_web_uri(file_path: Path) -> str:
"""构建 WebUI 可访问的 Prompt 预览地址。"""
try:
relative_path = file_path.resolve().relative_to(PromptPreviewLogger._BASE_DIR.resolve())
except ValueError:
return build_file_uri(file_path)
return f"/api/webui/config/maisaka-prompt-preview?path={quote(relative_path.as_posix(), safe='')}"
@dataclass(frozen=True)
class PromptPreviewAccess:
"""Prompt 预览文件的展示入口和可直接打开的路径。"""
body: RenderableType
viewer_path: Path
viewer_uri: str
viewer_web_uri: str
dump_path: Path
dump_uri: str
@dataclass(frozen=True)
class PromptSectionResult:
"""Prompt 面板及其可选 HTML 预览入口。"""
panel: Panel
preview_access: PromptPreviewAccess | None = None
class PromptImageDisplayMode(str, Enum):
"""图片在终端中的展示模式。"""
@@ -470,6 +501,77 @@ class PromptCLIVisualizer:
),
)
@classmethod
def build_prompt_preview_access(
cls,
messages: list[Any],
*,
category: str,
chat_id: str,
request_kind: str,
selection_reason: str,
tool_definitions: list[dict[str, Any]] | None = None,
) -> PromptPreviewAccess:
"""保存 Prompt 预览文件,并返回 CLI 展示入口与浏览器可打开的 URI。"""
viewer_messages: list[dict[str, Any]] = []
for message in messages:
if isinstance(message, dict):
viewer_messages.append(dict(message))
continue
normalized_message = {
"content": getattr(message, "content", None),
"role": getattr(getattr(message, "role", "unknown"), "value", getattr(message, "role", "unknown")),
}
tool_call_id = getattr(message, "tool_call_id", None)
if tool_call_id:
normalized_message["tool_call_id"] = tool_call_id
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
normalized_message["tool_calls"] = [
cls.format_tool_call_for_display(tool_call) for tool_call in tool_calls
]
viewer_messages.append(normalized_message)
prompt_dump_text = cls._build_prompt_dump_text(messages)
tool_definition_dump_text = cls._build_tool_definition_dump_text(tool_definitions)
if tool_definition_dump_text:
prompt_dump_text = f"{prompt_dump_text}\n\n{'=' * 80}\n\n{tool_definition_dump_text}"
viewer_html_text = cls._build_prompt_viewer_html(
viewer_messages,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
saved_paths = PromptPreviewLogger.save_preview_files(
chat_id,
category,
{
".html": viewer_html_text,
".txt": prompt_dump_text,
},
)
viewer_html_path = saved_paths[".html"]
prompt_dump_path = saved_paths[".txt"]
body = cls._build_preview_access_body(
viewer_label="html预览",
viewer_path=viewer_html_path,
viewer_link_text="在浏览器打开 Prompt",
dump_label="原始文本",
dump_path=prompt_dump_path,
dump_link_text="点击打开 Prompt 文本",
)
return PromptPreviewAccess(
body=body,
viewer_path=viewer_html_path,
viewer_uri=build_file_uri(viewer_html_path),
viewer_web_uri=_build_prompt_preview_web_uri(viewer_html_path),
dump_path=prompt_dump_path,
dump_uri=build_file_uri(prompt_dump_path),
)
@classmethod
def _build_html_role_class(cls, role: str) -> str:
return {
@@ -804,56 +906,14 @@ class PromptCLIVisualizer:
) -> RenderableType:
"""构建用于查看完整 prompt 的折叠入口内容。"""
viewer_messages: list[dict[str, Any]] = []
for message in messages:
if isinstance(message, dict):
viewer_messages.append(dict(message))
continue
normalized_message = {
"content": getattr(message, "content", None),
"role": getattr(getattr(message, "role", "unknown"), "value", getattr(message, "role", "unknown")),
}
tool_call_id = getattr(message, "tool_call_id", None)
if tool_call_id:
normalized_message["tool_call_id"] = tool_call_id
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
normalized_message["tool_calls"] = [
cls.format_tool_call_for_display(tool_call) for tool_call in tool_calls
]
viewer_messages.append(normalized_message)
prompt_dump_text = cls._build_prompt_dump_text(messages)
tool_definition_dump_text = cls._build_tool_definition_dump_text(tool_definitions)
if tool_definition_dump_text:
prompt_dump_text = f"{prompt_dump_text}\n\n{'=' * 80}\n\n{tool_definition_dump_text}"
viewer_html_text = cls._build_prompt_viewer_html(
viewer_messages,
return cls.build_prompt_preview_access(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
saved_paths = PromptPreviewLogger.save_preview_files(
chat_id,
category,
{
".html": viewer_html_text,
".txt": prompt_dump_text,
},
)
viewer_html_path = saved_paths[".html"]
prompt_dump_path = saved_paths[".txt"]
body = cls._build_preview_access_body(
viewer_label="html预览",
viewer_path=viewer_html_path,
viewer_link_text="在浏览器打开 Prompt",
dump_label="原始文本",
dump_path=prompt_dump_path,
dump_link_text="点击打开 Prompt 文本",
)
return body
).body
@classmethod
def build_prompt_section(
@@ -870,26 +930,56 @@ class PromptCLIVisualizer:
) -> Panel:
"""构建用于嵌入结果面板中的 Prompt 区块。"""
return cls.build_prompt_section_result(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
image_display_mode=image_display_mode,
folded=folded,
tool_definitions=tool_definitions,
).panel
@classmethod
def build_prompt_section_result(
cls,
messages: list[Any],
*,
category: str,
chat_id: str,
request_kind: str,
selection_reason: str,
image_display_mode: Literal["legacy", "path_link"] = "path_link",
folded: bool,
tool_definitions: list[dict[str, Any]] | None = None,
) -> PromptSectionResult:
"""构建 Prompt 面板,并在折叠模式下返回对应的 HTML 预览入口。"""
panel_title, panel_border_style = cls.get_request_panel_style(request_kind)
preview_access = cls.build_prompt_preview_access(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
if folded:
prompt_renderable = cls.build_prompt_access_panel(
messages,
category=category,
chat_id=chat_id,
request_kind=request_kind,
selection_reason=selection_reason,
tool_definitions=tool_definitions,
)
prompt_renderable = preview_access.body
else:
ordered_panels = cls.build_prompt_panels(messages)
prompt_renderable = Group(*ordered_panels)
return Panel(
prompt_renderable,
title=panel_title,
subtitle=selection_reason,
border_style=panel_border_style,
padding=(0, 1),
return PromptSectionResult(
panel=Panel(
prompt_renderable,
title=panel_title,
subtitle=selection_reason,
border_style=panel_border_style,
padding=(0, 1),
),
preview_access=preview_access,
)
@classmethod

View File

@@ -95,7 +95,7 @@ def build_visible_text_from_sequence(message_sequence: MessageSequence) -> str:
continue
if isinstance(component, ImageComponent):
append_visible_part(component.content.strip() or "[图片]")
append_visible_part(component.content.strip() or "[图片,识别中.....]")
continue
if isinstance(component, AtComponent):

View File

@@ -4,8 +4,9 @@
"""
from datetime import datetime
import time
from typing import Any, Dict, List, Optional
import json
import time
from src.common.logger import get_logger
@@ -57,7 +58,7 @@ def _extract_text_content(content: Any) -> Optional[str]:
if block_type == "text":
text_parts.append(str(block.get("text", "")))
elif block_type == "image_url":
text_parts.append("[图片]")
text_parts.append("[图片,识别中.....]")
else:
text_parts.append(f"[{block_type}]")
elif isinstance(block, str):
@@ -66,43 +67,65 @@ def _extract_text_content(content: Any) -> Optional[str]:
return str(content)
def _normalize_tool_call_arguments(arguments: Any) -> tuple[Any, Optional[str]]:
"""标准化工具调用参数,兼容 JSON 字符串和对象。"""
if isinstance(arguments, str):
raw_arguments = arguments
try:
parsed_arguments = json.loads(arguments) if arguments.strip() else {}
except json.JSONDecodeError:
return {}, raw_arguments
return _normalize_payload_value(parsed_arguments), raw_arguments
return _normalize_payload_value(arguments or {}), None
def _serialize_single_tool_call(tool_call: Any) -> Dict[str, Any]:
"""将不同来源的 tool_call 标准化为前端可直接展示的结构。"""
if isinstance(tool_call, dict):
function_info = tool_call.get("function")
if isinstance(function_info, dict):
raw_arguments = function_info.get("arguments", tool_call.get("arguments", tool_call.get("args", {})))
name = function_info.get("name", tool_call.get("name", tool_call.get("func_name", "unknown")))
else:
raw_arguments = tool_call.get("arguments", tool_call.get("args", {}))
name = tool_call.get("name", tool_call.get("func_name", "unknown"))
arguments, arguments_raw = _normalize_tool_call_arguments(raw_arguments)
serialized: Dict[str, Any] = {
"id": str(tool_call.get("id", tool_call.get("call_id", ""))),
"name": str(name or "unknown"),
"arguments": arguments,
}
if arguments_raw is not None:
serialized["arguments_raw"] = arguments_raw
return serialized
raw_arguments = getattr(tool_call, "args", None)
if raw_arguments is None:
raw_arguments = getattr(tool_call, "arguments", None)
arguments, arguments_raw = _normalize_tool_call_arguments(raw_arguments)
serialized = {
"id": str(getattr(tool_call, "id", None) or getattr(tool_call, "call_id", "")),
"name": str(getattr(tool_call, "func_name", None) or getattr(tool_call, "name", "unknown")),
"arguments": arguments,
}
if arguments_raw is not None:
serialized["arguments_raw"] = arguments_raw
return serialized
def _serialize_tool_calls_from_objects(tool_calls: List[Any]) -> List[Dict[str, Any]]:
"""将工具调用对象列表序列化为字典列表。"""
result: List[Dict[str, Any]] = []
for tool_call in tool_calls:
serialized: Dict[str, Any] = {
"id": getattr(tool_call, "id", None) or getattr(tool_call, "call_id", ""),
"name": getattr(tool_call, "func_name", None) or getattr(tool_call, "name", "unknown"),
}
args = getattr(tool_call, "args", None) or getattr(tool_call, "arguments", None)
if isinstance(args, dict):
serialized["arguments"] = _normalize_payload_value(args)
elif isinstance(args, str):
serialized["arguments_raw"] = args
result.append(serialized)
return result
return [_serialize_single_tool_call(tool_call) for tool_call in tool_calls]
def _serialize_tool_calls_from_dicts(tool_calls: List[Any]) -> List[Dict[str, Any]]:
"""将工具调用字典列表标准化为可传输格式。"""
result: List[Dict[str, Any]] = []
for tool_call in tool_calls:
if isinstance(tool_call, dict):
result.append({
"id": str(tool_call.get("id", "")),
"name": str(tool_call.get("name", tool_call.get("func_name", "unknown"))),
"arguments": _normalize_payload_value(tool_call.get("arguments", tool_call.get("args", {}))),
})
continue
result.append({
"id": str(getattr(tool_call, "id", getattr(tool_call, "call_id", ""))),
"name": str(getattr(tool_call, "func_name", getattr(tool_call, "name", "unknown"))),
"arguments": _normalize_payload_value(getattr(tool_call, "args", getattr(tool_call, "arguments", {}))),
})
return result
return [_serialize_single_tool_call(tool_call) for tool_call in tool_calls]
def _serialize_message(message: Any) -> Dict[str, Any]:
@@ -214,6 +237,7 @@ def _serialize_planner_block(
completion_tokens: Optional[int],
total_tokens: Optional[int],
duration_ms: Optional[float],
prompt_html_uri: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""标准化 planner 结果区块。"""
@@ -224,6 +248,7 @@ def _serialize_planner_block(
and completion_tokens is None
and total_tokens is None
and duration_ms is None
and prompt_html_uri is None
):
return None
@@ -234,6 +259,7 @@ def _serialize_planner_block(
"completion_tokens": int(completion_tokens or 0),
"total_tokens": int(total_tokens or 0),
"duration_ms": float(duration_ms or 0.0),
"prompt_html_uri": str(prompt_html_uri or ""),
}
@@ -429,6 +455,7 @@ async def emit_planner_finalized(
planner_completion_tokens: Optional[int],
planner_total_tokens: Optional[int],
planner_duration_ms: Optional[float],
planner_prompt_html_uri: Optional[str],
tools: Optional[List[Dict[str, Any]]],
time_records: Dict[str, float],
agent_state: str,
@@ -464,6 +491,7 @@ async def emit_planner_finalized(
planner_completion_tokens,
planner_total_tokens,
planner_duration_ms,
planner_prompt_html_uri,
),
"tools": _serialize_tool_results(list(tools or [])),
"final_state": {

View File

@@ -709,6 +709,7 @@ class MaisakaReasoningEngine:
),
planner_total_tokens=response.total_tokens if response is not None else None,
planner_duration_ms=planner_duration_ms if response is not None else None,
planner_prompt_html_uri=response.prompt_html_uri if response is not None else None,
tools=tool_monitor_results,
time_records=dict(completed_cycle.time_records),
agent_state=self._runtime._agent_state,