TUI 界面改进: - 全面中文化: CommandPalette/HelpPanel/Footer/Chatbox 边框标题等所有用户可见英文汉化 - 辅助中心 (Ctrl+P/F9): 原"设置"重命名, 含 6 项可切换设置 - 独立帮助浮层 (? 键): 重写帮助页面逻辑, 全中文, 带滑入淡入动画 - 背景切换: 辅助中心 [5] 预设背景, [6] 自定义背景; 新增 /bg 命令支持图片提取主色调 - 换行修复: Ctrl+J 作为主推换行键 (Windows 终端不区分 Shift+Enter) - 引擎切换修复: 解决 call_from_thread 在主线程崩溃的 RuntimeError 技术改进: - 文本工具调用解析: 检测模型用 <tool_call> 标签返回的工具调用 - 网络工具优化: network_tools.py 和 ai_search.py 改进请求头/重试/编码 - 流式输出修复: append_chunk 的 refresh 改为 layout=True - 取消响应修复: 引擎层 _cancelled 标志 + TUI 层 worker 身份跟踪 融合 v6.0.0 工作: - 保留 terminal 模拟器/operit 桥接/深度思考/工作模式/记忆模式等 v6.0.0 全部功能 - 在此基础上叠加本会话的 TUI 改进
2541 lines
100 KiB
Python
2541 lines
100 KiB
Python
"""TUI 界面模块 — 基于 elia 开源项目架构重写
|
||
|
||
原项目: https://github.com/darrenburns/elia (Apache-2.0)
|
||
核心组件: Chatbox(Markdown渲染) + ResponseStatus(LoadingIndicator) + @work(thread=True)流式
|
||
"""
|
||
|
||
from .constants import TEXTUAL_AVAILABLE
|
||
|
||
if TEXTUAL_AVAILABLE:
|
||
import re
|
||
import os
|
||
from functools import partial
|
||
from dataclasses import dataclass
|
||
from textual.app import App, ComposeResult, SystemCommand
|
||
from textual.containers import Horizontal, Vertical, VerticalScroll, Container
|
||
from textual.widgets import Header, Static, Rule, TextArea, Footer, LoadingIndicator, Label
|
||
from textual.widgets.option_list import Option
|
||
from textual.reactive import var, reactive
|
||
from textual.binding import Binding
|
||
from textual import on, work, events
|
||
from textual.message import Message
|
||
from textual.widget import Widget
|
||
from textual.css.query import NoMatches
|
||
from textual.command import CommandPalette, CommandList
|
||
from textual.screen import Screen
|
||
from rich.console import RenderableType
|
||
from rich.markdown import Markdown
|
||
from rich.syntax import Syntax
|
||
from rich.text import Text
|
||
from rich.align import Align
|
||
from typing import Iterable
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# 主题色
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
class _Theme:
|
||
BG = "#0d1117"
|
||
BG_LIGHT = "#161b22"
|
||
BG_INPUT = "#0d1117"
|
||
BORDER = "#30363d"
|
||
BORDER_FOCUS = "#58a6ff"
|
||
TEXT = "#c9d1d9"
|
||
TEXT_DIM = "#484f58"
|
||
TEXT_MUTED = "#8b949e"
|
||
ACCENT = "#58a6ff"
|
||
SUCCESS = "#3fb950"
|
||
WARNING = "#d29922"
|
||
ERROR = "#f85149"
|
||
USER = "#79c0ff"
|
||
AI = "#f0f6fc"
|
||
TOOL = "#56d364"
|
||
CODE_BG = "#161b22"
|
||
GLOW = "#1f6feb"
|
||
THINKING = "#8b949e"
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# CSS(融合 elia 样式 + 项目主题色)
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
_TUI_CSS = f"""
|
||
Screen {{
|
||
background: {_Theme.BG};
|
||
}}
|
||
|
||
#app-container {{
|
||
height: 1fr;
|
||
width: 100%;
|
||
layout: vertical;
|
||
}}
|
||
|
||
#chat-scroll {{
|
||
height: 1fr;
|
||
background: {_Theme.BG};
|
||
scrollbar-color: {_Theme.BORDER};
|
||
scrollbar-color-hover: {_Theme.TEXT_MUTED};
|
||
padding: 0 1;
|
||
}}
|
||
|
||
/* ── 输入区域 (elia PromptInput 风格) ── */
|
||
#input-area {{
|
||
height: auto;
|
||
min-height: 5;
|
||
max-height: 14;
|
||
padding: 0 1 1 1;
|
||
}}
|
||
|
||
#input-wrapper {{
|
||
height: auto;
|
||
min-height: 4;
|
||
max-height: 12;
|
||
background: {_Theme.BG_INPUT};
|
||
border: round {_Theme.BORDER};
|
||
padding: 0;
|
||
transition: border-color 300ms in_out_cubic;
|
||
}}
|
||
|
||
#input-wrapper:focus-within {{
|
||
border: round {_Theme.BORDER_FOCUS};
|
||
background: {_Theme.BG};
|
||
}}
|
||
|
||
#user-input {{
|
||
height: auto;
|
||
min-height: 3;
|
||
max-height: 10;
|
||
background: transparent;
|
||
color: {_Theme.TEXT};
|
||
padding: 0 1;
|
||
border: none;
|
||
}}
|
||
|
||
#user-input:focus {{
|
||
background: transparent;
|
||
}}
|
||
|
||
#user-input .text-area--cursor {{
|
||
color: {_Theme.ACCENT};
|
||
}}
|
||
|
||
#user-input .text-area--cursor-line {{
|
||
background: {_Theme.BG_LIGHT} 50%;
|
||
}}
|
||
|
||
#user-input .text-area--selection {{
|
||
background: {_Theme.GLOW} 40%;
|
||
}}
|
||
|
||
#input-hint {{
|
||
height: 1;
|
||
color: {_Theme.TEXT_DIM};
|
||
padding: 0 1;
|
||
opacity: 0;
|
||
transition: opacity 500ms in_out_cubic;
|
||
}}
|
||
|
||
#input-hint.visible {{
|
||
opacity: 1;
|
||
}}
|
||
|
||
/* ── 命令提示浮层 — 在输入框上方,display:none 时不占空间 ── */
|
||
#command-hint {{
|
||
layer: overlay;
|
||
display: none;
|
||
height: auto;
|
||
max-height: 10;
|
||
width: 1fr;
|
||
margin: 0 1 1 1;
|
||
padding: 0 1;
|
||
background: {_Theme.BG_LIGHT} 98%;
|
||
border: round {_Theme.ACCENT};
|
||
overflow-y: auto;
|
||
}}
|
||
.cmd-hint-cmd {{
|
||
color: {_Theme.ACCENT};
|
||
text-style: bold;
|
||
}}
|
||
.cmd-hint-desc {{
|
||
color: {_Theme.TEXT_DIM};
|
||
}}
|
||
|
||
/* ── 安全审查确认浮层 ── */
|
||
#confirm-overlay {{
|
||
dock: top;
|
||
layer: overlay;
|
||
display: none;
|
||
height: auto;
|
||
max-height: 16;
|
||
width: 60;
|
||
min-width: 40;
|
||
margin: 2 4;
|
||
padding: 1 2;
|
||
background: {_Theme.BG};
|
||
border: heavy {_Theme.ERROR};
|
||
overflow-y: auto;
|
||
}}
|
||
#confirm-overlay.visible {{
|
||
display: block;
|
||
}}
|
||
|
||
/* ── 辅助中心 ── 背景遮罩 + 居中卡片 */
|
||
#settings-backdrop {{
|
||
layer: overlay;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: none;
|
||
background: {_Theme.BG} 70%;
|
||
offset: 0 0;
|
||
opacity: 0;
|
||
transition: opacity 220ms out_cubic;
|
||
}}
|
||
#settings-backdrop.visible {{
|
||
display: block;
|
||
opacity: 1;
|
||
}}
|
||
#settings-backdrop.hiding {{
|
||
opacity: 0;
|
||
transition: opacity 180ms in_cubic;
|
||
}}
|
||
|
||
#settings-overlay {{
|
||
layer: overlay;
|
||
dock: top;
|
||
display: none;
|
||
height: auto;
|
||
max-height: 28;
|
||
width: 76;
|
||
min-width: 56;
|
||
margin: 2 4;
|
||
padding: 1 2;
|
||
background: {_Theme.BG_LIGHT} 98%;
|
||
border: round {_Theme.ACCENT} 80%;
|
||
overflow-y: auto;
|
||
offset: 0 -12;
|
||
opacity: 0;
|
||
transition: offset 320ms out_cubic, opacity 280ms out_cubic;
|
||
}}
|
||
#settings-overlay.visible {{
|
||
display: block;
|
||
offset: 0 0;
|
||
opacity: 1;
|
||
}}
|
||
#settings-overlay.hiding {{
|
||
offset: 0 -12;
|
||
opacity: 0;
|
||
transition: offset 240ms in_cubic, opacity 200ms in_cubic;
|
||
}}
|
||
.settings-title {{
|
||
color: {_Theme.ACCENT};
|
||
text-style: bold;
|
||
text-align: center;
|
||
padding: 0 0 1 0;
|
||
}}
|
||
.settings-section {{
|
||
color: {_Theme.TEXT_MUTED};
|
||
text-style: bold;
|
||
padding: 1 0 0 0;
|
||
}}
|
||
.settings-line {{
|
||
color: {_Theme.TEXT};
|
||
padding: 0 1;
|
||
}}
|
||
.settings-hint {{
|
||
color: {_Theme.TEXT_DIM};
|
||
text-style: italic;
|
||
padding: 1 0 0 0;
|
||
}}
|
||
.settings-key {{
|
||
color: {_Theme.WARNING};
|
||
text-style: bold;
|
||
}}
|
||
.settings-val {{
|
||
color: {_Theme.SUCCESS};
|
||
text-style: bold;
|
||
}}
|
||
|
||
/* ── 帮助浮层 ── 背景遮罩 + 居中卡片 */
|
||
#help-backdrop {{
|
||
layer: overlay;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: none;
|
||
background: {_Theme.BG} 70%;
|
||
offset: 0 0;
|
||
opacity: 0;
|
||
transition: opacity 220ms out_cubic;
|
||
}}
|
||
#help-backdrop.visible {{
|
||
display: block;
|
||
opacity: 1;
|
||
}}
|
||
#help-backdrop.hiding {{
|
||
opacity: 0;
|
||
transition: opacity 180ms in_cubic;
|
||
}}
|
||
|
||
#help-overlay {{
|
||
layer: overlay;
|
||
dock: top;
|
||
display: none;
|
||
height: auto;
|
||
max-height: 32;
|
||
width: 80;
|
||
min-width: 60;
|
||
margin: 1 2;
|
||
padding: 1 2;
|
||
background: {_Theme.BG_LIGHT} 98%;
|
||
border: round {_Theme.ACCENT} 80%;
|
||
overflow-y: auto;
|
||
offset: 0 -12;
|
||
opacity: 0;
|
||
transition: offset 320ms out_cubic, opacity 280ms out_cubic;
|
||
}}
|
||
#help-overlay.visible {{
|
||
display: block;
|
||
offset: 0 0;
|
||
opacity: 1;
|
||
}}
|
||
#help-overlay.hiding {{
|
||
offset: 0 -12;
|
||
opacity: 0;
|
||
transition: offset 240ms in_cubic, opacity 200ms in_cubic;
|
||
}}
|
||
|
||
/* ── 侧栏 ── 横向滑入滑出 + 淡入淡出 */
|
||
#sidebar {{
|
||
width: 32;
|
||
min-width: 32;
|
||
height: 1fr;
|
||
dock: right;
|
||
background: {_Theme.BG_LIGHT};
|
||
border-left: tall {_Theme.BORDER};
|
||
/* 用 opacity 控制可见性,offset 制造滑入滑出感 */
|
||
opacity: 0;
|
||
offset: 16 0;
|
||
display: none;
|
||
transition: offset 320ms out_cubic, opacity 280ms out_cubic;
|
||
}}
|
||
|
||
#sidebar.visible {{
|
||
display: block;
|
||
opacity: 1;
|
||
offset: 0 0;
|
||
}}
|
||
|
||
#sidebar.hiding {{
|
||
opacity: 0;
|
||
offset: 16 0;
|
||
transition: offset 240ms in_cubic, opacity 200ms in_cubic;
|
||
}}
|
||
|
||
.sidebar-header {{
|
||
width: 100%;
|
||
text-align: center;
|
||
color: {_Theme.ACCENT};
|
||
text-style: bold;
|
||
padding: 1 0 0 0;
|
||
}}
|
||
|
||
.sidebar-section {{
|
||
height: auto;
|
||
padding: 0 1;
|
||
margin: 0 0 1 0;
|
||
}}
|
||
|
||
.engine-item {{
|
||
padding: 0 1;
|
||
color: {_Theme.TEXT_MUTED};
|
||
}}
|
||
|
||
.engine-item-active {{
|
||
padding: 0 1;
|
||
color: {_Theme.SUCCESS};
|
||
text-style: bold;
|
||
}}
|
||
|
||
.tool-item {{
|
||
padding: 0 0 0 1;
|
||
color: {_Theme.TEXT_MUTED};
|
||
}}
|
||
|
||
/* ── 可切换背景效果 — 通过 #chat-scroll 的 class 切换 ──
|
||
Textual CSS 不支持 gradient 函数,改用纯色色调切换
|
||
bg-grid: 深蓝灰
|
||
bg-stars: 深紫
|
||
bg-glow: 深蓝
|
||
默认(无 class): 原始深黑
|
||
*/
|
||
#chat-scroll.bg-grid {{
|
||
background: #11161e;
|
||
}}
|
||
#chat-scroll.bg-stars {{
|
||
background: #16121f;
|
||
}}
|
||
#chat-scroll.bg-glow {{
|
||
background: #0f1620;
|
||
}}
|
||
|
||
/* ── 状态栏 ── */
|
||
#status-bar {{
|
||
height: 1;
|
||
width: 100%;
|
||
dock: bottom;
|
||
background: {_Theme.BG_LIGHT};
|
||
color: {_Theme.TEXT_MUTED};
|
||
padding: 0 1;
|
||
}}
|
||
|
||
#status-bar.pulse {{
|
||
color: {_Theme.WARNING};
|
||
background: {_Theme.GLOW} 40%;
|
||
transition: color 150ms, background 150ms;
|
||
}}
|
||
|
||
/* ── Footer ── */
|
||
Footer {{
|
||
background: {_Theme.BG_LIGHT};
|
||
color: {_Theme.TEXT_MUTED};
|
||
border-top: tall {_Theme.BORDER};
|
||
}}
|
||
|
||
Footer > .key {{
|
||
background: {_Theme.BG};
|
||
color: {_Theme.ACCENT};
|
||
}}
|
||
|
||
/* ── 消息样式 ── */
|
||
.msg-system {{
|
||
color: {_Theme.ACCENT};
|
||
padding: 0 1;
|
||
}}
|
||
|
||
.msg-tool-ok {{
|
||
color: {_Theme.SUCCESS};
|
||
padding: 0 1;
|
||
}}
|
||
|
||
.msg-tool-err {{
|
||
color: {_Theme.ERROR};
|
||
padding: 0 1;
|
||
}}
|
||
|
||
.msg-error {{
|
||
color: {_Theme.ERROR};
|
||
padding: 0 1;
|
||
}}
|
||
|
||
.msg-dim {{
|
||
color: {_Theme.TEXT_DIM};
|
||
padding: 0 1;
|
||
}}
|
||
|
||
.msg-welcome {{
|
||
color: {_Theme.ACCENT};
|
||
padding: 0 1;
|
||
text-style: bold;
|
||
}}
|
||
|
||
/* ── ResponseStatus (elia 原版) ── */
|
||
ResponseStatus {{
|
||
dock: top;
|
||
align-horizontal: right;
|
||
display: none;
|
||
layer: overlay;
|
||
height: 2;
|
||
width: auto;
|
||
margin-top: 1;
|
||
margin-right: 2;
|
||
}}
|
||
ResponseStatus Label {{
|
||
width: auto;
|
||
color: {_Theme.WARNING};
|
||
}}
|
||
ResponseStatus LoadingIndicator {{
|
||
width: auto;
|
||
color: {_Theme.ACCENT};
|
||
height: 1;
|
||
margin-top: 1;
|
||
dock: right;
|
||
}}
|
||
ResponseStatus.-awaiting-response LoadingIndicator {{
|
||
color: {_Theme.ACCENT};
|
||
}}
|
||
ResponseStatus.-agent-responding LoadingIndicator {{
|
||
color: {_Theme.WARNING};
|
||
}}
|
||
|
||
/* ── Chatbox (elia 原版样式) ── */
|
||
Chatbox {{
|
||
height: auto;
|
||
width: 1fr;
|
||
max-width: 1fr;
|
||
margin: 0 1;
|
||
padding: 0 2;
|
||
}}
|
||
Chatbox.assistant-message {{
|
||
border: round {_Theme.ACCENT};
|
||
}}
|
||
Chatbox.assistant-message.response-in-progress {{
|
||
background: {_Theme.ACCENT} 3%;
|
||
}}
|
||
Chatbox.human-message {{
|
||
border: round {_Theme.BORDER};
|
||
}}
|
||
|
||
/* ── ThinkingBox:深度思考专用 widget(独立显示,灰色斜体) ── */
|
||
ThinkingBox {{
|
||
height: auto;
|
||
width: 1fr;
|
||
max-width: 1fr;
|
||
margin: 0 1;
|
||
padding: 0 2;
|
||
border: round {_Theme.TEXT_DIM};
|
||
background: {_Theme.BG_LIGHT};
|
||
color: {_Theme.TEXT_DIM};
|
||
}}
|
||
ThinkingBox > .thinking-title {{
|
||
color: {_Theme.TEXT_MUTED};
|
||
text-style: bold italic;
|
||
padding: 0 0 1 0;
|
||
}}
|
||
ThinkingBox > .thinking-body {{
|
||
color: {_Theme.TEXT_DIM};
|
||
text-style: italic;
|
||
}}
|
||
|
||
/* ── 引擎切换浮层 ── */
|
||
EngineSwitchBanner {{
|
||
layer: overlay;
|
||
dock: top;
|
||
height: 7;
|
||
width: auto;
|
||
min-width: 44;
|
||
max-width: 72;
|
||
content-align: center middle;
|
||
text-align: center;
|
||
background: {_Theme.BG_LIGHT} 96%;
|
||
color: {_Theme.ACCENT};
|
||
text-style: bold;
|
||
border: round {_Theme.ACCENT} 80%;
|
||
border-title-color: {_Theme.ACCENT};
|
||
border-title-background: {_Theme.BG_LIGHT};
|
||
border-title-style: bold;
|
||
padding: 1 6;
|
||
margin: 2 0;
|
||
offset: 0 -8;
|
||
opacity: 0;
|
||
transition: offset 380ms out_cubic, opacity 380ms out_cubic;
|
||
}}
|
||
EngineSwitchBanner.show {{
|
||
offset: 0 0;
|
||
opacity: 1;
|
||
}}
|
||
EngineSwitchBanner.hide {{
|
||
offset: 0 -8;
|
||
opacity: 0;
|
||
transition: offset 280ms in_cubic, opacity 280ms in_cubic;
|
||
}}
|
||
"""
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# 桥接 TUI ↔ AICLI
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
class _TUIBridge:
|
||
"""桥接 TUI ↔ AICLI"""
|
||
def __init__(self, cli):
|
||
self.cli = cli
|
||
def engines(self) -> list:
|
||
return list(self.cli.engines.keys())
|
||
def current_engine(self) -> str:
|
||
return self.cli.get_current_engine_name()
|
||
def tools(self) -> list:
|
||
return self.cli.liugin_manager.tools
|
||
def history(self) -> list:
|
||
return self.cli.shared_conversation_history
|
||
def switch_engine(self, name: str) -> bool:
|
||
if name in self.cli.engines:
|
||
self.cli.switch_engine(name)
|
||
return True
|
||
return False
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# Chatbox — 直接复制自 elia,适配为无 litellm 依赖
|
||
# 原项目: https://github.com/darrenburns/elia/blob/main/elia_chat/widgets/chatbox.py
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
class Chatbox(Widget, can_focus=True):
|
||
"""支持 Markdown 渲染的聊天消息组件 — 流式追加
|
||
|
||
render() 返回 Rich Markdown 对象,实现真正的代码高亮和格式化。
|
||
append_chunk() 用于流式追加文本,只刷新布局不重建 widget。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
role: str = "assistant",
|
||
content: str = "",
|
||
name: str | None = None,
|
||
id: str | None = None,
|
||
classes: str | None = None,
|
||
disabled: bool = False,
|
||
) -> None:
|
||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||
self.role = role
|
||
self.content = content
|
||
|
||
def on_mount(self) -> None:
|
||
if self.role == "assistant":
|
||
self.add_class("assistant-message")
|
||
self.border_title = "小狸"
|
||
else:
|
||
self.add_class("human-message")
|
||
self.border_title = "你"
|
||
|
||
def render(self) -> RenderableType:
|
||
if not self.content:
|
||
return ""
|
||
if self.role == "user":
|
||
return Syntax(
|
||
self.content,
|
||
lexer="markdown",
|
||
word_wrap=True,
|
||
background_color=_Theme.BG,
|
||
)
|
||
return Markdown(self.content, code_theme="monokai")
|
||
|
||
def append_chunk(self, chunk: str) -> None:
|
||
"""流式追加文本 — 来自 elia 的原版实现"""
|
||
self.content += chunk
|
||
self.refresh(layout=True)
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# ThinkingBox — 深度思考专用 widget(独立于 AI 回答框)
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
class ThinkingBox(Widget, can_focus=False):
|
||
"""深度思考专用 widget — 灰色斜体,独立显示在 AI 回答框之前
|
||
|
||
用于显示深度思考模型(如 DeepSeek-R1、QwQ)的推理过程,
|
||
与最终回答在视觉上分开。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
content: str = "",
|
||
name: str | None = None,
|
||
id: str | None = None,
|
||
classes: str | None = None,
|
||
) -> None:
|
||
super().__init__(name=name, id=id, classes=classes)
|
||
self.content = content
|
||
|
||
def render(self) -> RenderableType:
|
||
if not self.content:
|
||
return ""
|
||
return Markdown(
|
||
f"💭 **深度思考**\n\n{self.content}",
|
||
code_theme="monokai",
|
||
)
|
||
|
||
def append_chunk(self, chunk: str) -> None:
|
||
"""流式追加思考内容"""
|
||
self.content += chunk
|
||
self.refresh(layout=True)
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# ResponseStatus — 直接复制自 elia
|
||
# 原项目: https://github.com/darrenburns/elia/blob/main/elia_chat/widgets/agent_is_typing.py
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
class ResponseStatus(Vertical):
|
||
"""响应状态指示器 — 使用 Textual 内置 LoadingIndicator
|
||
|
||
两种状态:
|
||
- awaiting-response: 等待 AI 响应(思考中)
|
||
- agent-responding: AI 正在输出(流式中)
|
||
"""
|
||
message: reactive[str] = reactive(" 思考中", recompose=False)
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Label(f" {self.message}", id="rs-label")
|
||
yield LoadingIndicator()
|
||
|
||
def watch_message(self, new_value: str) -> None:
|
||
try:
|
||
self.query_one("#rs-label").update(f" {new_value}")
|
||
except NoMatches:
|
||
pass
|
||
|
||
def set_awaiting_response(self) -> None:
|
||
self.message = " 思考中"
|
||
self.add_class("-awaiting-response")
|
||
self.remove_class("-agent-responding")
|
||
self.display = True
|
||
|
||
def set_agent_responding(self) -> None:
|
||
self.message = " 输出中"
|
||
self.add_class("-agent-responding")
|
||
self.remove_class("-awaiting-response")
|
||
|
||
def hide(self) -> None:
|
||
self.display = False
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# 辅助组件
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
class EngineSwitchBanner(Static):
|
||
"""引擎切换浮层 — 居中悬浮卡片,多行带色彩层次"""
|
||
DEFAULT_CSS = ""
|
||
|
||
def __init__(self, old_engine: str, new_engine: str, **kwargs):
|
||
from rich.text import Text
|
||
# 三行布局:标题行 + 切换行 + 状态行,用 Rich Text 上色
|
||
title = Text("⚡ 引擎切换", style=f"bold {self._acc()}", justify="center")
|
||
arrow = Text(" → ", style="dim")
|
||
old_t = Text(old_engine, style="dim strike")
|
||
new_t = Text(new_engine, style=f"bold {self._acc()}")
|
||
switch_line = Text.assemble(old_t, arrow, new_t)
|
||
hint = Text("切换中…", style="dim italic", justify="center")
|
||
content = Text.assemble(title, "\n", switch_line, "\n", hint)
|
||
super().__init__(content, **kwargs)
|
||
|
||
@staticmethod
|
||
def _acc():
|
||
return "#58a6ff"
|
||
|
||
class SendTextArea(TextArea):
|
||
"""回车发送消息,Ctrl+J 换行的输入框"""
|
||
async def _on_key(self, event):
|
||
# 命令提示导航:上下键 / Tab(提示可见时)
|
||
hint_visible = False
|
||
try:
|
||
hint = self.app.query_one("#command-hint")
|
||
hint_visible = hint.display and bool(hint._items)
|
||
if hint_visible:
|
||
if event.key == "up":
|
||
event.prevent_default()
|
||
hint.move_selection(-1)
|
||
return
|
||
elif event.key == "down":
|
||
event.prevent_default()
|
||
hint.move_selection(1)
|
||
return
|
||
elif event.key == "tab":
|
||
event.prevent_default()
|
||
cmd = hint.get_selected_command()
|
||
if cmd:
|
||
self.text = cmd + " "
|
||
self.cursor_location = (0, len(self.text))
|
||
return
|
||
elif event.key == "escape":
|
||
event.prevent_default()
|
||
self.app._hide_command_hint()
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
# 输入历史导航:上下键(提示不可见时)
|
||
if not hint_visible and event.key in ("up", "down"):
|
||
history = getattr(self.app, '_input_history', [])
|
||
if history:
|
||
event.prevent_default()
|
||
idx = getattr(self.app, '_history_idx', -1)
|
||
if event.key == "up":
|
||
if idx == -1:
|
||
idx = len(history) - 1
|
||
else:
|
||
idx = max(0, idx - 1)
|
||
self.text = history[idx]
|
||
else: # down
|
||
if idx == -1:
|
||
return # 已在最新,无操作
|
||
idx += 1
|
||
if idx >= len(history):
|
||
idx = -1
|
||
self.text = ""
|
||
else:
|
||
self.text = history[idx]
|
||
self.app._history_idx = idx
|
||
self.cursor_location = (0, len(self.text))
|
||
return
|
||
|
||
# 换行:Shift+回车 / Ctrl+J / Alt+回车
|
||
# (多数 Windows 终端不区分 Shift+Enter 与 Enter,故提供 Ctrl+J 作为可靠替代)
|
||
if event.key in ("shift+enter", "alt+enter", "ctrl+j"):
|
||
event.prevent_default()
|
||
event.stop()
|
||
self.insert("\n")
|
||
self.app.call_after_refresh(self.app._update_command_hint, self.text)
|
||
self.app._history_idx = -1
|
||
return
|
||
|
||
# 回车(无修饰键):发送消息
|
||
if event.key in ("enter", "\r", "\n"):
|
||
event.prevent_default()
|
||
try:
|
||
self.app.action_send_message()
|
||
except Exception:
|
||
pass
|
||
return
|
||
# 其他按键交由父类处理后,刷新命令提示
|
||
await super()._on_key(event)
|
||
self.app.call_after_refresh(self.app._update_command_hint, self.text)
|
||
# 任意编辑键重置历史浏览位置
|
||
self.app._history_idx = -1
|
||
|
||
class CommandHint(Widget, can_focus=False):
|
||
"""命令提示浮层 — 输入 / 时显示可用命令"""
|
||
|
||
BUILTIN_COMMANDS = [
|
||
("help", "帮助信息"),
|
||
("quit", "退出程序"),
|
||
("cli", "切换到 CLI 模式"),
|
||
("model", "切换 AI 引擎"),
|
||
("engine", "切换 AI 引擎"),
|
||
("engines", "列出可用引擎"),
|
||
("tools", "列出已加载工具"),
|
||
("status", "系统状态"),
|
||
("clear", "清屏"),
|
||
("safe", "切换安全模式"),
|
||
("diff", "切换文件修改展示模式"),
|
||
("chat", "对话历史管理 (save/list/open)"),
|
||
("settings","打开辅助中心"),
|
||
("profile", "引擎配置档案管理 (save/use/del)"),
|
||
("about", "关于"),
|
||
]
|
||
|
||
def __init__(self):
|
||
super().__init__(id="command-hint")
|
||
self._items = []
|
||
self._selected = 0
|
||
|
||
def update_hints(self, text: str):
|
||
"""根据输入文本更新提示列表"""
|
||
if not text.startswith('/'):
|
||
self._items = []
|
||
self.display = False
|
||
self.refresh()
|
||
return
|
||
|
||
query = text[1:].split(maxsplit=1)[0].lower() if len(text) > 1 else ""
|
||
|
||
items = []
|
||
# 内置命令
|
||
for cmd, desc in self.BUILTIN_COMMANDS:
|
||
if not query or cmd.startswith(query):
|
||
items.append((cmd, desc))
|
||
# 插件命令
|
||
cli = self.app.cli if hasattr(self.app, 'cli') else None
|
||
if cli and hasattr(cli, 'liugin_commands'):
|
||
for cmd_name in cli.liugin_commands:
|
||
if not query or cmd_name.startswith(query):
|
||
items.append((cmd_name, "插件命令"))
|
||
|
||
# 去重
|
||
seen = set()
|
||
unique = []
|
||
for cmd, desc in items:
|
||
if cmd not in seen:
|
||
seen.add(cmd)
|
||
unique.append((cmd, desc))
|
||
|
||
self._items = unique[:10]
|
||
self._selected = 0
|
||
self.display = len(self._items) > 0
|
||
self.refresh()
|
||
|
||
def move_selection(self, delta: int):
|
||
if not self._items:
|
||
return
|
||
self._selected = (self._selected + delta) % len(self._items)
|
||
self.refresh()
|
||
|
||
def get_selected_command(self) -> str:
|
||
if not self._items:
|
||
return ""
|
||
return "/" + self._items[self._selected][0]
|
||
|
||
def render(self) -> RenderableType:
|
||
if not self._items:
|
||
return ""
|
||
lines = []
|
||
for i, (cmd, desc) in enumerate(self._items):
|
||
marker = "▸ " if i == self._selected else " "
|
||
lines.append(f"{marker}[cmd-hint-cmd]/{cmd}[/] [cmd-hint-desc]{desc}[/]")
|
||
return "\n".join(lines)
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# 中文化命令面板 (CommandPalette)
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
class _CNCommandPalette(CommandPalette):
|
||
"""中文化命令面板 — 覆盖占位符、绑定描述与"未找到匹配项"文本"""
|
||
|
||
# 覆盖绑定描述为中文(Footer 与帮助面板会显示)
|
||
BINDINGS = [
|
||
Binding("ctrl+end, shift+end", "command_list('last')", "跳到底部", show=False),
|
||
Binding("ctrl+home, shift+home", "command_list('first')", "跳到顶部", show=False),
|
||
Binding("down", "cursor_down", "下一条命令", show=False),
|
||
Binding("escape", "escape", "退出命令面板"),
|
||
Binding("pagedown", "command_list('page_down')", "下一页", show=False),
|
||
Binding("pageup", "command_list('page_up')", "上一页", show=False),
|
||
Binding("up", "command_list('cursor_up')", "上一条命令", show=False),
|
||
]
|
||
|
||
def __init__(self, **kwargs) -> None:
|
||
kwargs.setdefault("placeholder", "搜索命令…")
|
||
super().__init__(**kwargs)
|
||
|
||
def _start_no_matches_countdown(self, search_value: str) -> None:
|
||
"""覆盖父类方法,将 "No matches found" 替换为中文"""
|
||
self._stop_no_matches_countdown()
|
||
|
||
def _show_no_matches() -> None:
|
||
if search_value:
|
||
command_list = self.query_one(CommandList)
|
||
command_list.add_option(
|
||
Option(
|
||
Align.center(Text("未找到匹配项", style="not bold")),
|
||
disabled=True,
|
||
id=self._NO_MATCHES,
|
||
)
|
||
)
|
||
self._list_visible = True
|
||
else:
|
||
self._list_visible = False
|
||
|
||
self._no_matches_timer = self.set_timer(
|
||
self._NO_MATCHES_COUNTDOWN,
|
||
_show_no_matches,
|
||
)
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# TUI 主应用
|
||
# ═══════════════════════════════════════════════════
|
||
|
||
class XiaoliTUI(App):
|
||
"""小狸 TUI — 基于 elia 架构"""
|
||
CSS = _TUI_CSS
|
||
TITLE = " 小狸 Pro-CLI"
|
||
SUB_TITLE = "智能编程助手"
|
||
|
||
BINDINGS = [
|
||
Binding("ctrl+c", "quit", "退出", show=True),
|
||
Binding("ctrl+l", "clear", "清屏", show=True),
|
||
Binding("ctrl+n", "new_chat", "新对话", show=True),
|
||
Binding("ctrl+enter", "send_message", "发送", show=False),
|
||
Binding("f1", "toggle_sidebar", "侧栏", show=True),
|
||
Binding("f2", "switch_engine", "引擎", show=True),
|
||
Binding("ctrl+p", "toggle_settings", "辅助中心", show=True),
|
||
Binding("f9", "toggle_settings", "辅助中心", show=False),
|
||
Binding("question_mark", "toggle_help", "帮助", show=True),
|
||
Binding("escape", "cancel", "取消", show=True),
|
||
]
|
||
|
||
sidebar_visible = var(False)
|
||
is_generating = var(False)
|
||
# 背景效果:0=墨黑 1=深蓝灰 2=深紫 3=深蓝
|
||
_BG_CLASSES = ["", "bg-grid", "bg-stars", "bg-glow"]
|
||
_BG_LABELS = ["墨黑", "深蓝灰", "深紫", "深蓝"]
|
||
|
||
def __init__(self, cli):
|
||
super().__init__()
|
||
self.cli = cli
|
||
self.bridge = _TUIBridge(cli)
|
||
self._thinking_widget = None
|
||
self._cancel_requested = False # 取消标志
|
||
self._current_worker = None # 当前 worker 引用
|
||
self._confirm_event = None # 安全审查确认 Event
|
||
self._confirm_result = False # 安全审查确认结果
|
||
self._bg_idx = 0 # 预设背景效果索引
|
||
self._bg_image_path = "" # 自定义背景图片路径
|
||
self._bg_custom_color = "" # 从图片提取的背景色(hex)
|
||
self._input_history = [] # 输入历史(最新在末尾)
|
||
self._history_idx = -1 # -1 表示当前未在浏览历史
|
||
|
||
# ── 命令面板中文化 ──
|
||
|
||
def action_command_palette(self) -> None:
|
||
"""覆盖默认命令面板,使用中文版"""
|
||
if self.use_command_palette and not CommandPalette.is_open(self):
|
||
self.push_screen(_CNCommandPalette(id="--command-palette"))
|
||
|
||
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
||
"""覆盖系统命令为中文"""
|
||
if not self.ansi_color:
|
||
yield SystemCommand(
|
||
"主题",
|
||
"切换当前主题",
|
||
self.action_change_theme,
|
||
)
|
||
yield SystemCommand(
|
||
"退出",
|
||
"退出应用程序",
|
||
self.action_quit,
|
||
)
|
||
if screen.query("HelpPanel"):
|
||
yield SystemCommand(
|
||
"快捷键",
|
||
"隐藏快捷键帮助面板",
|
||
self.action_hide_help_panel,
|
||
)
|
||
else:
|
||
yield SystemCommand(
|
||
"快捷键",
|
||
"显示快捷键与命令帮助",
|
||
self.action_show_help_panel,
|
||
)
|
||
if screen.maximized is not None:
|
||
yield SystemCommand(
|
||
"最小化",
|
||
"最小化当前控件并恢复原始尺寸",
|
||
screen.action_minimize,
|
||
)
|
||
elif screen.focused is not None and screen.focused.allow_maximize:
|
||
yield SystemCommand(
|
||
"最大化",
|
||
"最大化当前控件",
|
||
screen.action_maximize,
|
||
)
|
||
yield SystemCommand(
|
||
"截图",
|
||
"保存当前屏幕的 SVG 截图",
|
||
lambda: self.set_timer(0.1, self.deliver_screenshot),
|
||
)
|
||
|
||
def search_themes(self) -> None:
|
||
"""覆盖主题搜索面板,使用中文占位符"""
|
||
from textual.theme import ThemeProvider
|
||
self.push_screen(
|
||
_CNCommandPalette(
|
||
providers=[ThemeProvider],
|
||
placeholder="搜索主题…",
|
||
),
|
||
)
|
||
|
||
def action_show_help_panel(self) -> None:
|
||
"""打开帮助浮层(覆盖 Textual 默认侧边栏 HelpPanel)"""
|
||
self._show_help_panel()
|
||
|
||
def action_hide_help_panel(self) -> None:
|
||
"""关闭帮助浮层"""
|
||
self._hide_help_panel()
|
||
|
||
# ── 布局 ──
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Header(show_clock=True)
|
||
with Vertical(id="app-container"):
|
||
yield ResponseStatus(id="response-status")
|
||
yield VerticalScroll(id="chat-scroll")
|
||
yield Static("", id="confirm-overlay")
|
||
yield Static("", id="settings-backdrop")
|
||
yield Static("", id="settings-overlay")
|
||
yield Static("", id="help-backdrop")
|
||
yield Static("", id="help-overlay")
|
||
with Vertical(id="input-area"):
|
||
yield CommandHint()
|
||
with Container(id="input-wrapper"):
|
||
yield SendTextArea(
|
||
placeholder=" 输入消息... (回车发送, Ctrl+J 换行, / 命令提示)",
|
||
id="user-input",
|
||
soft_wrap=True,
|
||
tab_behavior="indent",
|
||
)
|
||
yield Static(
|
||
" 回车发送 Ctrl+J 换行 Esc 取消 F2 引擎 F1 侧栏 Ctrl+P 辅助中心 Ctrl+C 退出",
|
||
id="input-hint"
|
||
)
|
||
with Vertical(id="sidebar"):
|
||
yield Static(" 引擎", classes="sidebar-header")
|
||
yield Vertical(id="engine-list", classes="sidebar-section")
|
||
yield Rule(line_style="heavy")
|
||
yield Static(" 工具", classes="sidebar-header")
|
||
yield Vertical(id="tool-list", classes="sidebar-section")
|
||
yield Rule(line_style="heavy")
|
||
yield Static(" 状态", classes="sidebar-header")
|
||
yield Vertical(id="status-info", classes="sidebar-section")
|
||
yield Static(" 就绪", id="status-bar")
|
||
yield Footer()
|
||
|
||
def on_mount(self):
|
||
self._render_welcome()
|
||
self.query_one("#user-input").focus()
|
||
def _show_hint():
|
||
try:
|
||
self.query_one("#input-hint").add_class("visible")
|
||
except Exception:
|
||
pass
|
||
self.set_timer(0.5, _show_hint)
|
||
# 应用持久化的背景效果
|
||
try:
|
||
from .config import get_system_config
|
||
saved = get_system_config('tui_background', 0)
|
||
self._bg_idx = saved if 0 <= saved < len(self._BG_CLASSES) else 0
|
||
# 自定义图片背景
|
||
self._bg_image_path = get_system_config('tui_bg_image', '') or ''
|
||
if self._bg_image_path:
|
||
self._extract_color_from_image()
|
||
except Exception:
|
||
self._bg_idx = 0
|
||
self._apply_background()
|
||
self._update_status("就绪")
|
||
|
||
def on_key(self, event):
|
||
"""全局按键处理:辅助中心 / 帮助浮层 / 安全审查确认浮层可见时拦截按键"""
|
||
try:
|
||
# 辅助中心可见时,拦截所有按键
|
||
settings = self.query_one("#settings-overlay")
|
||
if settings.display and settings.has_class("visible"):
|
||
key = event.key.lower() if hasattr(event, 'key') else ""
|
||
if key == "escape":
|
||
event.prevent_default()
|
||
event.stop()
|
||
self._hide_settings()
|
||
elif key in ("1", "2", "3", "4", "5", "6"):
|
||
event.prevent_default()
|
||
event.stop()
|
||
self._cycle_setting(int(key))
|
||
else:
|
||
# 其他按键也拦截,避免触发输入框等
|
||
event.prevent_default()
|
||
event.stop()
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
# 帮助浮层可见时,仅 Esc 关闭,其余按键拦截
|
||
help_overlay = self.query_one("#help-overlay")
|
||
if help_overlay.display and help_overlay.has_class("visible"):
|
||
key = event.key.lower() if hasattr(event, 'key') else ""
|
||
if key == "escape":
|
||
event.prevent_default()
|
||
event.stop()
|
||
self._hide_help_panel()
|
||
else:
|
||
event.prevent_default()
|
||
event.stop()
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
overlay = self.query_one("#confirm-overlay")
|
||
if overlay.display and self._confirm_event:
|
||
key = event.key.lower() if hasattr(event, 'key') else ""
|
||
if key == 'y':
|
||
event.prevent_default()
|
||
event.stop()
|
||
self._confirm_result = True
|
||
self._confirm_event.set()
|
||
self._hide_confirm()
|
||
elif key in ('n', 'escape'):
|
||
event.prevent_default()
|
||
event.stop()
|
||
self._confirm_result = False
|
||
self._confirm_event.set()
|
||
self._hide_confirm()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 欢迎界面 ──
|
||
|
||
def _render_welcome(self):
|
||
scroll = self.query_one("#chat-scroll")
|
||
engine = self.bridge.current_engine()
|
||
tools = len(self.bridge.tools())
|
||
lines = [
|
||
("", "msg-dim"),
|
||
(" -----------------------------------------------", "msg-welcome"),
|
||
(" 小狸 Pro-CLI v6.1.0", "msg-welcome"),
|
||
(" 智能编程助手 · TUI 模式", "msg-welcome"),
|
||
(" -----------------------------------------------", "msg-welcome"),
|
||
("", "msg-dim"),
|
||
(f" 引擎: {engine} | 工具: {tools} 个", "msg-system"),
|
||
("", "msg-dim"),
|
||
(" /help 查看命令 /model 切换引擎 /clear 清屏", "msg-dim"),
|
||
("", "msg-dim"),
|
||
]
|
||
for text, cls in lines:
|
||
scroll.mount(Static(text, classes=cls))
|
||
scroll.scroll_end(animate=False)
|
||
|
||
# ── 消息操作 ──
|
||
|
||
def _append(self, widget):
|
||
scroll = self.query_one("#chat-scroll")
|
||
scroll.mount(widget)
|
||
scroll.scroll_end(animate=False)
|
||
|
||
def _smart_scroll_end(self):
|
||
"""线程安全的智能滚动 — 在主线程中执行"""
|
||
try:
|
||
scroll = self.query_one("#chat-scroll")
|
||
if scroll.scroll_y >= scroll.max_scroll_y - 5:
|
||
scroll.scroll_end(animate=False)
|
||
except Exception:
|
||
pass
|
||
|
||
def _mount_first_chunk(self, widget):
|
||
"""挂载首个流式 chunk 的 widget(主线程中执行,保证原子性)"""
|
||
self._remove_thinking()
|
||
self._append(widget)
|
||
try:
|
||
self.query_one("#response-status").set_agent_responding()
|
||
except Exception:
|
||
pass
|
||
self._update_status(" 输出中...")
|
||
|
||
def _mount_thinking_widget(self, widget):
|
||
"""挂载 ThinkingBox — 在聊天区追加(主线程中执行)"""
|
||
try:
|
||
scroll = self.query_one("#chat-scroll")
|
||
scroll.mount(widget)
|
||
scroll.scroll_end(animate=False)
|
||
except Exception:
|
||
pass
|
||
|
||
def _remove_thinking_widget(self, widget):
|
||
"""移除空的 ThinkingBox"""
|
||
try:
|
||
if widget and widget.is_mounted:
|
||
widget.remove()
|
||
except Exception:
|
||
pass
|
||
|
||
def _add(self, text, cls="msg-dim"):
|
||
self._append(Static(text, classes=cls))
|
||
|
||
def _user_msg(self, text):
|
||
"""用户消息 — 使用 Chatbox (elia 模式),CSS + on_mount 自动处理滑入动画"""
|
||
widget = Chatbox(role="user", content=text)
|
||
self._append(widget)
|
||
|
||
def _ai_msg(self, text):
|
||
"""AI 消息 — 使用 Chatbox 渲染 Markdown"""
|
||
self._append(Chatbox(role="assistant", content=text))
|
||
|
||
def _system(self, text):
|
||
self._add(f" {text}", "msg-system")
|
||
|
||
def _error(self, text):
|
||
self._add(f" [错误] {text}", "msg-error")
|
||
|
||
# ── 思考指示器 (elia ResponseStatus 模式) ──
|
||
|
||
def _show_thinking(self):
|
||
"""显示思考状态 — 仅右上角 ResponseStatus 浮层
|
||
|
||
不再往 chat-scroll 里 mount LoadingIndicator,
|
||
因为无 CSS 约束的 LoadingIndicator 会撑满整个聊天区(视觉上全屏)。
|
||
右上角 ResponseStatus(2 行高小浮层)+ 状态栏文字已足够。
|
||
"""
|
||
if self._thinking_widget:
|
||
try:
|
||
self._thinking_widget.remove()
|
||
except Exception:
|
||
pass
|
||
self._thinking_widget = None
|
||
try:
|
||
rs = self.query_one("#response-status")
|
||
rs.set_awaiting_response()
|
||
except Exception:
|
||
pass
|
||
|
||
def _remove_thinking(self):
|
||
"""移除思考状态"""
|
||
try:
|
||
self.query_one("#response-status").hide()
|
||
except Exception:
|
||
pass
|
||
if self._thinking_widget:
|
||
try:
|
||
self._thinking_widget.remove()
|
||
except Exception:
|
||
pass
|
||
self._thinking_widget = None
|
||
|
||
def _show_typing_indicator(self):
|
||
"""兼容旧接口"""
|
||
pass
|
||
|
||
def _remove_typing_indicator(self):
|
||
"""兼容旧接口"""
|
||
pass
|
||
|
||
# ── 侧栏 ──
|
||
|
||
def _update_sidebar(self):
|
||
engine_container = self.query_one("#engine-list")
|
||
engine_container.remove_children()
|
||
current = self.bridge.current_engine()
|
||
for name in self.bridge.engines():
|
||
if name == current:
|
||
engine_container.mount(Static(f" > {name}", classes="engine-item-active"))
|
||
else:
|
||
engine_container.mount(Static(f" {name}", classes="engine-item"))
|
||
|
||
tool_container = self.query_one("#tool-list")
|
||
tool_container.remove_children()
|
||
for tool in self.bridge.tools():
|
||
name = tool.get('name', '?')
|
||
tool_container.mount(Static(f" - {name}", classes="tool-item"))
|
||
|
||
status_container = self.query_one("#status-info")
|
||
status_container.remove_children()
|
||
status_container.mount(Static(f" 对话: {len(self.bridge.history())} 条", classes="tool-item"))
|
||
status_container.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
|
||
status_container.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
|
||
|
||
def _update_status(self, text: str):
|
||
try:
|
||
self.query_one("#status-bar").update(f" {text}")
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 输入动作 ──
|
||
|
||
def _update_command_hint(self, text: str):
|
||
"""更新命令提示浮层(输入 / 时触发)"""
|
||
try:
|
||
hint = self.query_one("#command-hint")
|
||
hint.update_hints(text)
|
||
except Exception:
|
||
pass
|
||
|
||
def _hide_command_hint(self):
|
||
"""隐藏命令提示浮层"""
|
||
try:
|
||
hint = self.query_one("#command-hint")
|
||
hint.display = False
|
||
hint._items = []
|
||
hint.refresh()
|
||
except Exception:
|
||
pass
|
||
|
||
def _show_confirm(self, risk_info: str):
|
||
"""显示工具调用安全审查确认浮层"""
|
||
try:
|
||
overlay = self.query_one("#confirm-overlay")
|
||
overlay.update(risk_info)
|
||
overlay.add_class("visible")
|
||
overlay.display = True
|
||
except Exception:
|
||
pass
|
||
|
||
def _hide_confirm(self):
|
||
"""隐藏确认浮层"""
|
||
try:
|
||
overlay = self.query_one("#confirm-overlay")
|
||
overlay.remove_class("visible")
|
||
overlay.display = False
|
||
overlay.update("")
|
||
except Exception:
|
||
pass
|
||
|
||
def action_send_message(self):
|
||
"""回车发送消息"""
|
||
if self.is_generating:
|
||
return
|
||
|
||
text_area = self.query_one("#user-input")
|
||
text = text_area.text.strip()
|
||
if not text:
|
||
return
|
||
|
||
# 记录到输入历史(去重:与最后一条相同则不记)
|
||
if not self._input_history or self._input_history[-1] != text:
|
||
self._input_history.append(text)
|
||
if len(self._input_history) > 100:
|
||
self._input_history.pop(0)
|
||
self._history_idx = -1
|
||
|
||
text_area.clear()
|
||
self._hide_command_hint()
|
||
|
||
if text.startswith('/'):
|
||
self._handle_command(text)
|
||
return
|
||
|
||
self._user_msg(text)
|
||
self.is_generating = True
|
||
self._cancel_requested = False # 重置取消标志
|
||
self._cancel_generation = getattr(self, '_cancel_generation', 0) + 1 # 新 worker 代次
|
||
self._current_worker = self.run_worker(partial(self._generate, text), thread=True, exclusive=True)
|
||
|
||
# ── 命令处理 ──
|
||
|
||
def _handle_command(self, cmd):
|
||
parts = cmd[1:].split(maxsplit=1)
|
||
name = parts[0].lower()
|
||
args = parts[1] if len(parts) > 1 else ""
|
||
|
||
cmds = {
|
||
'help': lambda: self._show_help_panel(),
|
||
'quit': lambda: self.exit(),
|
||
'q': lambda: self.exit(),
|
||
'exit': lambda: self.exit(),
|
||
'cli': lambda: self._switch_cli(),
|
||
'clear': lambda: self.action_clear(),
|
||
'cls': lambda: self.action_clear(),
|
||
'model': lambda: self._switch_model(args),
|
||
'engine': lambda: self._switch_model(args),
|
||
'about': lambda: self._system(" 小狸 Pro-CLI v6.1.0 - 智能编程助手"),
|
||
'status': lambda: self._show_status(),
|
||
'tools': lambda: self._show_tools(),
|
||
'engines': lambda: self._show_engines(),
|
||
'tui': lambda: self._system("已在 TUI 模式中"),
|
||
'manual': lambda: self._handle_manual(args),
|
||
'safe': lambda: self._handle_safe(args),
|
||
'diff': lambda: self._handle_diff_mode(args),
|
||
'chat': lambda: self._handle_chat(args),
|
||
'bg': lambda: self._handle_background(args),
|
||
'settings': lambda: self._show_settings(),
|
||
'profile': lambda: self._handle_profile(args),
|
||
}
|
||
|
||
handler = cmds.get(name)
|
||
if handler:
|
||
handler()
|
||
else:
|
||
if name in self.cli.liugin_commands:
|
||
try:
|
||
result = self.cli.liugin_commands[name](args)
|
||
if result:
|
||
self._system(result[:500])
|
||
except Exception as e:
|
||
self._error(f"插件命令失败: {e}")
|
||
else:
|
||
self._error(f"未知命令: /{name},输入 /help 查看帮助")
|
||
|
||
def _handle_safe(self, args):
|
||
"""切换安全模式:off=无限制(跳过审查) / on=普通 / manual=人工确认"""
|
||
from xcli_core.safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
|
||
safety = get_safety()
|
||
arg = args.strip().lower() if args else ""
|
||
if arg in ('off', 'unrestricted', '无限制'):
|
||
safety.set_mode(MODE_UNRESTRICTED)
|
||
self._system(" 安全模式: 无限制 (跳过工具审查)")
|
||
elif arg in ('on', 'normal', '普通'):
|
||
safety.set_mode(MODE_NORMAL)
|
||
self._system(" 安全模式: 普通 (AI 识别风险)")
|
||
elif arg in ('manual', '人工', 'all'):
|
||
safety.set_mode(MODE_MANUAL)
|
||
self._system(" 安全模式: 人工确认 (所有工具调用需确认)")
|
||
else:
|
||
mode_name = safety.cycle_mode()
|
||
self._system(f" 安全模式: {mode_name}")
|
||
|
||
def _handle_diff_mode(self, args):
|
||
"""切换文件修改展示模式:popup=弹窗 / inline=终端内 / off=关闭"""
|
||
arg = args.strip().lower() if args else ""
|
||
# 找到 code_editor 插件
|
||
editor = None
|
||
if hasattr(self.cli, 'liugin_manager'):
|
||
for tool in self.cli.liugin_manager.tools:
|
||
if tool.get('name') == 'code_editor':
|
||
editor = tool.get('instance')
|
||
break
|
||
if not editor:
|
||
self._error("未找到代码编辑器插件")
|
||
return
|
||
|
||
if arg in ('popup', '弹窗'):
|
||
editor.diff_popup_enabled = True
|
||
editor.diff_popup_mode = True
|
||
self._system(" 文件修改展示: 弹窗模式(新终端窗口)")
|
||
elif arg in ('inline', '内联', '终端', 'ssh'):
|
||
editor.diff_popup_enabled = True
|
||
editor.diff_popup_mode = False
|
||
self._system(" 文件修改展示: 终端内显示(SSH 兼容)")
|
||
elif arg in ('off', '关', '关闭'):
|
||
editor.diff_popup_enabled = False
|
||
self._system(" 文件修改展示: 已关闭")
|
||
else:
|
||
# 无参数:循环切换
|
||
if not editor.diff_popup_enabled:
|
||
editor.diff_popup_enabled = True
|
||
editor.diff_popup_mode = False
|
||
self._system(" 文件修改展示: 终端内显示")
|
||
elif editor.diff_popup_mode is False:
|
||
editor.diff_popup_mode = True
|
||
self._system(" 文件修改展示: 弹窗模式")
|
||
elif editor.diff_popup_mode is True:
|
||
editor.diff_popup_enabled = False
|
||
self._system(" 文件修改展示: 已关闭")
|
||
else:
|
||
editor.diff_popup_mode = False
|
||
self._system(" 文件修改展示: 终端内显示")
|
||
|
||
def _handle_chat(self, args):
|
||
"""对话历史管理: save <名称> / list / open <名称|序号>"""
|
||
if not getattr(self.cli, 'memory_manager', None):
|
||
self._error("记忆系统未初始化")
|
||
return
|
||
|
||
parts = args.strip().split(maxsplit=1)
|
||
sub = parts[0].lower() if parts else ""
|
||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||
|
||
if sub == 'save':
|
||
if not arg:
|
||
self._error("请提供名称。用法: /chat save <名称>")
|
||
return
|
||
self.cli.memory_manager.auto_save_chat(
|
||
self.cli.shared_conversation_history,
|
||
label=arg
|
||
)
|
||
self._system(f" 聊天记录已保存: {arg}")
|
||
|
||
elif sub == 'list':
|
||
chats = self.cli.memory_manager.list_chats(limit=30)
|
||
if not chats:
|
||
self._system(" 没有已保存的聊天记录")
|
||
return
|
||
lines = [" 已保存的聊天记录:"]
|
||
for i, c in enumerate(chats, 1):
|
||
lines.append(f" {i}. {c['name']} ({c['time']}, {c['size_kb']}KB)")
|
||
self._system("\n".join(lines))
|
||
|
||
elif sub == 'open':
|
||
if not arg:
|
||
self._error("请提供名称或序号。用法: /chat open <名称|序号>")
|
||
return
|
||
name = arg
|
||
# 支持序号加载
|
||
if name.isdigit():
|
||
chats = self.cli.memory_manager.list_chats(limit=50)
|
||
idx = int(name) - 1
|
||
if 0 <= idx < len(chats):
|
||
name = chats[idx]['name'].replace('.json', '')
|
||
else:
|
||
self._error(f"序号 {arg} 超出范围")
|
||
return
|
||
conversation = self.cli.memory_manager.load_chat(name)
|
||
if conversation is None and not name.endswith('.json'):
|
||
conversation = self.cli.memory_manager.load_chat(name + '.json')
|
||
if conversation is None:
|
||
self._error(f"聊天记录 '{name}' 不存在")
|
||
return
|
||
|
||
# 替换历史并同步给引擎
|
||
self.cli.shared_conversation_history = conversation
|
||
self.cli._set_shared_conversation_history()
|
||
|
||
# 重绘聊天区
|
||
scroll = self.query_one("#chat-scroll")
|
||
scroll.remove_children()
|
||
for msg in conversation:
|
||
role = msg.get('role', '')
|
||
content = msg.get('content', '')
|
||
if not content:
|
||
continue
|
||
if role == 'user':
|
||
self._user_msg(content)
|
||
elif role == 'assistant':
|
||
self._ai_msg(content)
|
||
# system/tool 消息跳过,避免噪音
|
||
self._system(f" 已加载聊天记录: {name}({len(conversation)} 条)")
|
||
|
||
else:
|
||
self._error("用法: /chat save <名称> | /chat list | /chat open <名称|序号>")
|
||
|
||
# ── 辅助中心浮层 / 引擎配置档案 ──
|
||
|
||
def _mask_key(self, key):
|
||
"""脱敏 API Key 用于显示"""
|
||
if not key:
|
||
return "(空)"
|
||
if len(key) <= 8:
|
||
return key[:2] + "***"
|
||
return key[:4] + "***" + key[-4:]
|
||
|
||
def _get_setting_state(self):
|
||
"""收集辅助中心所有选项的状态,返回 dict 供显示和切换使用"""
|
||
from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
|
||
from .notification import get_notification_manager
|
||
from .config import get_system_config
|
||
|
||
# 安全模式
|
||
safety = get_safety()
|
||
safety_modes = [(MODE_UNRESTRICTED, "无限制"), (MODE_NORMAL, "普通"), (MODE_MANUAL, "人工确认")]
|
||
safety_idx = safety.mode if 0 <= safety.mode <= 2 else 1
|
||
safety_label = safety_modes[safety_idx][1]
|
||
|
||
# 通知开关
|
||
nm = get_notification_manager()
|
||
notify_label = "开启" if nm.enabled else "关闭"
|
||
|
||
# diff 模式
|
||
diff_editor = None
|
||
if hasattr(self.cli, 'liugin_manager'):
|
||
for tool in self.cli.liugin_manager.tools:
|
||
if tool.get('name') == 'code_editor':
|
||
diff_editor = tool.get('instance')
|
||
break
|
||
diff_modes = [("off", "关闭"), ("inline", "终端内"), ("popup", "弹窗")]
|
||
diff_idx = 0
|
||
if diff_editor:
|
||
if not diff_editor.diff_popup_enabled:
|
||
diff_idx = 0
|
||
elif diff_editor.diff_popup_mode is False:
|
||
diff_idx = 1
|
||
else:
|
||
diff_idx = 2
|
||
diff_label = diff_modes[diff_idx][1]
|
||
diff_available = diff_editor is not None
|
||
|
||
# max_history(从 config 读取真实值,未配置则用默认 50)
|
||
max_history_val = get_system_config('max_history', 50)
|
||
# 历史档位
|
||
history_options = [20, 50, 100, 999999]
|
||
try:
|
||
history_idx = history_options.index(max_history_val) if max_history_val in history_options else 1
|
||
except (ValueError, TypeError):
|
||
history_idx = 1
|
||
history_label = "无限制" if max_history_val >= 999999 else str(max_history_val)
|
||
|
||
return {
|
||
'safety': {'idx': safety_idx, 'label': safety_label, 'options': safety_modes, 'available': True},
|
||
'notify': {'idx': 0 if nm.enabled else 1, 'label': notify_label,
|
||
'options': [(True, "开启"), (False, "关闭")], 'available': True},
|
||
'diff': {'idx': diff_idx, 'label': diff_label, 'options': diff_modes, 'available': diff_available},
|
||
'history': {'idx': history_idx, 'label': history_label,
|
||
'options': [(v, "无限制" if v >= 999999 else str(v)) for v in history_options],
|
||
'available': True, 'values': history_options},
|
||
'background': {'idx': self._bg_idx, 'label': self._BG_LABELS[self._bg_idx],
|
||
'options': list(enumerate(self._BG_LABELS)), 'available': True},
|
||
'bg_custom': {
|
||
'active': bool(self._bg_custom_color),
|
||
'label': (os.path.basename(self._bg_image_path) if self._bg_image_path else "无"),
|
||
'color': self._bg_custom_color,
|
||
},
|
||
}
|
||
|
||
def _build_settings_text(self):
|
||
"""构建辅助中心浮层的显示内容 — 全部功能 + 基础设置"""
|
||
from rich.text import Text
|
||
from .config import get_profiles, get_system_config
|
||
|
||
engine = self.bridge.current_engine()
|
||
engine_name = getattr(engine, 'name', 'unknown')
|
||
base_url = getattr(engine, 'base_url', '')
|
||
api_key = getattr(engine, 'api_key', '')
|
||
model = getattr(engine, 'model', '')
|
||
|
||
state = self._get_setting_state()
|
||
|
||
t = Text()
|
||
t.append(Text("⚙ 辅助中心\n", style=f"bold {_Theme.ACCENT}", justify="center"))
|
||
t.append(Text("─" * 56 + "\n", style="dim"))
|
||
|
||
# ── 基础设置 ──
|
||
t.append(Text("【基础设置】\n", style=f"bold {_Theme.TEXT_MUTED}"))
|
||
|
||
# 1. 安全模式
|
||
s = state['safety']
|
||
t.append(Text(" ", style=""))
|
||
t.append(Text("[1]", style=f"bold {_Theme.WARNING}"))
|
||
t.append(Text(" 安全模式 ", style=_Theme.TEXT))
|
||
t.append(Text(s['label'], style=f"bold {_Theme.SUCCESS}"))
|
||
if s['available']:
|
||
t.append(Text(" (无限制/普通/人工)", style="dim"))
|
||
t.append(Text("\n", style=""))
|
||
|
||
# 2. 通知开关
|
||
n = state['notify']
|
||
t.append(Text(" ", style=""))
|
||
t.append(Text("[2]", style=f"bold {_Theme.WARNING}"))
|
||
t.append(Text(" 任务完成通知 ", style=_Theme.TEXT))
|
||
t.append(Text(n['label'], style=f"bold {_Theme.SUCCESS}"))
|
||
t.append(Text(" (开启/关闭)", style="dim"))
|
||
t.append(Text("\n", style=""))
|
||
|
||
# 3. 对话历史条数
|
||
h = state['history']
|
||
t.append(Text(" ", style=""))
|
||
t.append(Text("[3]", style=f"bold {_Theme.WARNING}"))
|
||
t.append(Text(" 对话历史条数 ", style=_Theme.TEXT))
|
||
t.append(Text(h['label'], style=f"bold {_Theme.SUCCESS}"))
|
||
t.append(Text(" (20/50/100/无限)", style="dim"))
|
||
t.append(Text("\n", style=""))
|
||
|
||
# 4. diff 展示
|
||
d = state['diff']
|
||
t.append(Text(" ", style=""))
|
||
t.append(Text("[4]", style=f"bold {_Theme.WARNING}"))
|
||
t.append(Text(" 文件修改展示 ", style=_Theme.TEXT))
|
||
if d['available']:
|
||
t.append(Text(d['label'], style=f"bold {_Theme.SUCCESS}"))
|
||
t.append(Text(" (关闭/终端内/弹窗)", style="dim"))
|
||
else:
|
||
t.append(Text("(不可用)", style="dim"))
|
||
t.append(Text("\n", style=""))
|
||
|
||
# 5. 预设背景
|
||
bg = state['background']
|
||
t.append(Text(" ", style=""))
|
||
t.append(Text("[5]", style=f"bold {_Theme.WARNING}"))
|
||
t.append(Text(" 预设背景 ", style=_Theme.TEXT))
|
||
t.append(Text(bg['label'], style=f"bold {_Theme.SUCCESS}"))
|
||
t.append(Text(" (墨黑/深蓝灰/深紫/深蓝)", style="dim"))
|
||
t.append(Text("\n", style=""))
|
||
|
||
# 6. 自定义背景
|
||
bc = state['bg_custom']
|
||
t.append(Text(" ", style=""))
|
||
t.append(Text("[6]", style=f"bold {_Theme.WARNING}"))
|
||
t.append(Text(" 自定义背景 ", style=_Theme.TEXT))
|
||
if bc['active']:
|
||
t.append(Text(bc['label'], style=f"bold {_Theme.SUCCESS}"))
|
||
t.append(Text(f" ({bc['color']})", style="dim"))
|
||
else:
|
||
t.append(Text("无", style="dim"))
|
||
t.append(Text(" 用 /bg 命令设置", style="dim"))
|
||
t.append(Text("\n", style=""))
|
||
|
||
t.append(Text("\n【引擎配置】\n", style=f"bold {_Theme.TEXT_MUTED}"))
|
||
t.append(Text(f" 当前引擎: ", style="dim"))
|
||
t.append(Text(f"{engine_name}\n", style=f"bold {_Theme.ACCENT}"))
|
||
t.append(Text(f" 地址: ", style="dim"))
|
||
t.append(Text(f"{base_url or '(未设置)'}\n", style=_Theme.TEXT))
|
||
t.append(Text(f" 模型: ", style="dim"))
|
||
t.append(Text(f"{model or '(未设置)'}\n", style=_Theme.TEXT))
|
||
t.append(Text(f" 密钥: ", style="dim"))
|
||
t.append(Text(f"{self._mask_key(api_key)}\n", style=_Theme.TEXT))
|
||
|
||
# 已存档案
|
||
profiles = get_profiles(engine_name)
|
||
if profiles:
|
||
t.append(Text("\n 配置档案 (用 /profile use <名称|序号> 切换):\n", style=f"bold {_Theme.TEXT_MUTED}"))
|
||
for i, p in enumerate(profiles, 1):
|
||
mark = " ←" if (p.get('base_url') == base_url and p.get('model') == model) else ""
|
||
t.append(Text(f" {i}. {p.get('name', '未命名')}{mark}", style=_Theme.TEXT))
|
||
t.append(Text(f" ({p.get('model', '')})\n", style="dim"))
|
||
|
||
# 操作提示
|
||
t.append(Text("\n" + "─" * 56 + "\n", style="dim"))
|
||
t.append(Text(" 数字键 ", style="dim"))
|
||
t.append(Text("1-6", style=f"bold {_Theme.WARNING}"))
|
||
t.append(Text(" 切换选项 | ", style="dim"))
|
||
t.append(Text("Esc", style=f"bold {_Theme.WARNING}"))
|
||
t.append(Text(" 关闭\n", style="dim"))
|
||
return t
|
||
|
||
def action_toggle_settings(self):
|
||
"""Ctrl+P: 切换辅助中心显示/隐藏"""
|
||
overlay = self.query_one("#settings-overlay")
|
||
if overlay.has_class("visible"):
|
||
self._hide_settings()
|
||
else:
|
||
self._show_settings()
|
||
|
||
def _show_settings(self):
|
||
"""显示辅助中心:先显示背景遮罩,再滑入卡片"""
|
||
backdrop = self.query_one("#settings-backdrop")
|
||
overlay = self.query_one("#settings-overlay")
|
||
overlay.update(self._build_settings_text())
|
||
backdrop.styles.display = "block"
|
||
overlay.styles.display = "block"
|
||
backdrop.remove_class("hiding")
|
||
overlay.remove_class("hiding")
|
||
|
||
def _show():
|
||
backdrop.add_class("visible")
|
||
overlay.add_class("visible")
|
||
|
||
self.call_after_refresh(_show)
|
||
|
||
def _hide_settings(self):
|
||
"""隐藏辅助中心:先滑出卡片,再淡出遮罩"""
|
||
backdrop = self.query_one("#settings-backdrop")
|
||
overlay = self.query_one("#settings-overlay")
|
||
overlay.remove_class("visible")
|
||
overlay.add_class("hiding")
|
||
backdrop.remove_class("visible")
|
||
backdrop.add_class("hiding")
|
||
|
||
def _finish_hide():
|
||
overlay.remove_class("hiding")
|
||
overlay.styles.display = "none"
|
||
backdrop.remove_class("hiding")
|
||
backdrop.styles.display = "none"
|
||
|
||
self.set_timer(0.26, _finish_hide)
|
||
|
||
# ── 帮助浮层 ──
|
||
|
||
def action_toggle_help(self):
|
||
"""?: 切换帮助浮层显示/隐藏"""
|
||
overlay = self.query_one("#help-overlay")
|
||
if overlay.has_class("visible"):
|
||
self._hide_help_panel()
|
||
else:
|
||
self._show_help_panel()
|
||
|
||
def _show_help_panel(self):
|
||
"""显示帮助浮层:先显示背景遮罩,再滑入卡片"""
|
||
backdrop = self.query_one("#help-backdrop")
|
||
overlay = self.query_one("#help-overlay")
|
||
overlay.update(self._build_help_text())
|
||
backdrop.styles.display = "block"
|
||
overlay.styles.display = "block"
|
||
backdrop.remove_class("hiding")
|
||
overlay.remove_class("hiding")
|
||
|
||
def _show():
|
||
backdrop.add_class("visible")
|
||
overlay.add_class("visible")
|
||
|
||
self.call_after_refresh(_show)
|
||
|
||
def _hide_help_panel(self):
|
||
"""隐藏帮助浮层:先滑出卡片,再淡出遮罩"""
|
||
backdrop = self.query_one("#help-backdrop")
|
||
overlay = self.query_one("#help-overlay")
|
||
overlay.remove_class("visible")
|
||
overlay.add_class("hiding")
|
||
backdrop.remove_class("visible")
|
||
backdrop.add_class("hiding")
|
||
|
||
def _finish_hide():
|
||
overlay.remove_class("hiding")
|
||
overlay.styles.display = "none"
|
||
backdrop.remove_class("hiding")
|
||
backdrop.styles.display = "none"
|
||
|
||
self.set_timer(0.26, _finish_hide)
|
||
|
||
def _build_help_text(self):
|
||
"""构建帮助浮层内容 — 全中文,含命令、快捷键、操作说明"""
|
||
from rich.text import Text
|
||
t = Text()
|
||
t.append(Text("📖 帮助\n", style=f"bold {_Theme.ACCENT}", justify="center"))
|
||
t.append(Text("─" * 56 + "\n", style="dim"))
|
||
|
||
# ── 快捷键 ──
|
||
t.append(Text("【快捷键】\n", style=f"bold {_Theme.TEXT_MUTED}"))
|
||
t.append(Text(" Ctrl+C 退出 Ctrl+L 清屏\n", style=_Theme.TEXT))
|
||
t.append(Text(" Ctrl+N 新对话 Ctrl+P 辅助中心\n", style=_Theme.TEXT))
|
||
t.append(Text(" 回车 发送消息 Ctrl+J 换行\n", style=_Theme.TEXT))
|
||
t.append(Text(" F1 侧栏 F2 切换引擎\n", style=_Theme.TEXT))
|
||
t.append(Text(" F9 辅助中心 Esc 取消/关闭浮层\n", style=_Theme.TEXT))
|
||
t.append(Text(" ? 帮助 Tab 缩进\n", style=_Theme.TEXT))
|
||
|
||
# ── 命令 ──
|
||
t.append(Text("\n【命令】(在输入框输入 / 触发)\n", style=f"bold {_Theme.TEXT_MUTED}"))
|
||
t.append(Text(" /help 帮助信息\n", style=_Theme.TEXT))
|
||
t.append(Text(" /quit 退出\n", style=_Theme.TEXT))
|
||
t.append(Text(" /cli 切换命令行模式\n", style=_Theme.TEXT))
|
||
t.append(Text(" /model <引擎> 切换 AI 引擎\n", style=_Theme.TEXT))
|
||
t.append(Text(" /engines 列出引擎\n", style=_Theme.TEXT))
|
||
t.append(Text(" /tools 列出工具\n", style=_Theme.TEXT))
|
||
t.append(Text(" /status 系统状态\n", style=_Theme.TEXT))
|
||
t.append(Text(" /clear 清屏\n", style=_Theme.TEXT))
|
||
|
||
# ── 安全与展示 ──
|
||
t.append(Text("\n【安全与展示】\n", style=f"bold {_Theme.TEXT_MUTED}"))
|
||
t.append(Text(" /safe [off|on|manual] 切换安全模式\n", style=_Theme.TEXT))
|
||
t.append(Text(" /diff [popup|inline|off] 切换文件修改展示模式\n", style=_Theme.TEXT))
|
||
t.append(Text(" /bg <图片路径|off|list> 自定义背景(提取主色调)\n", style=_Theme.TEXT))
|
||
|
||
# ── 对话历史 ──
|
||
t.append(Text("\n【对话历史】\n", style=f"bold {_Theme.TEXT_MUTED}"))
|
||
t.append(Text(" /chat save <名称> 保存当前对话\n", style=_Theme.TEXT))
|
||
t.append(Text(" /chat list 列出已保存对话\n", style=_Theme.TEXT))
|
||
t.append(Text(" /chat open <名称|序号> 加载历史对话\n", style=_Theme.TEXT))
|
||
|
||
# ── 引擎配置 ──
|
||
t.append(Text("\n【引擎配置】\n", style=f"bold {_Theme.TEXT_MUTED}"))
|
||
t.append(Text(" /settings 打开辅助中心\n", style=_Theme.TEXT))
|
||
t.append(Text(" /profile save <名称> 保存当前 地址/密钥/模型 为档案\n", style=_Theme.TEXT))
|
||
t.append(Text(" /profile use <名称|序号> 切换到指定配置档案\n", style=_Theme.TEXT))
|
||
t.append(Text(" /profile del <名称> 删除配置档案\n", style=_Theme.TEXT))
|
||
|
||
# ── 操作提示 ──
|
||
t.append(Text("\n" + "─" * 56 + "\n", style="dim"))
|
||
t.append(Text(" 按 ", style="dim"))
|
||
t.append(Text("Esc", style=f"bold {_Theme.WARNING}"))
|
||
t.append(Text(" 关闭\n", style="dim"))
|
||
return t
|
||
|
||
def _cycle_setting(self, idx):
|
||
"""数字键切换辅助中心选项"""
|
||
from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
|
||
from .notification import get_notification_manager
|
||
from .config import set_system_config
|
||
|
||
state = self._get_setting_state()
|
||
|
||
if idx == 1:
|
||
# 安全模式循环
|
||
safety = get_safety()
|
||
new_idx = (state['safety']['idx'] + 1) % 3
|
||
mode_map = [MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL]
|
||
safety.set_mode(mode_map[new_idx])
|
||
label = state['safety']['options'][new_idx][1]
|
||
self._update_status(f"安全模式: {label}")
|
||
elif idx == 2:
|
||
# 通知开关循环
|
||
nm = get_notification_manager()
|
||
nm.enabled = not nm.enabled
|
||
self._update_status(f"通知: {'开启' if nm.enabled else '关闭'}")
|
||
elif idx == 3:
|
||
# 对话历史条数循环
|
||
new_idx = (state['history']['idx'] + 1) % 4
|
||
new_val = state['history']['values'][new_idx]
|
||
set_system_config('max_history', new_val)
|
||
# 同步到 cli 实例和所有引擎
|
||
self.cli.max_history = new_val
|
||
if hasattr(self.cli, '_set_shared_conversation_history'):
|
||
self.cli._set_shared_conversation_history()
|
||
label = "无限制" if new_val >= 999999 else str(new_val)
|
||
self._update_status(f"历史条数: {label}")
|
||
elif idx == 4:
|
||
# diff 模式循环
|
||
if not state['diff']['available']:
|
||
self._error("代码编辑器未加载,无法切换文件对比模式")
|
||
return
|
||
new_idx = (state['diff']['idx'] + 1) % 3
|
||
editor = None
|
||
if hasattr(self.cli, 'liugin_manager'):
|
||
for tool in self.cli.liugin_manager.tools:
|
||
if tool.get('name') == 'code_editor':
|
||
editor = tool.get('instance')
|
||
break
|
||
if editor is None:
|
||
return
|
||
if new_idx == 0: # off
|
||
editor.diff_popup_enabled = False
|
||
elif new_idx == 1: # inline
|
||
editor.diff_popup_enabled = True
|
||
editor.diff_popup_mode = False
|
||
else: # popup
|
||
editor.diff_popup_enabled = True
|
||
editor.diff_popup_mode = True
|
||
label = state['diff']['options'][new_idx][1]
|
||
self._update_status(f"文件对比: {label}")
|
||
elif idx == 5:
|
||
# 预设背景循环
|
||
self._bg_idx = (state['background']['idx'] + 1) % len(self._BG_CLASSES)
|
||
# 切换预设时清除自定义色(避免自定义覆盖预设)
|
||
self._bg_custom_color = ""
|
||
self._apply_background()
|
||
label = self._BG_LABELS[self._bg_idx]
|
||
self._update_status(f"背景: {label}")
|
||
try:
|
||
set_system_config('tui_background', self._bg_idx)
|
||
except Exception:
|
||
pass
|
||
elif idx == 6:
|
||
# 自定义背景切换:有自定义则关闭,无则提示用 /bg 命令
|
||
if self._bg_custom_color:
|
||
self._bg_image_path = ""
|
||
self._bg_custom_color = ""
|
||
try:
|
||
set_system_config('tui_bg_image', '')
|
||
except Exception:
|
||
pass
|
||
self._apply_background()
|
||
self._update_status("已清除自定义背景")
|
||
else:
|
||
self._system("用 /bg <图片路径> 设置自定义背景,/bg off 清除")
|
||
else:
|
||
return
|
||
|
||
# 刷新辅助中心卡片内容
|
||
overlay = self.query_one("#settings-overlay")
|
||
if overlay.has_class("visible"):
|
||
overlay.update(self._build_settings_text())
|
||
|
||
# ── 背景效果 ──
|
||
def _apply_background(self):
|
||
"""根据 _bg_idx / 自定义色 给 #chat-scroll 切换背景
|
||
优先级:自定义图片色 > 预设色调
|
||
"""
|
||
try:
|
||
scroll = self.query_one("#chat-scroll")
|
||
# 移除所有预设背景 class
|
||
for cls in self._BG_CLASSES:
|
||
if cls:
|
||
scroll.remove_class(cls)
|
||
# 移除自定义背景的内联样式
|
||
try:
|
||
scroll.styles.background = _Theme.BG
|
||
except Exception:
|
||
pass
|
||
# 自定义图片色优先
|
||
if self._bg_custom_color:
|
||
try:
|
||
scroll.styles.background = self._bg_custom_color
|
||
except Exception:
|
||
pass
|
||
else:
|
||
cur = self._BG_CLASSES[self._bg_idx]
|
||
if cur:
|
||
scroll.add_class(cur)
|
||
except Exception:
|
||
pass
|
||
|
||
def _extract_color_from_image(self):
|
||
"""从 _bg_image_path 提取主色调,暗化后作为背景色
|
||
暗化系数 0.18,避免图片过亮影响文字阅读
|
||
"""
|
||
if not self._bg_image_path:
|
||
self._bg_custom_color = ""
|
||
return
|
||
try:
|
||
from .constants import PIL_AVAILABLE
|
||
if not PIL_AVAILABLE:
|
||
self._bg_custom_color = ""
|
||
self._error("背景图片需要 Pillow 库")
|
||
return
|
||
from PIL import Image
|
||
import os
|
||
if not os.path.isfile(self._bg_image_path):
|
||
self._bg_custom_color = ""
|
||
self._error(f"背景图片不存在: {self._bg_image_path}")
|
||
return
|
||
with Image.open(self._bg_image_path) as img:
|
||
img = img.convert("RGB")
|
||
# 缩小到 40x40 加速计算
|
||
img.thumbnail((40, 40))
|
||
pixels = list(img.getdata())
|
||
if not pixels:
|
||
self._bg_custom_color = ""
|
||
return
|
||
# 计算平均色
|
||
r = sum(p[0] for p in pixels) // len(pixels)
|
||
g = sum(p[1] for p in pixels) // len(pixels)
|
||
b = sum(p[2] for p in pixels) // len(pixels)
|
||
# 暗化到 18% 亮度,保证文字可读
|
||
r = int(r * 0.18)
|
||
g = int(g * 0.18)
|
||
b = int(b * 0.18)
|
||
self._bg_custom_color = f"#{r:02x}{g:02x}{b:02x}"
|
||
except Exception as e:
|
||
self._bg_custom_color = ""
|
||
self._error(f"提取背景色失败: {e}")
|
||
|
||
def _handle_background(self, args):
|
||
"""/bg 命令:设置自定义图片背景或清除
|
||
用法: /bg <图片路径> 设置背景(提取主色调)
|
||
/bg off 清除自定义,回到预设
|
||
/bg list 显示当前背景状态
|
||
"""
|
||
from .config import set_system_config
|
||
arg = args.strip()
|
||
if not arg or arg.lower() == "list":
|
||
# 显示状态
|
||
if self._bg_custom_color:
|
||
name = os.path.basename(self._bg_image_path) if self._bg_image_path else ""
|
||
self._system(f"当前背景: 自定义 ({name}) 颜色: {self._bg_custom_color}")
|
||
else:
|
||
self._system(f"当前背景: 预设 ({self._BG_LABELS[self._bg_idx]})")
|
||
return
|
||
if arg.lower() == "off":
|
||
self._bg_image_path = ""
|
||
self._bg_custom_color = ""
|
||
try:
|
||
set_system_config('tui_bg_image', '')
|
||
except Exception:
|
||
pass
|
||
self._apply_background()
|
||
self._update_status("背景: 已清除自定义")
|
||
# 刷新辅助中心
|
||
try:
|
||
overlay = self.query_one("#settings-overlay")
|
||
if overlay.has_class("visible"):
|
||
overlay.update(self._build_settings_text())
|
||
except Exception:
|
||
pass
|
||
return
|
||
# 设置图片路径
|
||
path = arg.strip().strip('"').strip("'")
|
||
# 展开 ~ 和环境变量
|
||
path = os.path.expanduser(os.path.expandvars(path))
|
||
if not os.path.isfile(path):
|
||
self._error(f"图片文件不存在: {path}")
|
||
return
|
||
self._bg_image_path = path
|
||
self._extract_color_from_image()
|
||
if self._bg_custom_color:
|
||
self._apply_background()
|
||
try:
|
||
set_system_config('tui_bg_image', path)
|
||
except Exception:
|
||
pass
|
||
name = os.path.basename(path)
|
||
self._update_status(f"背景: {name}")
|
||
self._system(f"已设置背景: {name} 颜色: {self._bg_custom_color}")
|
||
# 刷新辅助中心
|
||
try:
|
||
overlay = self.query_one("#settings-overlay")
|
||
if overlay.has_class("visible"):
|
||
overlay.update(self._build_settings_text())
|
||
except Exception:
|
||
pass
|
||
|
||
def _handle_profile(self, args):
|
||
"""引擎配置档案管理: save/use/del/list"""
|
||
from .config import get_profiles, save_profile, delete_profile
|
||
|
||
parts = args.strip().split(maxsplit=1)
|
||
sub = parts[0].lower() if parts else ""
|
||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||
|
||
engine = self.bridge.current_engine()
|
||
engine_name = getattr(engine, 'name', 'unknown')
|
||
|
||
if sub in ('', 'list'):
|
||
# 显示辅助中心浮层(等同于 /settings)
|
||
self._show_settings()
|
||
return
|
||
|
||
if sub == 'save':
|
||
if not arg:
|
||
self._error("请提供档案名称。用法: /profile save <名称>")
|
||
return
|
||
profile = {
|
||
"name": arg,
|
||
"base_url": getattr(engine, 'base_url', ''),
|
||
"api_key": getattr(engine, 'api_key', ''),
|
||
"model": getattr(engine, 'model', ''),
|
||
}
|
||
save_profile(engine_name, profile)
|
||
self._system(f" 已保存配置档案: {arg}(引擎: {engine_name})")
|
||
# 如果辅助中心浮层可见则刷新
|
||
overlay = self.query_one("#settings-overlay")
|
||
if overlay.has_class("visible"):
|
||
overlay.update(self._build_settings_text())
|
||
|
||
elif sub == 'use':
|
||
if not arg:
|
||
self._error("请提供档案名称或序号。用法: /profile use <名称|序号>")
|
||
return
|
||
profiles = get_profiles(engine_name)
|
||
if not profiles:
|
||
self._error(f"引擎 {engine_name} 没有已保存的配置档案")
|
||
return
|
||
target = None
|
||
if arg.isdigit():
|
||
idx = int(arg) - 1
|
||
if 0 <= idx < len(profiles):
|
||
target = profiles[idx]
|
||
else:
|
||
self._error(f"序号 {arg} 超出范围(共 {len(profiles)} 个档案)")
|
||
return
|
||
else:
|
||
for p in profiles:
|
||
if p.get('name') == arg:
|
||
target = p
|
||
break
|
||
if not target:
|
||
self._error(f"未找到档案: {arg}")
|
||
return
|
||
|
||
# 应用配置到引擎实例
|
||
if hasattr(engine, 'apply_config'):
|
||
engine.apply_config(
|
||
base_url=target.get('base_url'),
|
||
api_key=target.get('api_key'),
|
||
model=target.get('model'),
|
||
)
|
||
self._system(f" 已切换到配置档案: {target.get('name')}({engine_name})")
|
||
self._update_status(f"引擎: {engine_name} | {target.get('model', '')}")
|
||
self._update_sidebar()
|
||
# 刷新辅助中心浮层
|
||
overlay = self.query_one("#settings-overlay")
|
||
if overlay.has_class("visible"):
|
||
overlay.update(self._build_settings_text())
|
||
else:
|
||
self._error(f"引擎 {engine_name} 不支持配置切换")
|
||
|
||
elif sub in ('del', 'delete'):
|
||
if not arg:
|
||
self._error("请提供档案名称。用法: /profile del <名称>")
|
||
return
|
||
profiles = get_profiles(engine_name)
|
||
exists = any(p.get('name') == arg for p in profiles)
|
||
if not exists:
|
||
self._error(f"未找到档案: {arg}")
|
||
return
|
||
delete_profile(engine_name, arg)
|
||
self._system(f" 已删除配置档案: {arg}")
|
||
overlay = self.query_one("#settings-overlay")
|
||
if overlay.has_class("visible"):
|
||
overlay.update(self._build_settings_text())
|
||
|
||
else:
|
||
self._error("用法: /profile save <名称> | use <名称|序号> | del <名称> | list")
|
||
|
||
def _handle_manual(self, args):
|
||
if 'manual' not in self.cli.engines:
|
||
self._error("手动引擎未加载")
|
||
return
|
||
if not args:
|
||
if self.bridge.switch_engine('manual'):
|
||
self._system("已切换到手动引擎")
|
||
self._update_sidebar()
|
||
else:
|
||
self._error("切换失败")
|
||
else:
|
||
result = self.cli.engines['manual'].handle_command(args)
|
||
if result:
|
||
self._system(result)
|
||
|
||
def _switch_cli(self):
|
||
self._system("切换到命令行模式...")
|
||
self.cli.tui_output_callback = None
|
||
self.cli._cli_requested = True
|
||
self.exit()
|
||
|
||
def _switch_model(self, name):
|
||
name = name.strip()
|
||
if not name:
|
||
self._show_engines()
|
||
return
|
||
if self.bridge.switch_engine(name):
|
||
self._system(f"已切换到: {name}")
|
||
self._update_sidebar()
|
||
else:
|
||
self._error(f"未找到引擎: {name}")
|
||
|
||
def _show_engines(self):
|
||
current = self.bridge.current_engine()
|
||
lines = [" 可用引擎:"]
|
||
for name in self.bridge.engines():
|
||
marker = ">" if name == current else " "
|
||
lines.append(f" {marker} {name}")
|
||
self._system('\n'.join(lines))
|
||
|
||
def _show_tools(self):
|
||
tools = self.bridge.tools()
|
||
lines = [f" 可用工具 ({len(tools)} 个):"]
|
||
for t in tools:
|
||
name = t.get('name', '?')
|
||
desc = t.get('description', '')[:40]
|
||
lines.append(f" - {name}: {desc}")
|
||
self._system('\n'.join(lines))
|
||
|
||
def _show_status(self):
|
||
status_lines = [
|
||
f" 系统状态:",
|
||
f" 引擎: {self.bridge.current_engine()}",
|
||
f" 引擎数: {len(self.bridge.engines())}",
|
||
f" 工具数: {len(self.bridge.tools())}",
|
||
f" 对话数: {len(self.bridge.history())}",
|
||
]
|
||
self._system('\n'.join(status_lines))
|
||
|
||
# ── AI 生成 (elia @work + call_from_thread 模式) ──
|
||
|
||
def _generate(self, user_input):
|
||
"""流式生成 — 基于 elia 的 stream_agent_response 模式
|
||
|
||
在线程中运行同步的 cli_core,
|
||
通过 call_from_thread() 线程安全地更新 UI。
|
||
"""
|
||
_stream_state = {
|
||
"widget": None,
|
||
"thinking_widget": None,
|
||
"text": "",
|
||
"thinking_text": "",
|
||
"thinking_removed": False,
|
||
"first_chunk": True,
|
||
}
|
||
|
||
def tui_output(msg):
|
||
# 引擎切换等操作可能在主线程调用,需做线程判断
|
||
import threading as _t
|
||
if self._thread_id == _t.get_ident():
|
||
self._write_raw(msg)
|
||
else:
|
||
self.call_from_thread(self._write_raw, msg)
|
||
|
||
original_cb = self.cli.tui_output_callback
|
||
self.cli.tui_output_callback = tui_output
|
||
|
||
def tui_stream(chunk, kind="content"):
|
||
"""流式回调 — kind: 'content' | 'thinking'"""
|
||
if self._cancel_requested:
|
||
return
|
||
if kind == "thinking":
|
||
_stream_state["thinking_text"] += chunk
|
||
if _stream_state["thinking_widget"] is None:
|
||
tw = ThinkingBox(content="")
|
||
_stream_state["thinking_widget"] = tw
|
||
self.call_from_thread(self._mount_thinking_widget, tw)
|
||
self.call_from_thread(
|
||
_stream_state["thinking_widget"].append_chunk, chunk
|
||
)
|
||
self.call_from_thread(self._smart_scroll_end)
|
||
return
|
||
# kind == "content"
|
||
if _stream_state["first_chunk"]:
|
||
_stream_state["first_chunk"] = False
|
||
_stream_state["thinking_removed"] = True
|
||
widget = Chatbox(
|
||
role="assistant",
|
||
content=chunk,
|
||
classes="response-in-progress",
|
||
)
|
||
_stream_state["widget"] = widget
|
||
_stream_state["text"] = chunk
|
||
self.call_from_thread(self._mount_first_chunk, widget)
|
||
return
|
||
|
||
_stream_state["text"] += chunk
|
||
widget = _stream_state["widget"]
|
||
if widget:
|
||
self.call_from_thread(widget.append_chunk, chunk)
|
||
self.call_from_thread(self._smart_scroll_end)
|
||
|
||
original_stream_cb = getattr(self.cli, 'tui_stream_callback', None)
|
||
self.cli.tui_stream_callback = tui_stream
|
||
|
||
# 新增:思考专用回调(独立于内容流)
|
||
def tui_thinking(chunk):
|
||
tui_stream(chunk, kind="thinking")
|
||
|
||
original_thinking_cb = getattr(self.cli, 'tui_thinking_callback', None)
|
||
self.cli.tui_thinking_callback = tui_thinking
|
||
|
||
# 每轮 AI 生成前: 重新显示思考指示器 (工具调用后 AI 继续)
|
||
def tui_before_generate():
|
||
_stream_state["thinking_removed"] = False
|
||
_stream_state["text"] = ""
|
||
_stream_state["thinking_text"] = ""
|
||
_stream_state["widget"] = None
|
||
_stream_state["thinking_widget"] = None
|
||
_stream_state["first_chunk"] = True
|
||
self.call_from_thread(self._show_thinking)
|
||
self.call_from_thread(lambda: self._update_status(" 思考中..."))
|
||
|
||
original_before_cb = getattr(self.cli, 'tui_before_generate_callback', None)
|
||
self.cli.tui_before_generate_callback = tui_before_generate
|
||
|
||
# 工具调用审查确认 — 跨线程 Event 实现
|
||
import threading as _threading
|
||
self._confirm_event = _threading.Event()
|
||
|
||
def tui_confirm(risk_info):
|
||
"""worker 线程调用:显示风险信息并等待用户确认"""
|
||
self._confirm_event.clear()
|
||
self._confirm_result = False
|
||
self.call_from_thread(self._show_confirm, risk_info)
|
||
self._confirm_event.wait(timeout=120)
|
||
return self._confirm_result
|
||
|
||
original_confirm_cb = getattr(self.cli, 'tui_confirm_callback', None)
|
||
self.cli.tui_confirm_callback = tui_confirm
|
||
|
||
# 记录当前 worker 的代次,用于 finally 中判断是否已被取消/替换
|
||
my_generation = getattr(self, '_cancel_generation', 0)
|
||
|
||
try:
|
||
self.cli.process_conversation(user_input)
|
||
except Exception as e:
|
||
# 被取消时不报错
|
||
if not self._cancel_requested:
|
||
self.call_from_thread(self._error, str(e))
|
||
finally:
|
||
# 只有当前 worker 未被取消/替换时,才恢复回调和状态
|
||
current_generation = getattr(self, '_cancel_generation', 0)
|
||
if current_generation == my_generation:
|
||
self.cli.tui_output_callback = original_cb
|
||
self.cli.tui_stream_callback = original_stream_cb
|
||
self.cli.tui_thinking_callback = original_thinking_cb
|
||
self.cli.tui_before_generate_callback = original_before_cb
|
||
self.cli.tui_confirm_callback = original_confirm_cb
|
||
# 移除 response-in-progress 样式
|
||
if _stream_state["widget"]:
|
||
self.call_from_thread(
|
||
_stream_state["widget"].remove_class, "response-in-progress"
|
||
)
|
||
# 清理空的思考 widget
|
||
if _stream_state["thinking_widget"] is not None and not _stream_state["thinking_text"]:
|
||
self.call_from_thread(self._remove_thinking_widget,
|
||
_stream_state["thinking_widget"])
|
||
self.call_from_thread(self._remove_thinking)
|
||
self.call_from_thread(lambda: self._update_status("就绪"))
|
||
self.call_from_thread(lambda: setattr(self, 'is_generating', False))
|
||
self.call_from_thread(self._update_sidebar)
|
||
|
||
_RICH_TAG_RE = re.compile(r'\[/?[a-zA-Z][^\[\]]*?\]')
|
||
|
||
def _write_raw(self, msg):
|
||
"""输出原始消息 — 去掉 Rich 标记"""
|
||
# 使用 rich 自身的标记解析(最准确,保留中文方括号)
|
||
try:
|
||
from rich.text import Text
|
||
clean = Text.from_markup(msg).plain
|
||
except Exception:
|
||
# 降级:手动正则剥离
|
||
prev = None
|
||
clean = msg
|
||
while prev != clean:
|
||
prev = clean
|
||
clean = self._RICH_TAG_RE.sub('', clean)
|
||
if 'OK' in clean or ('工具' in clean and ('调用' in clean or '执行' in clean)):
|
||
self._add(f" {clean}", "msg-tool-ok")
|
||
elif 'ERROR' in clean or '错误' in clean or '安全' in clean or '拒绝' in clean:
|
||
self._add(f" {clean}", "msg-tool-err")
|
||
else:
|
||
self._add(f" {clean}", "msg-dim")
|
||
|
||
# ── 动作 ──
|
||
|
||
def action_clear(self):
|
||
if self.is_generating:
|
||
return
|
||
scroll = self.query_one("#chat-scroll")
|
||
scroll.remove_children()
|
||
self._render_welcome()
|
||
|
||
def action_new_chat(self):
|
||
if self.is_generating:
|
||
return
|
||
# 清空前自动保存当前对话(如果有内容且记忆系统可用)
|
||
history = self.cli.shared_conversation_history
|
||
if history and getattr(self.cli, 'memory_manager', None):
|
||
try:
|
||
# 用第一条用户消息作为标签
|
||
label = ""
|
||
for m in history:
|
||
if m.get('role') == 'user' and m.get('content'):
|
||
label = m['content'][:30]
|
||
break
|
||
self.cli.memory_manager.auto_save_chat(history, label=label or "未命名对话")
|
||
except Exception:
|
||
pass
|
||
history.clear()
|
||
self.action_clear()
|
||
self._system("已开始新对话(旧对话已自动保存)")
|
||
|
||
def action_toggle_sidebar(self):
|
||
sidebar = self.query_one("#sidebar")
|
||
if sidebar.has_class("visible"):
|
||
# 隐藏:先播放滑出动画,结束后再 display:none 释放布局空间
|
||
sidebar.remove_class("visible")
|
||
sidebar.add_class("hiding")
|
||
self.sidebar_visible = False
|
||
|
||
def _finish_hide():
|
||
sidebar.remove_class("hiding")
|
||
sidebar.styles.display = "none"
|
||
|
||
self.set_timer(0.26, _finish_hide)
|
||
else:
|
||
# 显示:先 display:block 占位,下一帧再加 visible 类触发滑入
|
||
sidebar.styles.display = "block"
|
||
self._update_sidebar()
|
||
|
||
def _show():
|
||
sidebar.add_class("visible")
|
||
|
||
self.call_after_refresh(_show)
|
||
self.sidebar_visible = True
|
||
|
||
def action_switch_engine(self):
|
||
"""F2: 引擎切换 — 居中浮层丝滑滑入滑出"""
|
||
engines = self.bridge.engines()
|
||
if len(engines) <= 1:
|
||
self._system("只有一个引擎,无法切换")
|
||
return
|
||
current = self.bridge.current_engine()
|
||
try:
|
||
idx = engines.index(current)
|
||
next_idx = (idx + 1) % len(engines)
|
||
next_engine = engines[next_idx]
|
||
except ValueError:
|
||
next_engine = engines[0]
|
||
|
||
# 清理旧 banner
|
||
for old in self.query("EngineSwitchBanner"):
|
||
old.remove()
|
||
|
||
# 先挂载并显示 banner,再执行切换(营造过渡感)
|
||
banner = EngineSwitchBanner(current, next_engine)
|
||
self.mount(banner)
|
||
status = self.query_one("#status-bar")
|
||
|
||
def _show():
|
||
banner.add_class("show")
|
||
status.add_class("pulse")
|
||
self._update_status(f"切换中: {current} → {next_engine}")
|
||
|
||
def _do_switch():
|
||
# banner 显示后再真正切换引擎
|
||
if not self.bridge.switch_engine(next_engine):
|
||
self._error(f"切换引擎失败: {next_engine}")
|
||
banner.remove_class("show")
|
||
banner.add_class("hide")
|
||
self.set_timer(0.3, lambda: banner.remove())
|
||
status.remove_class("pulse")
|
||
self._update_status("就绪")
|
||
return
|
||
self._update_status(f"已切换: {next_engine}")
|
||
|
||
def _hide():
|
||
banner.remove_class("show")
|
||
banner.add_class("hide")
|
||
|
||
def _cleanup():
|
||
banner.remove()
|
||
status.remove_class("pulse")
|
||
self._update_status("就绪")
|
||
self._update_sidebar()
|
||
|
||
self.call_after_refresh(_show)
|
||
self.set_timer(0.25, _do_switch) # banner 滑入快完成时执行切换
|
||
self.set_timer(1.4, _hide) # 显示 1.15s 后开始滑出
|
||
self.set_timer(1.75, _cleanup) # 滑出完成后清理
|
||
|
||
def action_cancel(self):
|
||
if not self.is_generating:
|
||
return
|
||
self._cancel_requested = True
|
||
self._system("正在取消...")
|
||
# 通知引擎中断(设置 _cancelled 标志,流式循环会检查并退出)
|
||
engine = getattr(self.cli, 'current_engine', None)
|
||
if engine and hasattr(engine, 'cancel'):
|
||
try:
|
||
engine.cancel()
|
||
except Exception:
|
||
pass
|
||
# 释放确认等待(如果在工具审查确认期间取消)
|
||
if self._confirm_event:
|
||
self._confirm_event.set()
|
||
# 隐藏确认浮层
|
||
self._hide_confirm()
|
||
# 取消 worker(标记取消,线程会在下次检查点退出)
|
||
if self._current_worker:
|
||
try:
|
||
self._current_worker.cancel()
|
||
except Exception:
|
||
pass
|
||
self._current_worker = None
|
||
# 生成唯一 ID 标记本次取消,防止旧 worker finally 覆盖新 worker 状态
|
||
self._cancel_generation = getattr(self, '_cancel_generation', 0) + 1
|
||
# UI 收尾
|
||
self._finish_cancel()
|
||
|
||
def _finish_cancel(self):
|
||
"""主线程中完成取消的 UI 收尾"""
|
||
self._remove_thinking()
|
||
self._update_status("已取消")
|
||
self.is_generating = False
|
||
self._update_sidebar()
|
||
|
||
else:
|
||
class XiaoliTUI:
|
||
"""Textual 未安装时的占位类"""
|
||
def __init__(self, *args, **kwargs):
|
||
pass
|
||
def run(self, *args, **kwargs):
|
||
print("TUI 模式不可用:Textual 库未安装")
|
||
print("请运行: pip install textual rich")
|