ollama 自定义ui

一、项目结构

复制代码
D:\ai-agent-tk\
│
├── main.py                  # 程序入口,启动 Tkinter 主循环
├── app.py                  # 主应用类 App,绑定 UI 事件、流式响应、技能审批流
├── config.py               # 配置加载(config.ini),Ollama 地址/模型名/字体等
├── config.ini              # 实际配置文件(Ollama URL、模型、默认技能开关等)
├── conversation.py         # 对话管理:历史记录增删改查、导出、Ollama API 消息组装
├── conversations\          # 对话持久化目录,每个对话一个 JSON 文件
│   ├── 25a3ba9b-....json
│   └── 3a93b3db-....json
├── ollama_client.py       # Ollama API 客户端:流式生成、模型列表、连接检测
├── dialogs.py             # 弹窗/对话框:新建对话、重命名、确认删除等
├── widgets.py             # 自定义 Tkinter 控件:ChatBubble、Markdown 渲染、代码块
├── prompt_templates.json  # AI 系统提示词模板(中文/英文两套)
├── skills\                # 可插拔技能目录
│   ├── __init__.py       # 技能注册表:build_skill_prompt()、自动审批加载
│   ├── get_time.py       # 技能:获取当前时间(带时区)
│   ├── ssh_execute.py    # 技能:SSH 远程执行命令
│   └── auto_approve.json # 自动审批白名单(skip 弹窗的技能 ID)
├── start.bat              # Windows 启动脚本(双击运行)

main.py 入口函数

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""AI Agent --- 程序入口"""
import tkinter as tk
from app import ChatApp


def main():
    try:
        from ctypes import windll
        windll.shcore.SetProcessDpiAwareness(1)
    except Exception:
        pass
    root = tk.Tk()
    ChatApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()

widgets.py tk UI 页面

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""UI 组件:ScrollFrame 滚动容器 + ChatBubble 聊天气泡(含 Markdown 渲染 & 语法高亮)"""
import tkinter as tk
import re
from datetime import datetime
from config import C


# ── 滚动框架 ───────────────────────────────────────────────────
class ScrollFrame(tk.Frame):
    def __init__(self, parent, **kw):
        _bg = kw.get("bg", C["bg_chat"])
        super().__init__(parent, **kw)
        self.canvas = tk.Canvas(self, bg=_bg,
                                highlightthickness=0, bd=0)
        self.vscroll = tk.Scrollbar(self, orient="vertical",
                                    command=self.canvas.yview,
                                    width=8, bd=0, highlightthickness=0)
        self.inner = tk.Frame(self.canvas, bg=_bg)
        self.inner.bind(
            "<Configure>",
            lambda e: self.canvas.configure(
                scrollregion=self.canvas.bbox("all")))
        self._wid = self.canvas.create_window(
            (0, 0), window=self.inner, anchor="nw")
        self.canvas.configure(yscrollcommand=self.vscroll.set)
        self.canvas.bind(
            "<Configure>",
            lambda e: self.canvas.itemconfig(self._wid, width=e.width))
        self.vscroll.pack(side="right", fill="y")
        self.canvas.pack(side="left", fill="both", expand=True)
        self._wb = None
        self.canvas.bind("<Enter>",  self._wb_on)
        self.canvas.bind("<Leave>", self._wb_off)

    def _wb_on(self, e):
        self._wb = self.canvas.bind_all("<MouseWheel>", self._on_wheel)
        self.canvas.bind_all("<Button-4>",
            lambda e: self.canvas.yview_scroll(-1, "units"))
        self.canvas.bind_all("<Button-5>",
            lambda e: self.canvas.yview_scroll(1, "units"))

    def _wb_off(self, e):
        if self._wb:
            self.canvas.unbind_all("<MouseWheel>")
            self.canvas.unbind_all("<Button-4>")
            self.canvas.unbind_all("<Button-5>")
            self._wb = None

    def _on_wheel(self, e):
        self.canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")

    def scroll_bottom(self):
        self.canvas.update_idletasks()
        self.canvas.yview_moveto(1.0)


# ── 聊天气泡 ───────────────────────────────────────────────────
class ChatBubble(tk.Frame):
    def __init__(self, parent, role, text, timestamp=None,
                 streaming=True, font_size=11, msg_index=-1,
                 on_delete=None, on_edit=None):
        is_user = role == "user"
        is_system = role == "system"
        if is_user:
            self._bg = C["user_bubble"]
        elif is_system:
            self._bg = C["border"]  # 技能结果用边框色背景
        else:
            self._bg = C["ai_bubble"]
        self._font_size = font_size
        self._msg_index = msg_index
        self._on_delete = on_delete
        self._on_edit   = on_edit
        super().__init__(parent, bg=C["bg_chat"])
        if timestamp is None:
            timestamp = datetime.now().strftime("%H:%M")
        self._full_text = text
        self._stream_label = None

        align = tk.Frame(self, bg=C["bg_chat"])
        if is_system:
            align.pack(anchor="center", padx=40, pady=(10, 0))
        else:
            align.pack(anchor="e" if is_user else "w", padx=20, pady=(10, 0))
        # 角色名 + 时间戳 同一行
        header_row = tk.Frame(align, bg=C["bg_chat"])
        header_row.pack(anchor="center" if is_system else ("e" if is_user else "w"))
        if is_system:
            role_text = "🔧 技能"
            rc = C["yellow"]
        else:
            role_text = "你" if is_user else "AI"
            rc = C["green"] if is_user else C["accent"]
        tk.Label(header_row, text=role_text,
                  font=("Microsoft YaHei UI", 9),
                  fg=rc, bg=C["bg_chat"]).pack(side="left")
        if timestamp:
            self._ts_label = tk.Label(header_row, text=f"  {timestamp}",
                      font=("Microsoft YaHei UI", 8),
                      fg=C["text_dim"], bg=C["bg_chat"])
            self._ts_label.pack(
                side="left" if not is_user else "right")
        else:
            self._ts_label = None
        self._bubble = tk.Frame(align, bg=self._bg, padx=14, pady=10)
        if is_system:
            self._bubble.pack(anchor="center", padx=20)
        else:
            self._bubble.pack(anchor="e" if is_user else "w",
                               padx=(60, 0) if not is_user else (0, 60))

        if streaming:
            self._stream_label = tk.Label(
                self._bubble, text=text,
                font=("Microsoft YaHei UI", font_size),
                fg=C["text"], bg=self._bg,
                wraplength=480,
                justify="left", anchor="w")
            self._stream_label.pack()
        else:
            self._render_content(text)

        # 右键复制菜单
        self._bind_right_click(self)
        self._bind_right_click(self._bubble)
        self._bind_right_click(align)

        # hover 操作按钮(仅非流式消息显示)
        self._del_btn  = None
        self._edit_btn = None

        if not streaming and (self._on_delete or self._on_edit):
            # 删除按钮
            if self._on_delete:
                self._del_btn = tk.Label(
                    align, text="✕", font=("Arial", 8),
                    fg=C["text_dim"], bg=C["bg_chat"],
                    cursor="hand2", padx=4)
                self._del_btn.bind(
                    "<Button-1>",
                    lambda e: self._on_delete(self._msg_index))
                self._del_btn.bind(
                    "<Enter>",
                    lambda e: self._del_btn.config(fg=C["red"]))
                self._del_btn.bind(
                    "<Leave>",
                    lambda e: self._del_btn.config(fg=C["text_dim"]))

            # 编辑按钮(仅用户消息)
            if is_user and self._on_edit:
                self._edit_btn = tk.Label(
                    align, text="✎", font=("Arial", 8),
                    fg=C["text_dim"], bg=C["bg_chat"],
                    cursor="hand2", padx=4)
                self._edit_btn.bind(
                    "<Button-1>",
                    lambda e: self._on_edit(self._msg_index))
                self._edit_btn.bind(
                    "<Enter>",
                    lambda e: self._edit_btn.config(fg=C["accent"]))
                self._edit_btn.bind(
                    "<Leave>",
                    lambda e: self._edit_btn.config(fg=C["text_dim"]))

            # hover 显示/隐藏
            def _show_btns(e):
                if self._del_btn:
                    side = "right" if is_user else "left"
                    self._del_btn.pack(side=side, padx=(4, 0))
                if self._edit_btn:
                    side = "right" if is_user else "left"
                    self._edit_btn.pack(side=side, padx=(0, 4))
            def _hide_btns(e):
                try:
                    if self._del_btn:
                        self._del_btn.pack_forget()
                    if self._edit_btn:
                        self._edit_btn.pack_forget()
                except Exception:
                    pass

            for w in (self, align, self._bubble):
                w.bind("<Enter>", _show_btns)
                w.bind("<Leave>", _hide_btns)

        # 把自己 pack 进 parent(不用 fill="x",让气泡自适应内容宽度)
        self.pack(anchor="e" if is_user else "w")

    def _bind_right_click(self, widget):
        widget.bind("<Button-3>", self._show_copy_menu)

    def _auto_size_text(self, tw, max_chars=55):
        """自动调整 Text 控件宽高,使其贴合内容且不超过最大宽度"""
        content = tw.get("1.0", "end-1c")
        if not content.strip():
            tw.config(width=3, height=1)
            return
        lines = content.split('\n')
        # 计算显示宽度:CJK 字符 ≈ 2 单位宽度
        def line_w(s):
            return sum(2 if ord(c) > 0x2E80 else 1 for c in s)
        max_line = max(line_w(l) for l in lines)
        new_w = max(min(max_line + 4, max_chars), 3)
        # 估算显示行数(考虑自动换行)
        total = 0
        for l in lines:
            w = line_w(l)
            total += max(1, -(-w // new_w)) if w > 0 else 1  # ceil 除法
        tw.config(width=new_w, height=max(total, 1))

    def _show_copy_menu(self, event):
        menu = tk.Menu(self.winfo_toplevel(), tearoff=0,
                        bg=C["input_bg"], fg=C["text"],
                        activebackground=C["accent"],
                        activeforeground=C["btn_send_fg"])
        menu.add_command(label="📋 复制全部内容",
                          command=self._copy_all)
        try:
            menu.tk_popup(event.x_root, event.y_root)
        finally:
            menu.grab_release()

    def _copy_all(self):
        self.clipboard_clear()
        self.clipboard_append(self._full_text)

    def update_text(self, t):
        """流式输出时更新文本"""
        self._full_text = t
        if self._stream_label and self._stream_label.winfo_exists():
            self._stream_label.config(text=t + "▍")

    def render_final(self, text=None):
        """流式结束后,重建为带代码块格式的完整渲染"""
        if text is not None:
            self._full_text = text
        if self._stream_label:
            self._stream_label.destroy()
            self._stream_label = None
        self._render_content(self._full_text)

    def _render_content(self, text):
        """将文本渲染为带格式的 Markdown"""
        segments = self._parse_markdown(text)
        for seg_type, content, lang in segments:
            if seg_type == "code":
                self._add_code_block(content, lang)
            else:
                self._add_markdown_block(content)

    def _parse_markdown(self, text):
        """解析 Markdown,拆分为 (type, content, lang) 列表"""
        segments = []
        parts = re.split(r'(```[\s\S]*?```)', text)
        for part in parts:
            if part.startswith('```'):
                inner = part[3:]
                if inner.endswith('```'):
                    inner = inner[:-3]
                nl = inner.find('\n')
                if nl != -1:
                    lang = inner[:nl].strip()
                    code = inner[nl + 1:]
                else:
                    lang = ""
                    code = inner
                code = code.rstrip('\n')
                segments.append(('code', code, lang))
            else:
                if part:
                    segments.append(('text', part, ''))
        return segments

    # ── 行内格式 ──
    @staticmethod
    def _apply_inline(text):
        """返回行内格式化后的文本列表 [(text, tags), ...]"""
        result = []
        pattern = re.compile(
            r'(`[^`]+`)'            # 行内代码
            r'|(\*\*\*[^*]+\*\*\*)'  # 粗斜体
            r'|(\*\*[^*]+\*\*)'      # 粗体
            r'|(\*[^*]+\*)'          # 斜体
            r'|(\[([^\]]+)\]\([^)]+\))'  # 链接
        )
        last = 0
        for m in pattern.finditer(text):
            if m.start() > last:
                result.append((text[last:m.start()], ""))
            if m.group(1):
                result.append((m.group(1)[1:-1], "code"))
            elif m.group(2):
                result.append((m.group(2)[3:-3], "bold_italic"))
            elif m.group(3):
                result.append((m.group(3)[2:-2], "bold"))
            elif m.group(4):
                result.append((m.group(4)[1:-1], "italic"))
            elif m.group(5):
                result.append((m.group(6), "link"))
            last = m.end()
        if last < len(text):
            result.append((text[last:], ""))
        if not result:
            result.append((text, ""))
        return result

    def _insert_inline(self, widget, text):
        """向Text控件插入带格式的行内文本"""
        parts = self._apply_inline(text)
        for txt, tag in parts:
            if tag == "code":
                widget.insert("end", txt, ("inline_code",))
            elif tag == "bold":
                widget.insert("end", txt, ("bold",))
            elif tag == "italic":
                widget.insert("end", txt, ("italic",))
            elif tag == "bold_italic":
                widget.insert("end", txt, ("bold_italic",))
            elif tag == "link":
                widget.insert("end", txt, ("link",))
            else:
                widget.insert("end", txt)

    def _configure_text_tags(self, widget):
        """为Text控件配置行内格式tag"""
        bold_font = ("Microsoft YaHei UI", self._font_size, "bold")
        italic_font = ("Microsoft YaHei UI", self._font_size, "italic")
        bi_font = ("Microsoft YaHei UI", self._font_size, "bold italic")
        widget.tag_configure("inline_code",
                              font=("Consolas", self._font_size),
                              foreground=C["accent"],
                              background=C["code_bg"])
        widget.tag_configure("bold", font=bold_font)
        widget.tag_configure("italic", font=italic_font)
        widget.tag_configure("bold_italic", font=bi_font)
        widget.tag_configure("link", font=italic_font,
                              foreground=C["accent"])

    def _add_markdown_block(self, text):
        """渲染增强 Markdown 文本块"""
        lines = text.split('\n')
        i = 0
        while i < len(lines):
            line = lines[i]

            # 空行跳过
            if not line.strip():
                i += 1
                continue

            # 分割线 --- or *** or ___
            if re.match(r'^(\*{3,}|-{3,}|_{3,})\s*$', line.strip()):
                sep = tk.Frame(self._bubble, bg=C["border"], height=1)
                sep.pack(fill="x", pady=(6, 6), padx=4)
                i += 1
                continue

            # 标题 # ~ ######
            hm = re.match(r'^(#{1,6})\s+(.+)$', line)
            if hm:
                level = len(hm.group(1))
                title_text = hm.group(2)
                sizes = {1: 20, 2: 17, 3: 15, 4: 13, 5: 12, 6: 11}
                fs = sizes.get(level, self._font_size)
                tw = tk.Text(self._bubble, font=("Microsoft YaHei UI", fs, "bold"),
                              fg=C["accent"], bg=self._bg,
                              wrap="word", padx=8, pady=(8, 2), bd=0,
                              highlightthickness=0, height=1,
                              cursor="arrow")
                self._configure_text_tags(tw)
                tw.insert("end", title_text)
                tw.config(state="disabled")
                self._auto_size_text(tw)
                tw.pack(fill="x")
                self._bind_right_click(tw)
                i += 1
                continue

            # 引用 > text
            if line.strip().startswith('>'):
                quote_lines = []
                while i < len(lines) and lines[i].strip().startswith('>'):
                    quote_lines.append(re.sub(r'^>\s?', '', lines[i].strip()))
                    i += 1
                quote_text = '\n'.join(quote_lines)
                qf = tk.Frame(self._bubble, bg=C["bg_chat"],
                               padx=0, pady=0)
                qf.pack(fill="x", pady=(4, 4))
                tk.Frame(qf, bg=C["accent"], width=3).pack(
                    side="left", fill="y", padx=(8, 0))
                qt = tk.Text(qf, font=("Microsoft YaHei UI", self._font_size),
                              fg=C["text_dim"], bg=C["bg_chat"],
                              wrap="word", padx=8, pady=4, bd=0,
                              highlightthickness=0, height=1,
                              cursor="arrow")
                self._configure_text_tags(qt)
                self._insert_inline(qt, quote_text)
                qt.config(state="disabled")
                self._auto_size_text(qt)
                qt.pack(fill="x", side="left", expand=True)
                self._bind_right_click(qt)
                self._bind_right_click(qf)
                continue

            # 表格 | col | col |
            if '|' in line and i + 1 < len(lines) and re.match(
                    r'^[\s|:-]+$', lines[i + 1].strip()):
                table_rows = []
                while i < len(lines) and '|' in lines[i]:
                    cells = [c.strip() for c in lines[i].strip().strip('|').split('|')]
                    table_rows.append(cells)
                    i += 1
                    if i < len(lines) and re.match(r'^[\s|:-]+$', lines[i].strip()):
                        i += 1  # 跳过分隔行
                self._add_table(table_rows)
                continue

            # 无序列表 - / * / +
            ulm = re.match(r'^(\s*)([-*+])\s+(.+)$', line)
            if ulm:
                while i < len(lines):
                    ulm2 = re.match(r'^(\s*)([-*+])\s+(.+)$', lines[i])
                    if not ulm2:
                        break
                    indent = len(ulm2.group(1))
                    item_text = ulm2.group(3)
                    prefix = "  " * (indent // 2) + "• "
                    lt = tk.Text(self._bubble,
                                  font=("Microsoft YaHei UI", self._font_size),
                                  fg=C["text"], bg=self._bg,
                                  wrap="word", padx=8, pady=0, bd=0,
                                  highlightthickness=0, height=1,
                                  cursor="arrow")
                    self._configure_text_tags(lt)
                    lt.insert("end", prefix)
                    self._insert_inline(lt, item_text)
                    lt.config(state="disabled")
                    self._auto_size_text(lt)
                    lt.pack(fill="x")
                    self._bind_right_click(lt)
                    i += 1
                continue

            # 有序列表 1. / 2.
            olm = re.match(r'^(\s*)(\d+)[.)]\s+(.+)$', line)
            if olm:
                while i < len(lines):
                    olm2 = re.match(r'^(\s*)(\d+)[.)]\s+(.+)$', lines[i])
                    if not olm2:
                        break
                    num = olm2.group(2)
                    item_text = olm2.group(3)
                    prefix = f"{num}. "
                    lt = tk.Text(self._bubble,
                                  font=("Microsoft YaHei UI", self._font_size),
                                  fg=C["text"], bg=self._bg,
                                  wrap="word", padx=8, pady=0, bd=0,
                                  highlightthickness=0, height=1,
                                  cursor="arrow")
                    self._configure_text_tags(lt)
                    lt.insert("end", prefix)
                    self._insert_inline(lt, item_text)
                    lt.config(state="disabled")
                    self._auto_size_text(lt)
                    lt.pack(fill="x")
                    self._bind_right_click(lt)
                    i += 1
                continue

            # 普通段落
            para_lines = [line]
            i += 1
            while i < len(lines):
                nl = lines[i]
                if (not nl.strip() or re.match(r'^#{1,6}\s', nl)
                        or nl.strip().startswith('>')
                        or re.match(r'^(\s*)([-*+])\s', nl)
                        or re.match(r'^(\s*)(\d+)[.)]\s', nl)
                        or re.match(r'^(\*{3,}|-{3,}|_{3,})\s*$', nl.strip())):
                    break
                para_lines.append(nl)
                i += 1
            para_text = '\n'.join(para_lines)
            pt = tk.Text(self._bubble,
                          font=("Microsoft YaHei UI", self._font_size),
                          fg=C["text"], bg=self._bg,
                          wrap="word", padx=8, pady=2, bd=0,
                          highlightthickness=0, height=1,
                          cursor="arrow")
            self._configure_text_tags(pt)
            self._insert_inline(pt, para_text)
            pt.config(state="disabled")
            self._auto_size_text(pt)
            pt.pack(fill="x")
            self._bind_right_click(pt)

    def _add_table(self, rows):
        """渲染 Markdown 表格"""
        if not rows:
            return
        n_cols = max(len(r) for r in rows)
        tf = tk.Frame(self._bubble, bg=C["border"], padx=1, pady=1)
        tf.pack(fill="x", pady=(6, 6), padx=4)
        for ri, row in enumerate(rows):
            while len(row) < n_cols:
                row.append("")
            row_frame = tk.Frame(tf, bg=C["user_bubble"] if ri == 0 else C["bg_chat"])
            row_frame.pack(fill="x")
            for ci, cell in enumerate(row):
                cell_frame = tk.Frame(row_frame,
                                       bg=C["user_bubble"] if ri == 0 else C["bg_chat"],
                                       padx=8, pady=4)
                cell_frame.pack(side="left", fill="x", expand=True)
                font_kw = {}
                if ri == 0:
                    font_kw = {"font": ("Microsoft YaHei UI", self._font_size, "bold")}
                else:
                    font_kw = {"font": ("Microsoft YaHei UI", self._font_size)}
                cl = tk.Label(cell_frame, text=cell.strip(),
                               fg=C["text"],
                               bg=C["user_bubble"] if ri == 0 else C["bg_chat"],
                               anchor="w", wraplength=120, justify="left",
                               **font_kw)
                cl.pack(fill="x")
                self._bind_right_click(cl)
        self._bind_right_click(tf)

    def _add_text_block(self, text):
        """添加普通文本(兼容旧调用,内部走 _add_markdown_block)"""
        self._add_markdown_block(text)

    # ── 语法高亮 ──
    _SYNTAX_RULES = {
        "python": (
            {"def", "class", "import", "from", "return", "if", "elif", "else",
             "for", "while", "try", "except", "finally", "with", "as", "yield",
             "lambda", "pass", "break", "continue", "raise", "del", "global",
             "nonlocal", "assert", "and", "or", "not", "in", "is", "True",
             "False", "None", "self", "async", "await"},
            "#c678dd"
        ),
        "javascript": (
            {"function", "const", "let", "var", "return", "if", "else", "for",
             "while", "do", "switch", "case", "break", "continue", "new", "delete",
             "typeof", "instanceof", "in", "of", "class", "extends", "super",
             "import", "export", "default", "from", "async", "await", "try",
             "catch", "finally", "throw", "yield", "true", "false", "null",
             "undefined", "this", "void"},
            "#c678dd"
        ),
        "js": (
            {"function", "const", "let", "var", "return", "if", "else", "for",
             "while", "do", "switch", "case", "break", "continue", "new", "delete",
             "typeof", "instanceof", "in", "of", "class", "extends", "super",
             "import", "export", "default", "from", "async", "await", "try",
             "catch", "finally", "throw", "yield", "true", "false", "null",
             "undefined", "this", "void"},
            "#c678dd"
        ),
        "json": (
            set(),
            "#c678dd"
        ),
        "html": (
            {"html", "head", "body", "div", "span", "p", "a", "img", "ul", "ol",
             "li", "table", "tr", "td", "th", "form", "input", "button", "script",
             "style", "link", "meta", "title", "header", "footer", "nav", "section",
             "article", "main", "h1", "h2", "h3", "h4", "h5", "h6"},
            "#e06c75"
        ),
        "css": (
            {"color", "background", "margin", "padding", "border", "font", "display",
             "position", "width", "height", "top", "left", "right", "bottom",
             "flex", "grid", "align", "justify", "overflow", "opacity", "z-index",
             "transform", "transition", "animation", "box-shadow", "text-align",
             "line-height", "white-space", "cursor", "outline", "float", "clear",
             "content", "visibility", "max-width", "min-width", "text-decoration"},
            "#e06c75"
        ),
        "sql": (
            {"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE",
             "SET", "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX",
             "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR",
             "NOT", "IN", "IS", "NULL", "LIKE", "BETWEEN", "ORDER", "BY",
             "GROUP", "HAVING", "LIMIT", "OFFSET", "AS", "DISTINCT", "COUNT",
             "SUM", "AVG", "MAX", "MIN", "EXISTS", "UNION", "ALL", "CASE",
             "WHEN", "THEN", "ELSE", "END", "PRIMARY", "KEY", "FOREIGN",
             "REFERENCES", "CONSTRAINT", "DEFAULT", "CHECK", "UNIQUE"},
            "#c678dd"
        ),
        "bash": (
            {"if", "then", "else", "elif", "fi", "for", "while", "do", "done",
             "case", "esac", "in", "function", "return", "exit", "local",
             "export", "source", "alias", "echo", "cd", "ls", "grep", "awk",
             "sed", "cat", "mkdir", "rm", "cp", "mv", "chmod", "chown",
             "sudo", "apt", "yum", "pip", "npm", "true", "false"},
            "#c678dd"
        ),
        "sh": (
            {"if", "then", "else", "elif", "fi", "for", "while", "do", "done",
             "case", "esac", "in", "function", "return", "exit", "local",
             "export", "source", "alias", "echo", "cd", "ls", "grep", "awk",
             "sed", "cat", "mkdir", "rm", "cp", "mv", "chmod", "chown",
             "sudo", "apt", "yum", "pip", "npm", "true", "false"},
            "#c678dd"
        ),
    }

    _STRING_COLOR    = "#98c379"
    _COMMENT_COLOR   = "#5c6370"
    _NUMBER_COLOR    = "#d19a66"
    _DECORATOR_COLOR = "#e5c07b"

    def _apply_syntax_highlight(self, widget, code, lang=""):
        """为代码文本控件应用语法高亮"""
        lang_lower = lang.lower().strip()

        widget.tag_configure("keyword", foreground="#c678dd")
        widget.tag_configure("string",  foreground=self._STRING_COLOR)
        widget.tag_configure("comment", foreground=self._COMMENT_COLOR)
        widget.tag_configure("number",  foreground=self._NUMBER_COLOR)
        widget.tag_configure("decorator", foreground=self._DECORATOR_COLOR)

        if lang_lower in self._SYNTAX_RULES:
            keywords, kw_color = self._SYNTAX_RULES[lang_lower]
            widget.tag_configure("keyword", foreground=kw_color)
            if keywords:
                kw_pattern = r'\b(' + '|'.join(
                    re.escape(k) for k in sorted(keywords, key=len, reverse=True)
                ) + r')\b'
                for m in re.finditer(kw_pattern, code):
                    start = f"1.0+{m.start()}c"
                    end = f"1.0+{m.end()}c"
                    widget.tag_add("keyword", start, end)

        if lang_lower in ("python",):
            for m in re.finditer(r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'', code):
                start = f"1.0+{m.start()}c"
                end = f"1.0+{m.end()}c"
                widget.tag_add("string", start, end)
            for m in re.finditer(r'(?<!")"(?:[^"\\]|\\.)*"(?!")|(?<!\')\'(?:[^\'\\]|\\.)*\'(?!\')', code):
                start = f"1.0+{m.start()}c"
                end = f"1.0+{m.end()}c"
                widget.tag_add("string", start, end)
        elif lang_lower in ("json",):
            for m in re.finditer(r'"(?:[^"\\]|\\.)*"', code):
                start = f"1.0+{m.start()}c"
                end = f"1.0+{m.end()}c"
                widget.tag_add("string", start, end)
        else:
            for m in re.finditer(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'', code):
                start = f"1.0+{m.start()}c"
                end = f"1.0+{m.end()}c"
                widget.tag_add("string", start, end)

        if lang_lower in ("python", "bash", "sh"):
            for m in re.finditer(r'#[^\n]*', code):
                start = f"1.0+{m.start()}c"
                end = f"1.0+{m.end()}c"
                widget.tag_add("comment", start, end)
        elif lang_lower in ("javascript", "js", "css", "json"):
            for m in re.finditer(r'//[^\n]*', code):
                start = f"1.0+{m.start()}c"
                end = f"1.0+{m.end()}c"
                widget.tag_add("comment", start, end)
        elif lang_lower == "html":
            for m in re.finditer(r'<!--[\s\S]*?-->', code):
                start = f"1.0+{m.start()}c"
                end = f"1.0+{m.end()}c"
                widget.tag_add("comment", start, end)
        elif lang_lower == "sql":
            for m in re.finditer(r'--[^\n]*', code):
                start = f"1.0+{m.start()}c"
                end = f"1.0+{m.end()}c"
                widget.tag_add("comment", start, end)

        for m in re.finditer(r'\b\d+\.?\d*\b', code):
            start = f"1.0+{m.start()}c"
            end = f"1.0+{m.end()}c"
            widget.tag_add("number", start, end)

        if lang_lower == "python":
            for m in re.finditer(r'@\w+', code):
                start = f"1.0+{m.start()}c"
                end = f"1.0+{m.end()}c"
                widget.tag_add("decorator", start, end)

        widget.tag_raise("comment")
        widget.tag_raise("string")
        widget.tag_raise("decorator")
        widget.tag_raise("keyword")
        widget.tag_raise("number")

    def _add_code_block(self, code, lang=""):
        """添加代码块(带复制按钮)"""
        code_fs = max(8, self._font_size - 1)
        block = tk.Frame(self._bubble, bg=C["code_bg"],
                          padx=0, pady=0)
        block.pack(fill="x", pady=(6, 6))

        header = tk.Frame(block, bg=C["code_header"])
        header.pack(fill="x")
        lang_text = lang if lang else "代码"
        tk.Label(header, text=lang_text,
                  font=("Consolas", 9),
                  fg=C["text_dim"], bg=C["code_header"],
                  padx=8, pady=3, anchor="w").pack(side="left")

        copy_btn = tk.Label(header, text="📋 复制",
                             font=("Microsoft YaHei UI", 9),
                             fg=C["text_dim"], bg=C["code_header"],
                             cursor="hand2", padx=8, pady=3)
        copy_btn.pack(side="right")
        copy_btn.bind("<Enter>",
                       lambda e: copy_btn.config(fg=C["accent"]))
        copy_btn.bind("<Leave>",
                       lambda e: copy_btn.config(fg=C["text_dim"]))

        _code = code
        def _do_copy(e, c=_code):
            self.clipboard_clear()
            self.clipboard_append(c)
            copy_btn.config(text="✓ 已复制", fg=C["green"])
            self.after(1500, lambda: copy_btn.config(
                text="📋 复制", fg=C["text_dim"]))
        copy_btn.bind("<Button-1>", _do_copy)

        n_lines = code.count('\n') + 1
        code_text = tk.Text(block,
                             font=("Consolas", code_fs),
                             fg=C["text"], bg=C["code_bg"],
                             insertbackground=C["text"],
                             relief="flat", wrap="none",
                             padx=12, pady=8, bd=0,
                             height=n_lines,
                             highlightthickness=0,
                             selectbackground=C["accent"],
                             selectforeground=C["btn_send_fg"])
        code_text.pack(fill="x")
        code_text.insert("1.0", code)
        if lang:
            try:
                self._apply_syntax_highlight(code_text, code, lang)
            except Exception:
                pass
        code_text.config(state="disabled")
        self._auto_size_text(code_text, max_chars=70)

        self._bind_right_click(code_text)
        self._bind_right_click(block)
        self._bind_right_click(header)

prompt_templates.json 系统提示词模板

复制代码
[
  {"name": "代码助手", "prompt": "你是一个专业的编程助手,请用中文回答问题,给出可运行的代码示例,并解释代码逻辑。"},
  {"name": "翻译官", "prompt": "你是一个专业的翻译官,请将输入的文本准确翻译,保持原文的语气和风格。如未指定方向,默认翻译为中文。"},
  {"name": "写作助手", "prompt": "你是一个创意写作助手,擅长各种文体的写作。请用优美流畅的中文进行创作,注意文字的节奏和韵律。"},
  {"name": "知识问答", "prompt": "你是一个知识渊博的助手,请准确、全面地回答问题。如果不确定,请诚实说明。"},
  {"name": "数据分析", "prompt": "你是一个数据分析专家,擅长数据处理、统计分析和可视化。请提供清晰的分析思路和可执行的代码。"}
]

ollama_client.py LLM客户端

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Ollama 客户端:流式对话、模型列表、模型删除"""
import requests
import json
from config import OLLAMA_BASE_URL, DEFAULT_MODEL


class OllamaClient:
    def __init__(self, base_url=OLLAMA_BASE_URL,
                 api_key="", api_key_mode="bearer",
                 custom_header_name="", temperature=0.7, top_p=0.9):
        self.base_url   = base_url.rstrip("/")
        self.api_key    = api_key.strip()
        self.api_key_mode  = api_key_mode.strip().lower()
        self.custom_header_name = custom_header_name.strip()
        self.temperature = temperature
        self.top_p       = top_p

    def _make_headers(self):
        """生成请求头(含 API Key 如果需要)"""
        headers = {"Content-Type": "application/json"}
        if self.api_key:
            if self.api_key_mode == "bearer":
                headers["Authorization"] = f"Bearer {self.api_key}"
            else:
                hname = self.custom_header_name or "X-Api-Key"
                headers[hname] = self.api_key
        return headers

    def chat_stream(self, messages, model=DEFAULT_MODEL):
        url     = f"{self.base_url}/api/chat"
        payload = {
            "model": model,
            "messages": messages,
            "stream": True,
            "options": {
                "temperature": self.temperature,
                "top_p": self.top_p
            }
        }
        headers = self._make_headers()
        try:
            resp = requests.post(url, json=payload, headers=headers,
                                 stream=True, timeout=300)
            resp.raise_for_status()
            for line in resp.iter_lines():
                if line:
                    try:
                        data = json.loads(line)
                    except json.JSONDecodeError:
                        continue
                    token = data.get("message", {}).get("content", "")
                    if token:
                        yield token
                    if data.get("done", False):
                        break
        except requests.exceptions.ConnectionError:
            yield "\n\n[错误] 无法连接到Ollama,请确认Ollama正在运行"
        except requests.exceptions.HTTPError as e:
            if resp.status_code in (401, 403):
                yield f"\n\n[错误] API Key 认证失败({resp.status_code}),请检查 config.ini 中的 api_key 配置"
            else:
                yield f"\n\n[错误] HTTP {resp.status_code}: {str(e)}"
        except Exception as e:
            yield f"\n\n[错误] {type(e).__name__}: {str(e)}"

    def list_models(self):
        try:
            headers = self._make_headers()
            resp = requests.get(f"{self.base_url}/api/tags",
                                headers=headers, timeout=5)
            resp.raise_for_status()
            data   = resp.json()
            models = [m["name"] for m in data.get("models", [])]
            return models if models else [DEFAULT_MODEL]
        except Exception:
            return [DEFAULT_MODEL]

    def list_models_detail(self):
        """返回模型详情列表 [(name, size_str, modified), ...]"""
        try:
            headers = self._make_headers()
            resp = requests.get(f"{self.base_url}/api/tags",
                                headers=headers, timeout=5)
            resp.raise_for_status()
            data = resp.json()
            result = []
            for m in data.get("models", []):
                name = m.get("name", "")
                size_bytes = m.get("size", 0)
                if size_bytes >= 1_000_000_000:
                    size_str = f"{size_bytes / 1_000_000_000:.1f} GB"
                elif size_bytes >= 1_000_000:
                    size_str = f"{size_bytes / 1_000_000:.0f} MB"
                else:
                    size_str = f"{size_bytes / 1_000:.0f} KB"
                modified = m.get("modified_at", "")[:10]
                result.append((name, size_str, modified))
            return result
        except Exception:
            return []

    def delete_model(self, model_name):
        """删除本地模型"""
        url = f"{self.base_url}/api/delete"
        payload = {"name": model_name}
        headers = self._make_headers()
        try:
            resp = requests.delete(url, json=payload, headers=headers,
                                    timeout=10)
            resp.raise_for_status()
            return True, "已删除"
        except requests.exceptions.HTTPError as e:
            return False, f"删除失败: {e}"
        except Exception as e:
            return False, str(e)

dialogs.py 弹窗对话

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""对话框模块:ModelManagerDialog + SystemPromptDialog"""
import tkinter as tk
from tkinter import messagebox
import json
import os
import threading
from config import C, APP_DIR, DEFAULT_SYSTEM_PROMPT


# ── 模型管理对话框 ────────────────────────────────────────────
class ModelManagerDialog(tk.Toplevel):
    """模型管理:查看/切换/删除模型"""

    def __init__(self, parent, ollama_client, current_model="", on_switch=None, on_done=None):
        super().__init__(parent)
        self.title("模型管理")
        self.geometry("480x400")
        self.configure(bg=C["bg"])
        self.resizable(True, True)
        self.minsize(380, 280)
        self.ollama = ollama_client
        self.current_model = current_model
        self.on_switch = on_switch
        self.on_done = on_done

        self.transient(parent)
        self.attributes("-topmost", True)
        if parent.winfo_ismapped():
            px = parent.winfo_x() + parent.winfo_width() // 2
            py = parent.winfo_y() + parent.winfo_height() // 2
            self.geometry(f"480x400+{px-240}+{py-200}")

        # ── 标题 ──
        hdr = tk.Frame(self, bg=C["bg"])
        hdr.pack(fill="x", padx=16, pady=(12, 4))
        tk.Label(hdr, text="📦 模型管理",
                  font=("Microsoft YaHei UI", 13, "bold"),
                  fg=C["accent"], bg=C["bg"]).pack(side="left")

        tk.Label(self, text="点击模型名称可切换为当前模型,当前使用模型标 ★",
                  font=("Microsoft YaHei UI", 8),
                  fg=C["text_dim"], bg=C["bg"]).pack(anchor="w", padx=16)

        self.progress_label = tk.Label(self, text="",
                                        font=("Microsoft YaHei UI", 9),
                                        fg=C["green"], bg=C["bg"])
        self.progress_label.pack(fill="x", padx=16)

        # ── 已安装模型列表 ──
        list_frame = tk.Frame(self, bg=C["bg"])
        list_frame.pack(fill="both", expand=True, padx=16, pady=(6, 12))

        self.list_canvas = tk.Canvas(list_frame, bg=C["bg_chat"],
                                      highlightthickness=0)
        scrollbar = tk.Scrollbar(list_frame, command=self.list_canvas.yview)
        self.list_inner = tk.Frame(self.list_canvas, bg=C["bg_chat"])
        self.list_inner.bind("<Configure>",
            lambda e: self.list_canvas.config(scrollregion=self.list_canvas.bbox("all")))
        self._list_win = self.list_canvas.create_window((0, 0), window=self.list_inner, anchor="nw")
        self.list_canvas.config(yscrollcommand=scrollbar.set)
        self.list_canvas.bind("<Configure>",
            lambda e: self.list_canvas.itemconfig(self._list_win, width=e.width))
        scrollbar.pack(side="right", fill="y")
        self.list_canvas.pack(side="left", fill="both", expand=True)
        self.list_canvas.bind("<Enter>", self._wheel_on)
        self.list_canvas.bind("<Leave>", self._wheel_off)

        # 底部按钮
        btn_frame = tk.Frame(self, bg=C["bg"])
        btn_frame.pack(fill="x", padx=16, pady=(0, 12))
        self.del_btn = tk.Button(btn_frame, text="🗑 删除选中",
                                  font=("Microsoft YaHei UI", 10),
                                  fg=C["text"], bg=C["input_bg"],
                                  bd=0, padx=16, pady=5, cursor="hand2",
                                  command=self._delete_selected)
        self.del_btn.pack(side="left")
        tk.Button(btn_frame, text="🔄 刷新",
                   font=("Microsoft YaHei UI", 10),
                   fg=C["text"], bg=C["input_bg"],
                   bd=0, padx=16, pady=5, cursor="hand2",
                   command=self._refresh).pack(side="left", padx=(8, 0))
        tk.Button(btn_frame, text="关闭",
                   font=("Microsoft YaHei UI", 10),
                   fg=C["text_dim"], bg=C["input_bg"],
                   bd=0, padx=16, pady=5, cursor="hand2",
                   command=self.destroy).pack(side="right")

        self._models_detail = []
        self._selected_model = ""
        self._refresh()

    def _wheel_on(self, e):
        self.list_canvas.bind_all("<MouseWheel>",
            lambda ev: self.list_canvas.yview_scroll(int(-1 * (ev.delta / 120)), "units"))
        self.list_canvas.bind_all("<Button-4>",
            lambda ev: self.list_canvas.yview_scroll(-1, "units"))
        self.list_canvas.bind_all("<Button-5>",
            lambda ev: self.list_canvas.yview_scroll(1, "units"))

    def _wheel_off(self, e):
        self.list_canvas.unbind_all("<MouseWheel>")
        self.list_canvas.unbind_all("<Button-4>")
        self.list_canvas.unbind_all("<Button-5>")

    def _refresh(self):
        """刷新已安装模型列表"""
        for w in self.list_inner.winfo_children():
            w.destroy()
        tk.Label(self.list_inner, text="  加载中...",
                  font=("Microsoft YaHei UI", 9),
                  fg=C["text_dim"], bg=C["bg_chat"]).pack(anchor="w", pady=8)

        def _load():
            details = self.ollama.list_models_detail()
            try:
                self.after(0, lambda: self._update_list(details))
            except Exception:
                pass

        threading.Thread(target=_load, daemon=True).start()

    def _update_list(self, details):
        try:
            self._models_detail = details
            for w in self.list_inner.winfo_children():
                w.destroy()
        except tk.TclError:
            return

        if not details:
            tk.Label(self.list_inner, text="  (无法获取模型列表,请确认Ollama正在运行)",
                      font=("Microsoft YaHei UI", 9),
                      fg=C["text_dim"], bg=C["bg_chat"]).pack(anchor="w", pady=8)
            return

        for name, size, modified in details:
            is_current = (name == self.current_model)
            row = tk.Frame(self.list_inner, bg=C["bg_chat"], cursor="hand2",
                           padx=8, pady=4)
            row.pack(fill="x", pady=1)

            star = "★ " if is_current else "  "
            name_lbl = tk.Label(row, text=f"{star}{name}",
                                 font=("Microsoft YaHei UI", 10,
                                       "bold" if is_current else "normal"),
                                 fg=C["accent"] if is_current else C["text"],
                                 bg=C["bg_chat"], anchor="w")
            name_lbl.pack(side="left")

            tk.Label(row, text=size,
                      font=("Microsoft YaHei UI", 9),
                      fg=C["text_dim"], bg=C["bg_chat"]).pack(side="left", padx=(12, 4))

            tk.Label(row, text=modified,
                      font=("Microsoft YaHei UI", 8),
                      fg=C["text_dim"], bg=C["bg_chat"]).pack(side="left")

            btn_area = tk.Frame(row, bg=C["bg_chat"])
            btn_area.pack(side="right")

            if not is_current:
                switch_lbl = tk.Label(btn_area, text="切换",
                                       font=("Microsoft YaHei UI", 9),
                                       fg=C["accent"], bg=C["bg_chat"],
                                       cursor="hand2", padx=6)
                switch_lbl.pack(side="left")
                switch_lbl.bind("<Button-1>",
                    lambda e, n=name: self._switch_model(n))
                for w in (row, name_lbl):
                    w.bind("<Button-1>",
                        lambda e, n=name: self._switch_model(n))

            del_lbl = tk.Label(btn_area, text="✕",
                                font=("Microsoft YaHei UI", 10),
                                fg=C["text_dim"], bg=C["bg_chat"],
                                cursor="hand2", padx=4)
            del_lbl.pack(side="left")
            del_lbl.bind("<Button-1>",
                lambda e, n=name: self._delete_model(n))
            del_lbl.bind("<Enter>",
                lambda e, l=del_lbl: l.config(fg=C["red"]))
            del_lbl.bind("<Leave>",
                lambda e, l=del_lbl: l.config(fg=C["text_dim"]))

    def _switch_model(self, model_name):
        self.current_model = model_name
        if self.on_switch:
            self.on_switch(model_name)
        self._update_list(self._models_detail)
        self.progress_label.config(text=f"✅ 已切换到 {model_name}", fg=C["green"])

    def _delete_model(self, model_name):
        if model_name == self.current_model:
            messagebox.showwarning("提示", "不能删除当前正在使用的模型")
            return
        if not messagebox.askyesno("删除模型",
                                    f"确定删除模型「{model_name}」吗?\n这将释放磁盘空间。"):
            return

        self.progress_label.config(text=f"正在删除 {model_name}...", fg=C["accent"])

        def _do_del():
            ok, msg = self.ollama.delete_model(model_name)
            self.after(0, lambda: self._del_done(ok, msg, model_name))

        threading.Thread(target=_do_del, daemon=True).start()

    def _delete_selected(self):
        if self._selected_model:
            self._delete_model(self._selected_model)
        else:
            messagebox.showinfo("提示", "请点击模型右侧的 ✕ 按钮删除")

    def _del_done(self, ok, msg, name):
        if ok:
            self.progress_label.config(text=f"✅ {name} {msg}", fg=C["green"])
            self._refresh()
            if self.on_done:
                self.on_done()
        else:
            self.progress_label.config(text=f"❌ {name}: {msg}", fg=C["red"])


# ── 系统提示词对话框 ───────────────────────────────────────────
class SystemPromptDialog(tk.Toplevel):
    """系统提示词编辑器 - 手动保存"""

    def __init__(self, parent, current_prompt, on_save, conv_title=""):
        super().__init__(parent)
        self.title("系统提示词设置")
        self.geometry("640x460")
        self.configure(bg=C["bg"])
        self.resizable(True, True)
        self.minsize(400, 300)
        self.on_save = on_save
        self._saved_prompt = current_prompt

        self.transient(parent)

        self.attributes("-topmost", True)
        if parent.winfo_ismapped():
            px = parent.winfo_x() + parent.winfo_width() // 2
            py = parent.winfo_y() + parent.winfo_height() // 2
            self.geometry(f"640x460+{px-320}+{py-230}")
        else:
            self.update_idletasks()
            w = self.winfo_width()
            h = self.winfo_height()
            sw = self.winfo_screenwidth()
            sh = self.winfo_screenheight()
            self.geometry(f"+{(sw-w)//2}+{(sh-h)//2}")

        # ── 标题区 ──
        hdr = tk.Frame(self, bg=C["bg"])
        hdr.pack(fill="x", padx=16, pady=(12, 4))

        tk.Label(hdr, text="⚙ 系统提示词",
                  font=("Microsoft YaHei UI", 13, "bold"),
                  fg=C["yellow"], bg=C["bg"]).pack(side="left")

        if conv_title:
            tk.Label(hdr,
                      text=f"「{conv_title}」",
                      font=("Microsoft YaHei UI", 10),
                      fg=C["accent"], bg=C["bg"]).pack(side="left",
                                                        padx=(8, 0))

        tk.Label(self,
                  text="设置AI的角色和行为规则,对当前对话生效",
                  font=("Microsoft YaHei UI", 8),
                  fg=C["text_dim"], bg=C["bg"]).pack(
            anchor="w", padx=16)

        # ── 按钮区 ──
        btn_frame = tk.Frame(self, bg=C["bg"])
        btn_frame.pack(fill="x", side="bottom", padx=16, pady=(0, 12))

        self.save_btn = tk.Button(btn_frame, text="✓ 保存",
                                   font=("Microsoft YaHei UI", 11, "bold"),
                                   fg="#ffffff", bg=C["accent"],
                                   activebackground="#74a7f3",
                                   activeforeground="#ffffff",
                                   bd=0, padx=24, pady=7,
                                   cursor="hand2",
                                   command=self._save)
        self.save_btn.pack(side="right")

        close_btn = tk.Button(btn_frame, text="关闭",
                               font=("Microsoft YaHei UI", 10),
                               fg=C["text_dim"], bg=C["input_bg"],
                               activebackground=C["border"],
                               bd=0, padx=18, pady=7,
                               cursor="hand2",
                               command=self._close)
        close_btn.pack(side="right", padx=(8, 0))

        self.unsaved_label = tk.Label(btn_frame, text="",
                                       font=("Microsoft YaHei UI", 9),
                                       fg=C["red"], bg=C["bg"])
        self.unsaved_label.pack(side="left")

        reset_btn = tk.Button(btn_frame, text="↺ 恢复默认",
                              font=("Microsoft YaHei UI", 9),
                              fg=C["text_dim"], bg=C["bg"],
                              activebackground=C["border"],
                              bd=0, padx=10, pady=5,
                              cursor="hand2",
                              command=self._reset)
        reset_btn.pack(side="left", padx=(8, 0))

        # ── 文本编辑区 ──
        text_frame = tk.Frame(self, bg=C["input_bg"])
        text_frame.pack(fill="both", expand=True, padx=16,
                        pady=(6, 8))

        # ── 模板选择行 ──
        tpl_row = tk.Frame(text_frame, bg=C["input_bg"])
        tpl_row.pack(fill="x", pady=(0, 4))

        tk.Label(tpl_row, text="模板:",
                  font=("Microsoft YaHei UI", 9),
                  fg=C["text_dim"], bg=C["input_bg"]).pack(side="left")

        self._templates = self._load_templates()
        tpl_names = ["(选择模板)"] + [t["name"] for t in self._templates]
        self._tpl_var = tk.StringVar(value=tpl_names[0])
        self._tpl_menu = tk.OptionMenu(tpl_row, self._tpl_var, *tpl_names,
                                         command=self._on_template_select)
        self._tpl_menu.config(font=("Microsoft YaHei UI", 9),
                               fg=C["text"], bg=C["bg"],
                               activebackground=C["accent"],
                               activeforeground=C["btn_send_fg"],
                               highlightthickness=0, bd=0,
                               relief="flat", padx=6, pady=2)
        self._tpl_menu["menu"].config(font=("Microsoft YaHei UI", 9),
                                       fg=C["text"], bg=C["input_bg"],
                                       activebackground=C["accent"],
                                       activeforeground=C["btn_send_fg"])
        self._tpl_menu.pack(side="left", padx=(0, 8))

        save_tpl_btn = tk.Label(tpl_row, text="+ 保存为模板",
                                 font=("Microsoft YaHei UI", 8),
                                 fg=C["accent"], bg=C["input_bg"],
                                 cursor="hand2")
        save_tpl_btn.pack(side="left", padx=(0, 8))
        save_tpl_btn.bind("<Button-1>", lambda e: self._save_as_template())
        save_tpl_btn.bind("<Enter>",
                           lambda e: save_tpl_btn.config(fg=C["green"]))
        save_tpl_btn.bind("<Leave>",
                           lambda e: save_tpl_btn.config(fg=C["accent"]))

        del_tpl_btn = tk.Label(tpl_row, text="✕ 删除模板",
                                font=("Microsoft YaHei UI", 8),
                                fg=C["text_dim"], bg=C["input_bg"],
                                cursor="hand2")
        del_tpl_btn.pack(side="left")
        del_tpl_btn.bind("<Button-1>", lambda e: self._delete_template())
        del_tpl_btn.bind("<Enter>",
                          lambda e: del_tpl_btn.config(fg=C["red"]))
        del_tpl_btn.bind("<Leave>",
                          lambda e: del_tpl_btn.config(fg=C["text_dim"]))

        self.text = tk.Text(text_frame,
                            font=("Microsoft YaHei UI", 11),
                            fg=C["text"], bg=C["input_bg"],
                            insertbackground=C["text"],
                            relief="solid", wrap="word",
                            padx=14, pady=12, bd=1,
                            highlightthickness=1,
                            highlightbackground=C["border"],
                            selectbackground=C["accent"],
                            selectforeground=C["btn_send_fg"])
        self.text.pack(fill="both", expand=True)
        self.text.insert("1.0", current_prompt)

        self.text.bind("<KeyRelease>", self._mark_unsaved)

        def _on_focus_in(_e):
            self.attributes("-topmost", False)
        self.bind("<FocusIn>", _on_focus_in)

        self.bind("<Escape>", lambda e: self._close())
        self.text.focus_set()
        self.grab_set()
        self.protocol("WM_DELETE_WINDOW", self._close)

    def _mark_unsaved(self, _event=None):
        current = self.text.get("1.0", "end-1c").strip()
        if current != self._saved_prompt:
            self.unsaved_label.config(text="● 未保存")
            self.save_btn.config(bg=C["green"])
        else:
            self.unsaved_label.config(text="")
            self.save_btn.config(bg=C["accent"])

    def _save(self):
        prompt = self.text.get("1.0", "end-1c").strip()
        self.on_save(prompt)
        self._saved_prompt = prompt
        self.unsaved_label.config(text="✓ 已保存", fg=C["green"])
        self.save_btn.config(bg=C["accent"])
        self.after(2000, lambda: self.unsaved_label.config(text=""))

    def _close(self):
        current = self.text.get("1.0", "end-1c").strip()
        if current != self._saved_prompt:
            if messagebox.askyesno("未保存",
                                    "提示词有修改未保存,是否保存?"):
                self._save()
        self.destroy()

    def _reset(self):
        self.text.delete("1.0", "end")
        self.text.insert("1.0", DEFAULT_SYSTEM_PROMPT)
        self._mark_unsaved()

    # ── 模板管理 ──
    @staticmethod
    def _load_templates():
        path = os.path.join(APP_DIR, "prompt_templates.json")
        if not os.path.exists(path):
            return []
        try:
            with open(path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return []

    @staticmethod
    def _save_templates(templates):
        path = os.path.join(APP_DIR, "prompt_templates.json")
        with open(path, "w", encoding="utf-8") as f:
            json.dump(templates, f, ensure_ascii=False, indent=2)

    def _on_template_select(self, name):
        if name == "(选择模板)":
            return
        for t in self._templates:
            if t["name"] == name:
                self.text.delete("1.0", "end")
                self.text.insert("1.0", t["prompt"])
                self._mark_unsaved()
                break

    def _save_as_template(self):
        prompt = self.text.get("1.0", "end-1c").strip()
        if not prompt:
            return
        dlg = tk.Toplevel(self)
        dlg.title("保存为模板")
        dlg.geometry("320x120")
        dlg.configure(bg=C["bg"])
        dlg.transient(self)
        dlg.grab_set()
        dlg.update_idletasks()
        x = self.winfo_x() + self.winfo_width() // 2 - 160
        y = self.winfo_y() + self.winfo_height() // 2 - 60
        dlg.geometry(f"+{x}+{y}")

        tk.Label(dlg, text="模板名称:",
                  font=("Microsoft YaHei UI", 10),
                  fg=C["text"], bg=C["bg"]).pack(pady=(12, 4))
        name_var = tk.StringVar()
        entry = tk.Entry(dlg, textvariable=name_var,
                          font=("Microsoft YaHei UI", 10),
                          fg=C["text"], bg=C["input_bg"],
                          insertbackground=C["text"], bd=0,
                          highlightthickness=1,
                          highlightbackground=C["accent"])
        entry.pack(padx=20, fill="x")
        entry.focus_set()

        def _confirm(_e=None):
            name = name_var.get().strip()
            if not name:
                return
            for t in self._templates:
                if t["name"] == name:
                    t["prompt"] = prompt
                    break
            else:
                self._templates.append({"name": name, "prompt": prompt})
            self._save_templates(self._templates)
            menu = self._tpl_menu["menu"]
            menu.delete(0, "end")
            tpl_names = ["(选择模板)"] + [t["name"] for t in self._templates]
            for n in tpl_names:
                menu.add_command(label=n,
                                  command=lambda v=n: self._tpl_var.set(v))
            self._tpl_var.set(name)
            dlg.destroy()

        entry.bind("<Return>", _confirm)
        entry.bind("<Escape>", lambda e: dlg.destroy())

    def _delete_template(self):
        name = self._tpl_var.get()
        if name == "(选择模板)":
            return
        self._templates = [t for t in self._templates if t["name"] != name]
        self._save_templates(self._templates)
        menu = self._tpl_menu["menu"]
        menu.delete(0, "end")
        tpl_names = ["(选择模板)"] + [t["name"] for t in self._templates]
        for n in tpl_names:
            menu.add_command(label=n,
                              command=lambda v=n: self._tpl_var.set(v))
        self._tpl_var.set("(选择模板)")

conversation.py 历史对话记录

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""对话数据模型:Conversation 类 + 列表加载"""
import json
import uuid
import os
from datetime import datetime
from config import CONV_DIR, DEFAULT_SYSTEM_PROMPT


class Conversation:
    def __init__(self, conv_id=None, title="新对话", system_prompt=""):
        self.id           = conv_id or str(uuid.uuid4())
        self.title        = title
        self.messages     = []
        self.system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
        self.created_at   = datetime.now().isoformat()
        self.updated_at   = datetime.now().isoformat()
        self.pinned       = False

    def add_message(self, role, content):
        self.messages.append({"role": role, "content": content,
                              "timestamp": datetime.now().strftime("%H:%M")})
        self.updated_at = datetime.now().isoformat()
        # 自动用第一条用户消息做标题
        if role == "user" and self.title == "新对话" and content.strip():
            t = content.strip()[:30]
            self.title = t + ("..." if len(content.strip()) > 30 else "")

    def get_api_messages(self, ctx_len=0):
        """组装发给Ollama的消息列表(含system prompt),ctx_len=0不限制"""
        msgs = []
        if self.system_prompt.strip():
            msgs.append({"role": "system", "content": self.system_prompt})
        if ctx_len > 0 and len(self.messages) > ctx_len:
            raw = self.messages[-ctx_len:]
        else:
            raw = self.messages
        for m in raw:
            role = m["role"]
            content = m["content"]
            # 对话中间的 system 消息(技能结果等)转为 user 角色
            # 因为 Ollama/大多数模型会忽略中间的 system 消息
            if role == "system":
                role = "user"
                content = f"[系统消息] {content}"
            msgs.append({"role": role, "content": content})
        return msgs

    def save(self):
        path = os.path.join(CONV_DIR, f"{self.id}.json")
        data = {
            "id":            self.id,
            "title":         self.title,
            "messages":      self.messages,
            "system_prompt":  self.system_prompt,
            "created_at":    self.created_at,
            "updated_at":    self.updated_at,
            "pinned":        self.pinned,
        }
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)

    @classmethod
    def load_from_file(cls, conv_id):
        path = os.path.join(CONV_DIR, f"{conv_id}.json")
        if not os.path.exists(path):
            return None
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        conv = cls(
            conv_id       = data["id"],
            title         = data.get("title", "新对话"),
            system_prompt = data.get("system_prompt", DEFAULT_SYSTEM_PROMPT),
        )
        conv.messages   = data.get("messages", [])
        conv.created_at = data.get("created_at", conv.created_at)
        conv.updated_at = data.get("updated_at", conv.updated_at)
        conv.pinned    = data.get("pinned", False)
        return conv

    def delete_file(self):
        path = os.path.join(CONV_DIR, f"{self.id}.json")
        if os.path.exists(path):
            os.remove(path)


def list_all_conversations():
    """列出所有对话,置顶在前,其余按 updated_at 降序"""
    if not os.path.exists(CONV_DIR):
        return []
    convs = []
    for fname in os.listdir(CONV_DIR):
        if fname.endswith(".json"):
            conv_id = fname[:-5]
            conv = Conversation.load_from_file(conv_id)
            if conv:
                convs.append(conv)
    # 稳定排序:先按 updated_at 降序,再置顶浮上来
    convs.sort(key=lambda c: c.updated_at, reverse=True)
    convs.sort(key=lambda c: not c.pinned)
    return convs

config.py 配置加载

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""配置模块:常量、配色、config.ini 加载"""
import os

# ── 基础常量 ──
OLLAMA_BASE_URL     = "http://localhost:11434"
DEFAULT_MODEL       = "qwen2.5:1.5b"
APP_DIR             = os.path.dirname(os.path.abspath(__file__))
CONV_DIR            = os.path.join(APP_DIR, "conversations")
DEFAULT_SYSTEM_PROMPT = "你是一个有用的AI助手,请用中文回答问题。"
os.makedirs(CONV_DIR, exist_ok=True)


# ── 从 config.ini 读取配置 ──
def _load_config():
    """从 config.ini 读取配置"""
    import configparser
    cfg = configparser.ConfigParser()
    cfg_path = os.path.join(APP_DIR, "config.ini")
    if os.path.exists(cfg_path):
        cfg.read(cfg_path, encoding="utf-8")
    base_url = cfg.get("ollama", "base_url", fallback=OLLAMA_BASE_URL)
    model    = cfg.get("ollama", "default_model", fallback=DEFAULT_MODEL)
    api_key  = cfg.get("ollama", "api_key", fallback="")
    api_key_mode = cfg.get("ollama", "api_key_mode", fallback="bearer")
    custom_header_name = cfg.get("ollama", "custom_header_name",
                                  fallback="X-Api-Key")
    temperature = cfg.getfloat("ollama", "temperature", fallback=0.7)
    top_p       = cfg.getfloat("ollama", "top_p", fallback=0.9)
    font_size   = cfg.getint("ollama", "font_size", fallback=11)
    ctx_len     = cfg.getint("ollama", "context_length", fallback=0)
    return base_url, model, api_key, api_key_mode, custom_header_name, temperature, top_p, font_size, ctx_len


_CFG_BASE_URL, _CFG_MODEL, _CFG_API_KEY, _CFG_API_MODE, _CFG_CUSTOM_HEADER, _CFG_TEMP, _CFG_TOP_P, _CFG_FONT_SIZE, _CFG_CTX_LEN = _load_config()
OLLAMA_BASE_URL = _CFG_BASE_URL
DEFAULT_MODEL   = _CFG_MODEL

# ── 配色 (Catppuccin Mocha) ──
C = {
    "bg":            "#1e1e2e",
    "bg_chat":       "#181825",
    "bg_topbar":     "#1e1e2e",
    "bg_sidebar":    "#11111b",
    "sidebar_item":   "#181825",
    "sidebar_hover":  "#313244",
    "sidebar_active":  "#313244",
    "user_bubble":   "#313244",
    "ai_bubble":     "#252540",
    "text":          "#cdd6f4",
    "text_dim":      "#6c7086",
    "accent":        "#89b4fa",
    "green":         "#a6e3a1",
    "red":           "#f38ba8",
    "input_bg":      "#313244",
    "btn_send":      "#89b4fa",
    "btn_send_fg":   "#1e1e2e",
    "stop_btn":      "#f38ba8",
    "border":        "#45475a",
    "yellow":        "#f9e2af",
    "code_bg":       "#1a1a2e",
    "code_header":   "#16162a",
}

config.ini

复制代码
[ollama]
base_url = http://localhost:11434
default_model = qwen2.5:1.5b
api_key = 
api_key_mode = bearer
custom_header_name = X-Api-Key
temperature = 0.0
top_p = 0.7
font_size = 11
context_length = 0
model = qwen2.5:1.5b

[ui]
window_width = 920
window_height = 720
bubble_wraplength = 500

app.py 主应用绑定ui

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""主应用模块:ChatApp 类(UI 构建、对话管理、消息发送/流式接收)"""
import tkinter as tk
from tkinter import messagebox, filedialog
import threading
import os
import re
import configparser
from datetime import datetime

from config import (C, OLLAMA_BASE_URL, _CFG_API_KEY, _CFG_API_MODE,
                    _CFG_CUSTOM_HEADER, _CFG_TEMP, _CFG_TOP_P,
                    _CFG_FONT_SIZE, _CFG_CTX_LEN, DEFAULT_MODEL,
                    DEFAULT_SYSTEM_PROMPT, APP_DIR)
from ollama_client import OllamaClient
from conversation import Conversation, list_all_conversations
from widgets import ScrollFrame, ChatBubble
from dialogs import ModelManagerDialog, SystemPromptDialog
import skills


# ── 主应用 ─────────────────────────────────────────────────────
class ChatApp:
    def __init__(self, root):
        self.root = root
        self.root.title("AI Agent")
        self.root.geometry("1060x720")
        self.root.minsize(820, 620)
        self.root.configure(bg=C["bg"])

        self.ollama = OllamaClient(
            base_url=OLLAMA_BASE_URL,
            api_key=_CFG_API_KEY,
            api_key_mode=_CFG_API_MODE,
            custom_header_name=_CFG_CUSTOM_HEADER,
            temperature=_CFG_TEMP,
            top_p=_CFG_TOP_P,
        )
        self.streaming       = False
        self.current_bubble  = None
        self.stop_flag       = False
        self.stream_conv_id  = None   # 当前流式对话的ID(防止切换对话后写错)

        # 对话管理
        self.conversations  = {}      # id -> Conversation
        self.current_conv_id = None
        self.conv_items     = {}      # id -> 侧边栏Frame控件
        self.sidebar_visible = True
        self._font_size     = _CFG_FONT_SIZE  # 聊天区字体大小
        self._ctx_len       = _CFG_CTX_LEN    # 上下文消息条数限制,0=不限制
        self._edit_index   = None      # 正在编辑的消息索引
        self._tray_icon    = None      # 系统托盘图标

        self._build_ui()
        self._load_models()
        self._load_skills()      # 加载技能
        self._load_conversations()

        if not self.conversations:
            self._new_chat()
        self.input_text.focus_set()

        # 系统托盘最小化
        self._setup_tray()

    # ── 构建 UI ────────────────────────────────────────────────
    def _build_ui(self):
        # 整体横排:侧边栏 | 右侧面板
        self.main_frame = tk.Frame(self.root, bg=C["bg"])
        self.main_frame.pack(fill="both", expand=True)

        self._build_sidebar()

        self.right_panel = tk.Frame(self.main_frame, bg=C["bg"])
        self.right_panel.pack(side="left", fill="both", expand=True)

        self._build_topbar()
        self.chat_frame = ScrollFrame(self.right_panel)
        self.chat_frame.pack(fill="both", expand=True, side="top")
        self._build_input()

    def _setup_tray(self):
        """设置系统托盘:关闭窗口时最小化到托盘(纯 ctypes 实现,无需额外依赖)"""
        import ctypes
        from ctypes import wintypes

        self._tray_active = False
        self._tray_hwnd = None
        self._tray_msg_id = None
        self._tray_hicon = None

        try:
            user32 = ctypes.windll.user32
            # 注册自定义窗口消息
            self._tray_msg_id = user32.RegisterWindowMessageW("AI_AGENT_TRAY_MSG")
            if not self._tray_msg_id:
                self.root.protocol("WM_DELETE_WINDOW", self._on_close)
                return

            hinstance = ctypes.windll.kernel32.GetModuleHandleW(None)
            hicon = user32.LoadIconW(None, ctypes.c_void_p(32512))  # IDI_APPLICATION
            self._tray_hicon = hicon

            # WNDCLASSEX 结构体
            class WNDCLASSEX(ctypes.Structure):
                _fields_ = [
                    ("cbSize", wintypes.UINT),
                    ("style", wintypes.UINT),
                    ("lpfnWndProc", ctypes.c_void_p),
                    ("cbClsExtra", ctypes.c_int),
                    ("cbWndExtra", ctypes.c_int),
                    ("hInstance", wintypes.HINSTANCE),
                    ("hIcon", wintypes.HANDLE),
                    ("hCursor", wintypes.HANDLE),
                    ("hbrBackground", wintypes.HANDLE),
                    ("lpszMenuName", wintypes.LPCWSTR),
                    ("lpszClassName", wintypes.LPCWSTR),
                    ("hIconSm", wintypes.HANDLE),
                ]

            # 窗口过程回调
            @ctypes.WINFUNCTYPE(ctypes.c_long, wintypes.HWND, wintypes.UINT,
                                wintypes.WPARAM, wintypes.LPARAM)
            def wnd_proc(hwnd, msg, wp, lp):
                if msg == self._tray_msg_id:
                    if lp == 0x0202:  # WM_LBUTTONUP
                        self.root.after(0, self._tray_restore)
                    elif lp == 0x0205:  # WM_RBUTTONUP
                        self.root.after(0, self._tray_show_menu)
                return user32.DefWindowProcW(hwnd, msg, wp, lp)

            class_name = "AI_AGENT_TRAY_WND"
            wc = WNDCLASSEX()
            wc.cbSize = ctypes.sizeof(WNDCLASSEX)
            wc.hInstance = hinstance
            wc.lpszClassName = class_name
            wc.lpfnWndProc = wnd_proc
            wc.hIcon = hicon
            wc.hIconSm = hicon

            user32.RegisterClassExW(ctypes.byref(wc))

            self._tray_hwnd = user32.CreateWindowExW(
                0, class_name, "AI_AGENT_TRAY", 0, 0, 0, 0, 0,
                None, None, hinstance, None)

            # 覆盖窗口关闭行为
            self.root.protocol("WM_DELETE_WINDOW", self._tray_minimize)

        except Exception:
            # 托盘初始化失败,退回"关闭即退出"
            self.root.protocol("WM_DELETE_WINDOW", self._on_close)

    def _tray_add_icon(self):
        """添加托盘图标"""
        if not self._tray_hwnd or self._tray_active:
            return
        import ctypes
        from ctypes import wintypes

        class NOTIFYICONDATA(ctypes.Structure):
            _fields_ = [
                ("cbSize", wintypes.DWORD),
                ("hWnd", wintypes.HWND),
                ("uID", wintypes.UINT),
                ("uFlags", wintypes.UINT),
                ("uCallbackMessage", wintypes.UINT),
                ("hIcon", wintypes.HANDLE),
                ("szTip", ctypes.c_wchar * 128),
                ("dwState", wintypes.DWORD),
                ("dwStateMask", wintypes.DWORD),
                ("szInfo", ctypes.c_wchar * 256),
                ("uVersion", wintypes.UINT),
                ("szInfoTitle", ctypes.c_wchar * 64),
                ("dwInfoFlags", wintypes.DWORD),
            ]

        NIF_MESSAGE = 0x01
        NIF_ICON = 0x02
        NIF_TIP = 0x04

        nid = NOTIFYICONDATA()
        nid.cbSize = ctypes.sizeof(NOTIFYICONDATA)
        nid.hWnd = self._tray_hwnd
        nid.uID = 1
        nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP
        nid.uCallbackMessage = self._tray_msg_id
        nid.hIcon = self._tray_hicon
        nid.szTip = "AI Agent - 双击恢复窗口"

        ctypes.windll.shell32.Shell_NotifyIconW(0x00, ctypes.byref(nid))  # NIM_ADD
        self._tray_active = True

    def _tray_remove_icon(self):
        """移除托盘图标"""
        if not self._tray_active or not self._tray_hwnd:
            return
        import ctypes
        from ctypes import wintypes

        class NOTIFYICONDATA_MIN(ctypes.Structure):
            _fields_ = [
                ("cbSize", wintypes.DWORD),
                ("hWnd", wintypes.HWND),
                ("uID", wintypes.UINT),
            ]

        nid = NOTIFYICONDATA_MIN()
        nid.cbSize = ctypes.sizeof(NOTIFYICONDATA_MIN)
        nid.hWnd = self._tray_hwnd
        nid.uID = 1

        ctypes.windll.shell32.Shell_NotifyIconW(0x02, ctypes.byref(nid))  # NIM_DELETE
        self._tray_active = False

    def _tray_minimize(self):
        """最小化到系统托盘"""
        self.root.withdraw()
        self._tray_add_icon()

    def _tray_restore(self):
        """从系统托盘恢复窗口"""
        self.root.deiconify()
        self.root.lift()
        self.root.focus_force()
        self._tray_remove_icon()

    def _tray_show_menu(self):
        """显示托盘右键菜单"""
        menu = tk.Menu(self.root, tearoff=0,
                        bg=C["input_bg"], fg=C["text"],
                        activebackground=C["accent"],
                        activeforeground=C["btn_send_fg"])
        menu.add_command(label="📌 显示窗口", command=self._tray_restore)
        menu.add_separator()
        menu.add_command(label="❌ 退出", command=self._quit_app)
        # 在鼠标位置显示菜单
        try:
            menu.tk_popup(self.root.winfo_pointerx(),
                           self.root.winfo_pointery())
        finally:
            menu.grab_release()

    def _on_close(self):
        """直接关闭窗口并退出"""
        self._quit_app()

    def _quit_app(self):
        """退出应用"""
        self._tray_remove_icon()
        if self._tray_hwnd:
            try:
                import ctypes
                ctypes.windll.user32.DestroyWindow(self._tray_hwnd)
            except Exception:
                pass
        self.root.destroy()

    def _build_sidebar(self):
        self.sidebar = tk.Frame(self.main_frame,
                                bg=C["bg_sidebar"], width=220)
        self.sidebar.pack(side="left", fill="y")
        self.sidebar.pack_propagate(False)

        # 标题
        hdr = tk.Frame(self.sidebar, bg=C["bg_sidebar"], pady=10)
        hdr.pack(fill="x", padx=10)
        tk.Label(hdr, text="对话列表",
                  font=("Microsoft YaHei UI", 12, "bold"),
                  fg=C["text"], bg=C["bg_sidebar"]).pack(side="left")

        # + 新对话 按钮
        nb = tk.Frame(self.sidebar, bg=C["accent"], cursor="hand2")
        nb.pack(fill="x", padx=10, pady=(0, 6))
        nb_lbl = tk.Label(nb, text="+ 新对话",
                           font=("Microsoft YaHei UI", 10, "bold"),
                           fg=C["btn_send_fg"], bg=C["accent"],
                           pady=6)
        nb_lbl.pack()
        for w in (nb, nb_lbl):
            w.bind("<Button-1>", lambda e: self._new_chat())

        # ⚙ 系统提示词 按钮
        sp = tk.Frame(self.sidebar, bg=C["border"], cursor="hand2")
        sp.pack(fill="x", padx=10, pady=(0, 4))
        sp_lbl = tk.Label(sp, text="⚙ 系统提示词",
                           font=("Microsoft YaHei UI", 9),
                           fg=C["yellow"], bg=C["border"], pady=5)
        sp_lbl.pack()
        for w in (sp, sp_lbl):
            w.bind("<Button-1>", lambda e: self._open_system_prompt())

        # 📤 导出对话 按钮
        ep = tk.Frame(self.sidebar, bg=C["border"], cursor="hand2")
        ep.pack(fill="x", padx=10, pady=(0, 4))
        ep_lbl = tk.Label(ep, text="📤 导出对话",
                           font=("Microsoft YaHei UI", 9),
                           fg=C["green"], bg=C["border"], pady=5)
        ep_lbl.pack()
        for w in (ep, ep_lbl):
            w.bind("<Button-1>", lambda e: self._export_conversation())

        # 📦 模型管理 按钮
        mm = tk.Frame(self.sidebar, bg=C["border"], cursor="hand2")
        mm.pack(fill="x", padx=10, pady=(0, 4))
        mm_lbl = tk.Label(mm, text="📦 模型管理",
                           font=("Microsoft YaHei UI", 9),
                           fg=C["accent"], bg=C["border"], pady=5)
        mm_lbl.pack()
        for w in (mm, mm_lbl):
            w.bind("<Button-1>", lambda e: self._open_model_manager())

        # ── 系统设置栏 ──
        settings_frame = tk.Frame(self.sidebar, bg=C["bg_sidebar"])
        settings_frame.pack(fill="x", padx=10, pady=(0, 4))

        # 标题行(点击可展开/收起)
        self._settings_visible = True
        settings_hdr = tk.Frame(settings_frame, cursor="hand2")
        settings_hdr.pack(fill="x")
        settings_hdr.configure(bg=C["bg_sidebar"])

        self._settings_arrow = tk.Label(
            settings_hdr, text="▼",
            font=("Microsoft YaHei UI", 8),
            fg=C["text_dim"], bg=C["bg_sidebar"])
        self._settings_arrow.pack(side="left", padx=(0, 4))
        tk.Label(settings_hdr, text="系统设置",
                  font=("Microsoft YaHei UI", 9, "bold"),
                  fg=C["text_dim"], bg=C["bg_sidebar"]).pack(side="left")

        # 设置内容区
        self._settings_body = tk.Frame(settings_frame, bg=C["bg_sidebar"])
        self._settings_body.pack(fill="x", pady=(4, 0))

        self.confirm_var = tk.BooleanVar(value=True)
        chk = tk.Checkbutton(
            self._settings_body, text="操作需确认",
            variable=self.confirm_var,
            font=("Microsoft YaHei UI", 9),
            fg=C["text_dim"], bg=C["bg_sidebar"],
            selectcolor=C["bg_sidebar"],
            activebackground=C["bg_sidebar"],
            activeforeground=C["text"],
            anchor="w")
        chk.pack(fill="x", padx=(16, 0))

        # Temperature 滑块
        temp_frame = tk.Frame(self._settings_body, bg=C["bg_sidebar"])
        temp_frame.pack(fill="x", padx=(16, 0), pady=(6, 0))
        self.temp_var = tk.DoubleVar(value=self.ollama.temperature)
        self.temp_label = tk.Label(
            temp_frame, text=f"Temperature: {self.ollama.temperature:.1f}",
            font=("Microsoft YaHei UI", 8),
            fg=C["text_dim"], bg=C["bg_sidebar"], anchor="w")
        self.temp_label.pack(fill="x")
        tk.Scale(
            temp_frame, variable=self.temp_var,
            from_=0.0, to=2.0, resolution=0.1,
            orient="horizontal", showvalue=0, length=160,
            bg=C["bg_sidebar"], fg=C["text_dim"],
            activebackground=C["accent"],
            troughcolor=C["input_bg"],
            highlightthickness=0, bd=0,
            command=lambda v: self._on_temp_change(float(v))
        ).pack(fill="x")

        # Top-P 滑块
        top_p_frame = tk.Frame(self._settings_body, bg=C["bg_sidebar"])
        top_p_frame.pack(fill="x", padx=(16, 0), pady=(6, 0))
        self.top_p_var = tk.DoubleVar(value=self.ollama.top_p)
        self.top_p_label = tk.Label(
            top_p_frame, text=f"Top-P: {self.ollama.top_p:.2f}",
            font=("Microsoft YaHei UI", 8),
            fg=C["text_dim"], bg=C["bg_sidebar"], anchor="w")
        self.top_p_label.pack(fill="x")
        tk.Scale(
            top_p_frame, variable=self.top_p_var,
            from_=0.0, to=1.0, resolution=0.05,
            orient="horizontal", showvalue=0, length=160,
            bg=C["bg_sidebar"], fg=C["text_dim"],
            activebackground=C["accent"],
            troughcolor=C["input_bg"],
            highlightthickness=0, bd=0,
            command=lambda v: self._on_top_p_change(float(v))
        ).pack(fill="x")

        # 字体大小 滑块
        fs_frame = tk.Frame(self._settings_body, bg=C["bg_sidebar"])
        fs_frame.pack(fill="x", padx=(16, 0), pady=(6, 0))
        self.fs_var = tk.IntVar(value=self._font_size)
        self.fs_label = tk.Label(
            fs_frame, text=f"字体大小: {self._font_size}",
            font=("Microsoft YaHei UI", 8),
            fg=C["text_dim"], bg=C["bg_sidebar"], anchor="w")
        self.fs_label.pack(fill="x")
        tk.Scale(
            fs_frame, variable=self.fs_var,
            from_=8, to=20, resolution=1,
            orient="horizontal", showvalue=0, length=160,
            bg=C["bg_sidebar"], fg=C["text_dim"],
            activebackground=C["accent"],
            troughcolor=C["input_bg"],
            highlightthickness=0, bd=0,
            command=lambda v: self._on_font_size_change(int(v))
        ).pack(fill="x")

        # 上下文长度 滑块
        ctx_frame = tk.Frame(self._settings_body, bg=C["bg_sidebar"])
        ctx_frame.pack(fill="x", padx=(16, 0), pady=(6, 0))
        ctx_display = "不限" if self._ctx_len == 0 else str(self._ctx_len)
        self.ctx_var = tk.IntVar(value=self._ctx_len)
        self.ctx_label = tk.Label(
            ctx_frame, text=f"上下文长度: {ctx_display}",
            font=("Microsoft YaHei UI", 8),
            fg=C["text_dim"], bg=C["bg_sidebar"], anchor="w")
        self.ctx_label.pack(fill="x")
        tk.Scale(
            ctx_frame, variable=self.ctx_var,
            from_=0, to=50, resolution=1,
            orient="horizontal", showvalue=0, length=160,
            bg=C["bg_sidebar"], fg=C["text_dim"],
            activebackground=C["accent"],
            troughcolor=C["input_bg"],
            highlightthickness=0, bd=0,
            command=lambda v: self._on_ctx_change(int(v))
        ).pack(fill="x")

        # 点击标题行切换展开/收起
        for w in (settings_hdr, self._settings_arrow):
            w.bind("<Button-1>", lambda e: self._toggle_settings())

        # 分隔线
        tk.Frame(self.sidebar, bg=C["border"], height=1).pack(
            fill="x", padx=10, pady=4)

        # 搜索框
        search_frame = tk.Frame(self.sidebar, bg=C["bg_sidebar"])
        search_frame.pack(fill="x", padx=10, pady=(0, 4))
        self.search_var = tk.StringVar()
        self.search_entry = tk.Entry(
            search_frame, textvariable=self.search_var,
            font=("Microsoft YaHei UI", 9),
            fg=C["text"], bg=C["input_bg"],
            insertbackground=C["text"], bd=0,
            highlightthickness=1, highlightbackground=C["border"])
        self.search_entry.pack(fill="x", ipady=3)
        # placeholder 效果
        self._search_placeholder = True
        self.search_entry.config(fg=C["text_dim"])
        self.search_entry.insert(0, "🔍 搜索对话...")
        self.search_entry.bind("<FocusIn>", self._on_search_focus_in)
        self.search_entry.bind("<FocusOut>", self._on_search_focus_out)
        self.search_var.trace_add("write", lambda *_: self._on_search_change())

        # 可滚动的对话列表
        self.conv_scroll = ScrollFrame(self.sidebar, bg=C["bg_sidebar"])
        self.conv_scroll.pack(fill="both", expand=True)

        # ── 底部统计栏 ──
        self._stats_frame = tk.Frame(self.sidebar, bg=C["bg_sidebar"], pady=4)
        self._stats_frame.pack(fill="x", side="bottom", padx=10)
        self._stats_label = tk.Label(
            self._stats_frame, text="",
            font=("Microsoft YaHei UI", 8),
            fg=C["text_dim"], bg=C["bg_sidebar"],
            anchor="w", justify="left")
        self._stats_label.pack(fill="x")

    def _build_topbar(self):
        bar = tk.Frame(self.right_panel, bg=C["bg_topbar"], height=48)
        bar.pack(fill="x", side="top")
        bar.pack_propagate(False)

        # 侧边栏显隐按钮
        self.toggle_btn = tk.Label(
            bar, text="☰", font=("Arial", 14),
            fg=C["text_dim"], bg=C["bg_topbar"],
            cursor="hand2", padx=8)
        self.toggle_btn.pack(side="left", padx=(8, 0))
        self.toggle_btn.bind("<Button-1>",
                             lambda e: self._toggle_sidebar())

        tk.Label(bar, text="AI Agent",
                  font=("Microsoft YaHei UI", 14, "bold"),
                  fg=C["accent"], bg=C["bg_topbar"]).pack(
            side="left", padx=8)

        # 模型选择器
        mf = tk.Frame(bar, bg=C["bg_topbar"])
        mf.pack(side="left", padx=16)
        tk.Label(mf, text="模型:",
                  font=("Microsoft YaHei UI", 9),
                  fg=C["text_dim"], bg=C["bg_topbar"]).pack(side="left")
        self.model_var = tk.StringVar(value=DEFAULT_MODEL)
        self.model_menu = tk.OptionMenu(mf, self.model_var, DEFAULT_MODEL)
        self.model_menu.config(
            font=("Microsoft YaHei UI", 10),
            fg=C["text"], bg=C["input_bg"],
            activebackground=C["border"],
            activeforeground=C["text"],
            highlightthickness=0, bd=0,
            relief="flat", padx=8, pady=2)
        self.model_menu["menu"].config(
            font=("Microsoft YaHei UI", 10),
            fg=C["text"], bg=C["input_bg"],
            activebackground=C["accent"],
            activeforeground=C["btn_send_fg"])
        self.model_menu.pack(side="left")

        # 右侧:连接状态
        self.status_dot = tk.Label(
            bar, text="●", font=("Arial", 9),
            fg=C["red"], bg=C["bg_topbar"])
        self.status_dot.pack(side="right", padx=(0, 16))
        self.status_label = tk.Label(
            bar, text="未连接",
            font=("Microsoft YaHei UI", 9),
            fg=C["text_dim"], bg=C["bg_topbar"])
        self.status_label.pack(side="right")

        # 清空对话按钮
        self.clear_btn = tk.Label(
            bar, text="🗑 清空", font=("Microsoft YaHei UI", 9),
            fg=C["text_dim"], bg=C["bg_topbar"],
            cursor="hand2", padx=8)
        self.clear_btn.pack(side="right")
        self.clear_btn.bind("<Button-1>",
                             lambda e: self._clear_conversation())
        self.clear_btn.bind("<Enter>",
                             lambda e: self.clear_btn.config(fg=C["red"]))
        self.clear_btn.bind("<Leave>",
                             lambda e: self.clear_btn.config(fg=C["text_dim"]))

    def _build_input(self):
        container = tk.Frame(self.right_panel, bg=C["bg"])
        container.pack(fill="x", side="bottom", padx=16, pady=(0, 16))
        self.input_row = tk.Frame(container, bg=C["input_bg"], padx=3, pady=3)
        self.input_row.pack(fill="x")
        self.input_text = tk.Text(
            self.input_row, font=("Microsoft YaHei UI", 11),
            fg=C["text"], bg=C["input_bg"],
            insertbackground=C["text"],
            relief="flat", height=3, wrap="word",
            padx=12, pady=8, bd=0,
            highlightthickness=0,
            selectbackground=C["accent"],
            selectforeground=C["btn_send_fg"])
        self.input_text.pack(side="left", fill="both", expand=True)
        self.send_btn = tk.Label(
            self.input_row, text="发送 ▸",
            font=("Microsoft YaHei UI", 10, "bold"),
            fg=C["btn_send_fg"], bg=C["btn_send"],
            cursor="hand2", padx=16, pady=8)
        self.send_btn.pack(side="right", padx=(6, 4), pady=4)
        self.send_btn.bind("<Button-1>",
                           lambda e: self._on_send_click())
        tk.Label(container,
                  text="Enter发送 · Shift+Enter换行 · Ctrl+N新建 · Ctrl+↑↓切换",
                  font=("Microsoft YaHei UI", 8),
                  fg=C["text_dim"], bg=C["bg"]).pack(
            anchor="e", pady=(4, 0))
        self.input_text.bind("<Return>",   self._on_enter)
        self.input_text.bind("<KP_Enter>", self._on_enter)
        self.input_text.bind("<KeyRelease>", self._auto_resize_input)

        # ── 全局快捷键 ──
        self.root.bind("<Control-n>", lambda e: self._new_chat())
        self.root.bind("<Control-N>", lambda e: self._new_chat())
        self.root.bind("<Control-e>", lambda e: self._export_conversation())
        self.root.bind("<Control-E>", lambda e: self._export_conversation())
        self.root.bind("<Control-Up>",   lambda e: self._switch_adjacent(-1))
        self.root.bind("<Control-Down>", lambda e: self._switch_adjacent(1))

    # ── 对话管理 ───────────────────────────────────────────────
    def _cur_conv(self):
        cid = self.current_conv_id
        if cid and cid in self.conversations:
            return self.conversations[cid]
        return None

    def _new_chat(self):
        conv = Conversation(system_prompt=DEFAULT_SYSTEM_PROMPT)
        conv.save()
        self.conversations[conv.id] = conv
        self._add_sidebar_item(conv, at_top=True)
        self._switch_to(conv.id)
        self._update_stats()
        self.input_text.focus_set()

    def _switch_to(self, conv_id):
        if conv_id not in self.conversations:
            return
        # 切换对话时取消编辑模式,防止 _edit_index 指向旧对话
        if self._edit_index is not None:
            self._cancel_edit()
        if self.streaming:
            self._stop_stream()
            # 强制重置流式状态,防止 _stream_done 尚未执行导致 input 被 lock
            self.streaming      = False
            self.stop_flag      = False
            self.current_bubble = None
            self.stream_conv_id = None
            self.send_btn.config(text="发送 ▸", bg=C["btn_send"])
            self.input_text.config(state="normal")
        self.current_conv_id = conv_id
        self._highlight_active(conv_id)

        # 清空聊天区,重建消息
        for w in self.chat_frame.inner.winfo_children():
            w.destroy()
        conv = self.conversations[conv_id]
        if not conv.messages:
            self._show_welcome()
        else:
            for idx, msg in enumerate(conv.messages):
                ts = msg.get("timestamp", "")
                # 旧消息没有 timestamp 时,用 created_at 中的时间
                if not ts:
                    ts = conv.created_at[11:16] if len(conv.created_at) > 16 else ""
                ChatBubble(self.chat_frame.inner,
                            role=msg["role"],
                            text=msg["content"],
                            timestamp=ts,
                            streaming=False,
                            font_size=self._font_size,
                            msg_index=idx,
                            on_delete=self._delete_message,
                            on_edit=self._on_edit_message)
        self.chat_frame.scroll_bottom()

    def _highlight_active(self, conv_id):
        for cid, item in self.conv_items.items():
            is_active = cid == conv_id
            bg = C["sidebar_active"] if is_active else C["sidebar_item"]
            item.configure(bg=bg)
            self._set_bg_recursive(item, bg)

    @staticmethod
    def _set_bg_recursive(widget, bg):
        """递归设置控件及其所有子控件的背景色"""
        try:
            widget.configure(bg=bg)
        except tk.TclError:
            pass
        for child in widget.winfo_children():
            ChatApp._set_bg_recursive(child, bg)

    def _add_sidebar_item(self, conv, at_top=False):
        inner = self.conv_scroll.inner

        # 先获取已有子控件(必须在创建 item 之前!)
        existing = list(inner.winfo_children())

        item = tk.Frame(inner, bg=C["sidebar_item"],
                           cursor="hand2", padx=10, pady=8)

        # 新对话插入到列表顶部
        if at_top and existing:
            item.pack(fill="x", pady=(0, 2), padx=4,
                       before=existing[0])
        else:
            item.pack(fill="x", pady=(0, 2), padx=4)

        # 文字区域(左侧)
        text_area = tk.Frame(item, bg=C["sidebar_item"])
        text_area.pack(side="left", fill="both", expand=True)

        # 标题行:置顶标记 + 标题
        title_row = tk.Frame(text_area, bg=C["sidebar_item"])
        title_row.pack(fill="x")

        pin_icon = "📌 " if conv.pinned else ""
        title_lbl = tk.Label(
            title_row, text=f"{pin_icon}{conv.title}",
            font=("Microsoft YaHei UI", 10),
            fg=C["text"], bg=C["sidebar_item"],
            anchor="w", wraplength=140, justify="left")
        title_lbl.pack(side="left", fill="x", expand=True)

        n_msg = sum(1 for m in conv.messages if m["role"] == "user")
        cnt_lbl = tk.Label(
            text_area, text=f"{n_msg} 条消息",
            font=("Microsoft YaHei UI", 8),
            fg=C["text_dim"], bg=C["sidebar_item"],
            anchor="w")
        cnt_lbl.pack(fill="x")

        # 删除按钮(右侧,默认隐藏)
        del_btn = tk.Label(
            item, text="✕",
            font=("Arial", 10),
            fg=C["text_dim"], bg=C["sidebar_item"],
            cursor="hand2", padx=4)
        # 置顶按钮(右侧,默认隐藏)
        pin_btn_text = "📌" if not conv.pinned else "📍"
        pin_btn = tk.Label(
            item, text=pin_btn_text,
            font=("Arial", 10),
            fg=C["text_dim"], bg=C["sidebar_item"],
            cursor="hand2", padx=4)
        # 不 pack,hover 时才显示

        cid = conv.id   # 闭包捕获

        # 区分单击/双击:单击切换对话,双击重命名
        _click_timer = [None]

        def _on_click(e, _cid=cid):
            # 延迟执行单击,如果很快又来了双击则取消
            if _click_timer[0]:
                self.root.after_cancel(_click_timer[0])
                _click_timer[0] = None
            _click_timer[0] = self.root.after(
                250, lambda: self._switch_to(_cid))

        def _on_dblclick(e, _cid=cid):
            # 取消挂起的单击
            if _click_timer[0]:
                self.root.after_cancel(_click_timer[0])
                _click_timer[0] = None
            self._rename_chat(_cid)

        for w in (item, title_lbl, cnt_lbl, text_area, title_row):
            w.bind("<Button-1>", _on_click)
            w.bind("<Double-Button-1>", _on_dblclick)

        def _show_del():
            pin_btn.pack(side="right")
            del_btn.pack(side="right")

        def _hide_del():
            pin_btn.pack_forget()
            del_btn.pack_forget()

        # 悬停效果 + 显示/隐藏操作按钮
        def _on_enter(e):
            if self.current_conv_id != cid:
                bg = C["sidebar_hover"]
            else:
                bg = C["sidebar_active"]
            item.configure(bg=bg)
            text_area.configure(bg=bg)
            title_row.configure(bg=bg)
            title_lbl.configure(bg=bg)
            cnt_lbl.configure(bg=bg)
            del_btn.configure(bg=bg)
            pin_btn.configure(bg=bg)
            _show_del()

        def _on_leave(e):
            if self.current_conv_id != cid:
                bg = C["sidebar_item"]
            else:
                bg = C["sidebar_active"]
            item.configure(bg=bg)
            text_area.configure(bg=bg)
            title_row.configure(bg=bg)
            title_lbl.configure(bg=bg)
            cnt_lbl.configure(bg=bg)
            del_btn.configure(bg=bg)
            pin_btn.configure(bg=bg)
            _hide_del()

        for w in (item, title_lbl, cnt_lbl, text_area, title_row):
            w.bind("<Enter>", _on_enter)
            w.bind("<Leave>", _on_leave)

        # 删除按钮事件
        def _on_del(e, _cid=cid):
            self._delete_chat(_cid)
        del_btn.bind("<Button-1>", _on_del)
        del_btn.bind("<Enter>", lambda e: del_btn.config(fg=C["red"]))
        del_btn.bind("<Leave>", lambda e: del_btn.config(fg=C["text_dim"]))

        # 置顶按钮事件
        def _on_pin(e, _cid=cid):
            self._toggle_pin(_cid)
        pin_btn.bind("<Button-1>", _on_pin)
        pin_btn.bind("<Enter>", lambda e: pin_btn.config(fg=C["accent"]))
        pin_btn.bind("<Leave>", lambda e: pin_btn.config(fg=C["text_dim"]))

        self.conv_items[cid] = item

    def _update_sidebar_item(self, conv_id):
        if conv_id not in self.conv_items:
            return
        conv  = self.conversations[conv_id]
        item  = self.conv_items[conv_id]
        pin_icon = "📌 " if conv.pinned else ""
        # 遍历 item 的子控件,找到 text_area
        for w in item.winfo_children():
            # text_area 内部: title_row, cnt_lbl
            inner_kids = w.winfo_children()
            for ik in inner_kids:
                # title_row 内部: title_lbl (可能还有 pin_btn)
                if isinstance(ik, tk.Frame):
                    for sub in ik.winfo_children():
                        if isinstance(sub, tk.Label):
                            sub.config(text=f"{pin_icon}{conv.title}")
                            break
                elif isinstance(ik, tk.Label):
                    # cnt_lbl
                    n = sum(1 for m in conv.messages if m["role"] == "user")
                    ik.config(text=f"{n} 条消息")

    def _toggle_pin(self, conv_id):
        """切换对话置顶状态"""
        if conv_id not in self.conversations:
            return
        conv = self.conversations[conv_id]
        conv.pinned = not conv.pinned
        conv.save()
        # 重建侧边栏(重排序)
        self._rebuild_sidebar()
        self._toast("📌 已置顶" if conv.pinned else "📍 已取消置顶")

    def _rebuild_sidebar(self):
        """重建侧边栏对话列表(重排序后调用)"""
        inner = self.conv_scroll.inner
        # 保存当前选中ID
        saved_id = self.current_conv_id
        # 清除所有侧边栏子控件
        for w in inner.winfo_children():
            w.destroy()
        self.conv_items.clear()
        # 按排序重新添加
        sorted_convs = list_all_conversations()
        for conv in sorted_convs:
            self._add_sidebar_item(conv)
        # 恢复高亮
        if saved_id:
            self._highlight_active(saved_id)

    def _delete_chat(self, conv_id):
        if conv_id not in self.conversations:
            return
        conv = self.conversations[conv_id]
        if self.confirm_var.get():
            ok = messagebox.askyesno(
                "删除对话", f"确定删除「{conv.title}」吗?")
            if not ok:
                return
        if self.streaming and self.stream_conv_id == conv_id:
            self._stop_stream()
        # 删除对话时取消编辑模式
        if self._edit_index is not None:
            self._cancel_edit()
        conv.delete_file()
        del self.conversations[conv.id]
        if conv.id in self.conv_items:
            self.conv_items[conv.id].destroy()
            del self.conv_items[conv.id]
        if conv_id == self.current_conv_id:
            if not self.conversations:
                self._new_chat()
            else:
                remaining = sorted(
                    self.conversations.values(),
                    key=lambda c: c.updated_at,
                    reverse=True)
                self._switch_to(remaining[0].id)
        self._update_stats()

    def _delete_current_chat(self):
        if self.current_conv_id:
            self._delete_chat(self.current_conv_id)

    def _rename_chat(self, conv_id):
        """双击侧边栏项目时,弹出行内编辑框修改标题"""
        if conv_id not in self.conv_items:
            return
        conv = self.conversations[conv_id]
        item = self.conv_items[conv_id]
        # 找到 text_area 里的 title_lbl
        kids = item.winfo_children()
        title_lbl = None
        for w in kids:
            inner = w.winfo_children()
            if inner:
                title_lbl = inner[0]
                break
        if not title_lbl:
            return

        # 隐藏 title_lbl,插入 Entry
        title_lbl.pack_forget()
        edit_var = tk.StringVar(value=conv.title)
        edit_entry = tk.Entry(title_lbl.master,
                               textvariable=edit_var,
                               font=("Microsoft YaHei UI", 10),
                               fg=C["text"], bg=C["input_bg"],
                               insertbackground=C["text"],
                               relief="flat", bd=0,
                               highlightthickness=1,
                               highlightbackground=C["accent"])
        edit_entry.pack(fill="x")
        edit_entry.select_range(0, "end")
        edit_entry.focus_set()

        def _confirm(_e=None):
            new_title = edit_var.get().strip()
            if new_title and new_title != conv.title:
                conv.title = new_title
                conv.save()
                title_lbl.config(text=new_title)
                self._update_sidebar_item(conv_id)
            edit_entry.destroy()
            title_lbl.pack(fill="x")

        def _cancel(_e=None):
            edit_entry.destroy()
            title_lbl.pack(fill="x")

        edit_entry.bind("<Return>", _confirm)
        edit_entry.bind("<Escape>", _cancel)
        edit_entry.bind("<FocusOut>", _confirm)

    def _toggle_sidebar(self):
        if self.sidebar_visible:
            self.sidebar.pack_forget()
        else:
            self.sidebar.pack(side="left", fill="y",
                               before=self.right_panel)
        self.sidebar_visible = not self.sidebar_visible

    def _toggle_settings(self):
        """展开/收起系统设置栏"""
        self._settings_visible = not self._settings_visible
        if self._settings_visible:
            self._settings_body.pack(fill="x", pady=(4, 0))
            self._settings_arrow.config(text="▼")
        else:
            self._settings_body.pack_forget()
            self._settings_arrow.config(text="▶")

    # ── 对话统计 ──
    def _update_stats(self):
        """更新底部统计信息"""
        total_conv = len(self.conversations)
        total_msgs = sum(len(c.messages) for c in self.conversations.values())
        total_chars = sum(
            sum(len(m["content"]) for m in c.messages)
            for c in self.conversations.values()
        )
        # 今日统计
        today_str = datetime.now().strftime("%Y-%m-%d")
        today_conv = 0
        today_msgs = 0
        for c in self.conversations.values():
            if c.updated_at.startswith(today_str):
                today_conv += 1
                today_msgs += len(c.messages)

        stats_text = f"💬 {total_conv} 对话 · {total_msgs} 消息 · {total_chars} 字"
        if today_conv > 0 or today_msgs > 0:
            stats_text += f"\n📅 今日 {today_conv} 对话 · {today_msgs} 消息"
        self._stats_label.config(text=stats_text)

    # ── 对话搜索 ──
    def _on_search_focus_in(self, e=None):
        if self._search_placeholder:
            self.search_entry.delete(0, "end")
            self.search_entry.config(fg=C["text"])
            self._search_placeholder = False

    def _on_search_focus_out(self, e=None):
        if not self.search_var.get().strip():
            self._search_placeholder = True   # 必须在 insert 之前设置,防止 trace 回调触发过滤
            self.search_entry.insert(0, "🔍 搜索对话...")
            self.search_entry.config(fg=C["text_dim"])

    def _on_search_change(self):
        """搜索框内容变化时过滤对话列表"""
        if self._search_placeholder:
            # 占位符状态:确保所有对话可见(自修复)
            for conv_id, item in self.conv_items.items():
                item.pack(fill="x", pady=(0, 2), padx=4)
            return
        keyword = self.search_var.get().strip().lower()
        for conv_id, item in self.conv_items.items():
            if not keyword:
                item.pack(fill="x", pady=(0, 2), padx=4)
                continue
            conv = self.conversations.get(conv_id)
            if not conv:
                continue
            # 搜索标题和消息内容
            match = False
            if keyword in conv.title.lower():
                match = True
            else:
                for msg in conv.messages:
                    if keyword in msg["content"].lower():
                        match = True
                        break
            if match:
                item.pack(fill="x", pady=(0, 2), padx=4)
            else:
                item.pack_forget()

    def _on_temp_change(self, val):
        """Temperature 滑块变化回调"""
        self.ollama.temperature = val
        self.temp_label.config(text=f"Temperature: {val:.1f}")
        self._save_model_params()

    def _on_top_p_change(self, val):
        """Top-P 滑块变化回调"""
        self.ollama.top_p = val
        self.top_p_label.config(text=f"Top-P: {val:.2f}")
        self._save_model_params()

    def _on_font_size_change(self, val):
        """字体大小滑块变化回调"""
        self._font_size = val
        self.fs_label.config(text=f"字体大小: {val}")
        self.input_text.config(font=("Microsoft YaHei UI", val))
        # 重建当前聊天区以应用新字体
        conv = self._cur_conv()
        if conv:
            self._switch_to(conv.id)
        self._save_model_params()

    def _on_ctx_change(self, val):
        """上下文长度滑块变化回调"""
        self._ctx_len = val
        display = "不限" if val == 0 else str(val)
        self.ctx_label.config(text=f"上下文长度: {display}")
        self._save_model_params()

    def _save_model_params(self):
        """保存模型参数到 config.ini"""
        cfg = configparser.ConfigParser()
        cfg_path = os.path.join(APP_DIR, "config.ini")
        if os.path.exists(cfg_path):
            cfg.read(cfg_path, encoding="utf-8")
        if "ollama" not in cfg:
            cfg.add_section("ollama")
        cfg.set("ollama", "temperature", str(self.ollama.temperature))
        cfg.set("ollama", "top_p", str(self.ollama.top_p))
        cfg.set("ollama", "font_size", str(self._font_size))
        cfg.set("ollama", "context_length", str(self._ctx_len))
        with open(cfg_path, "w", encoding="utf-8") as f:
            cfg.write(f)

    def _load_conversations(self):
        for conv in list_all_conversations():
            self.conversations[conv.id] = conv
            self._add_sidebar_item(conv)
        if self.conversations:
            first_id = next(iter(self.conv_items))
            self._switch_to(first_id)
        self._update_stats()

    # ── 模型加载 ───────────────────────────────────────────────
    def _load_models(self):
        def _f():
            models = self.ollama.list_models()
            self.root.after(0, lambda: self._update_models(models))
        threading.Thread(target=_f, daemon=True).start()

    def _update_models(self, models):
        menu = self.model_menu["menu"]
        menu.delete(0, "end")
        for m in models:
            menu.add_command(
                label=m,
                command=lambda v=m: self.model_var.set(v))
        # 优先保留用户当前选择的模型,避免刷新列表时重置
        current = self.model_var.get()
        if current in models:
            self.model_var.set(current)
        elif DEFAULT_MODEL in models:
            self.model_var.set(DEFAULT_MODEL)
        elif models:
            self.model_var.set(models[0])
        self.status_dot.config(fg=C["green"])
        self.status_label.config(text="已连接")

    # ── 模型管理 ────────────────────────────────────────────────
    def _open_model_manager(self):
        ModelManagerDialog(self.root, self.ollama,
                            current_model=self.model_var.get(),
                            on_switch=self._switch_model,
                            on_done=self._load_models)

    def _switch_model(self, model_name):
        """切换当前使用的模型"""
        self.model_var.set(model_name)
        # 同步到配置
        cfg = configparser.ConfigParser()
        cfg_path = os.path.join(APP_DIR, "config.ini")
        if os.path.exists(cfg_path):
            cfg.read(cfg_path, encoding="utf-8")
        if "ollama" not in cfg:
            cfg.add_section("ollama")
        cfg.set("ollama", "model", model_name)
        with open(cfg_path, "w", encoding="utf-8") as f:
            cfg.write(f)

    # ── 系统提示词 ────────────────────────────────────────────
    def _open_system_prompt(self):
        conv = self._cur_conv()
        prompt = conv.system_prompt if conv else DEFAULT_SYSTEM_PROMPT
        title  = conv.title if conv else ""
        SystemPromptDialog(self.root, prompt, self._save_system_prompt, title)

    def _save_system_prompt(self, prompt):
        conv = self._cur_conv()
        if conv:
            conv.system_prompt = prompt
            conv.save()
            self._toast("✅ 系统提示词已保存")

    def _export_conversation(self):
        """导出当前对话为 Markdown 或纯文本格式"""
        conv = self._cur_conv()
        if not conv:
            return
        if not conv.messages:
            messagebox.showinfo("导出", "当前对话为空,无需导出")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".md",
            filetypes=[("Markdown", "*.md"), ("纯文本", "*.txt")],
            initialfile=f"{conv.title}.md",
            title="导出对话")
        if not path:
            return
        try:
            if path.endswith(".txt"):
                lines = [conv.title, "=" * 40,
                         f"系统提示词:{conv.system_prompt}",
                         f"导出时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
                         ""]
                for msg in conv.messages:
                    role = "你" if msg["role"] == "user" else "AI"
                    lines.append(f"[{role}]")
                    lines.append(msg["content"])
                    lines.append("")
                with open(path, "w", encoding="utf-8") as f:
                    f.write("\n".join(lines))
            else:  # .md (default)
                lines = [f"# {conv.title}\n"]
                lines.append(f"> 系统提示词:{conv.system_prompt}\n")
                lines.append(f"> 导出时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n---\n")
                for msg in conv.messages:
                    role = "🧑 你" if msg["role"] == "user" else "🤖 AI"
                    lines.append(f"### {role}\n\n{msg['content']}\n\n---\n")
                with open(path, "w", encoding="utf-8") as f:
                    f.write("\n".join(lines))

            self._toast(f"✅ 已导出到 {os.path.basename(path)}")
        except Exception as e:
            messagebox.showerror("导出失败", str(e))

    def _delete_message(self, msg_index):
        """删除指定索引的消息,然后重建聊天区"""
        conv = self._cur_conv()
        if not conv or msg_index < 0 or msg_index >= len(conv.messages):
            return
        if self.streaming:
            return
        conv.messages.pop(msg_index)
        conv.save()
        self._switch_to(conv.id)
        self._update_sidebar_item(conv.id)

    def _on_edit_message(self, msg_index):
        """点击消息的「编辑」按钮时的回调"""
        conv = self._cur_conv()
        if not conv or msg_index < 0 or msg_index >= len(conv.messages):
            return
        msg = conv.messages[msg_index]
        if msg["role"]!= "user":
            return  # 只允许编辑用户消息
        self._edit_index = msg_index
        # 填充输入框
        self.input_text.delete("1.0", "end")
        self.input_text.insert("1.0", msg["content"])
        self.input_text.focus_set()
        # 更新UI:发送按钮文本、显示取消按钮
        self.send_btn.config(text="保存并重新生成")
        self._show_cancel_edit_btn()

    def _show_cancel_edit_btn(self):
        """显示取消编辑按钮"""
        if hasattr(self, '_cancel_edit_btn'):
            return
        self._cancel_edit_btn = tk.Label(
            self.input_row, text="取消编辑",
            font=("Microsoft YaHei UI", 9),
            fg=C["red"], bg=C["bg"],
            cursor="hand2", padx=8)
        self._cancel_edit_btn.pack(side="right", padx=(0, 8))
        self._cancel_edit_btn.bind("<Button-1>", lambda e: self._cancel_edit())
        self._cancel_edit_btn.bind("<Enter>",
                                    lambda e: self._cancel_edit_btn.config(fg=C["accent"]))
        self._cancel_edit_btn.bind("<Leave>",
                                    lambda e: self._cancel_edit_btn.config(fg=C["red"]))

    def _cancel_edit(self):
        """取消编辑模式"""
        self._edit_index = None
        self.input_text.delete("1.0", "end")
        self.send_btn.config(text="发送 ▸")
        # 移除取消编辑按钮
        if hasattr(self, '_cancel_edit_btn'):
            self._cancel_edit_btn.destroy()
            del self._cancel_edit_btn
        self.input_text.focus_set()

    def _handle_edit_message(self, new_text, conv):
        """处理编辑消息后的保存和重新生成"""
        # 1. 更新指定索引的消息内容
        conv.messages[self._edit_index]["content"] = new_text
        # 2. 删除该索引之后的所有消息(因为这些消息是基于旧内容的回复)
        conv.messages = conv.messages[:self._edit_index + 1]
        conv.save()
        # 3. 先保存编辑索引,再取消编辑(_cancel_edit 会将 _edit_index 设为 None)
        saved_idx = self._edit_index
        # 4. 重建聊天区
        self._switch_to(conv.id)
        # 5. 取消编辑模式
        self._cancel_edit()
        # 6. 重新生成AI回复
        self._resend_from_index(saved_idx, conv)

    def _resend_from_index(self, idx, conv):
        """从指定索引的用户消息重新生成AI回复"""
        # 创建 AI 气泡时不显示时间戳(流式完成后再更新)
        self.current_bubble = ChatBubble(
            self.chat_frame.inner, role="assistant", text="▍",
            timestamp="",  # 流式进行中不显示时间
            font_size=self._font_size)
        self.chat_frame.scroll_bottom()
        # 设置流式状态
        self.streaming = True
        self.stop_flag = False
        self.stream_conv_id = conv.id
        self.send_btn.config(text="■ 停止", bg=C["stop_btn"])
        self.input_text.config(state="disabled")
        _captured_id = conv.id
        _captured_model = self.model_var.get()  # 在主线程捕获模型名
        # 启动流式请求
        def _stream():
            full = ""
            try:
                api_msgs = conv.get_api_messages(self._ctx_len)
                api_msgs = self._inject_skill_prompt(api_msgs)
                for token in self.ollama.chat_stream(
                        api_msgs, model=_captured_model):
                    if self.stop_flag:
                        break
                    full += token
                    snap = full
                    self.root.after(
                        0,
                        lambda t=snap: self._update_bubble(t))
            finally:
                snap = full
                self.root.after(
                    0,
                    lambda: self._stream_done(snap, _captured_id))
        threading.Thread(target=_stream, daemon=True).start()

    def _clear_conversation(self):
        """清空当前对话的消息(保留对话和系统提示词)"""
        conv = self._cur_conv()
        if not conv or not conv.messages:
            return
        if self.confirm_var.get():
            ok = messagebox.askyesno("清空对话",
                                      f"确定清空「{conv.title}」的所有消息吗?\n(对话和系统提示词会保留)")
            if not ok:
                return
        if self.streaming:
            self._stop_stream()
        # 清空对话时取消编辑模式
        if self._edit_index is not None:
            self._cancel_edit()
        conv.messages = []
        conv.save()
        self._switch_to(conv.id)

    def _toast(self, message, duration=2000):
        """显示临时提示(2秒后自动消失)"""
        # 放在聊天区域底部
        tframe = tk.Frame(self.chat_frame.inner,
                           bg=C["green"], padx=16, pady=6)
        tframe.pack(fill="x", pady=(4, 0))
        tk.Label(tframe, text=message,
                  font=("Microsoft YaHei UI", 10),
                  fg=C["bg"], bg=C["green"]).pack()

        def _fade():
            try:
                tframe.destroy()
            except Exception:
                pass

        self.root.after(duration, _fade)

    # ── 欢迎界面 ───────────────────────────────────────────────
    def _show_welcome(self):
        w = tk.Frame(self.chat_frame.inner, bg=C["bg_chat"])
        w.pack(fill="x", pady=(60, 20))
        tk.Label(w, text="AI Agent",
                  font=("Microsoft YaHei UI", 22, "bold"),
                  fg=C["text"], bg=C["bg_chat"]).pack(pady=(8, 4))
        tk.Label(w, text="本地Ollama模型 · 私密安全 · 流式输出",
                  font=("Microsoft YaHei UI", 11),
                  fg=C["text_dim"], bg=C["bg_chat"]).pack(pady=(0, 12))
        tk.Label(w, text="提示:点击左侧「⚙ 系统提示词」可自定义AI角色",
                  font=("Microsoft YaHei UI", 10),
                  fg=C["yellow"], bg=C["bg_chat"]).pack(pady=(0, 20))

    # ── 发送消息 ───────────────────────────────────────────────
    def _on_send_click(self):
        if self.streaming:
            self._stop_stream()
        else:
            self._send_message()

    def _send_message(self):
        text = self.input_text.get("1.0", "end-1c").strip()
        if not text or self.streaming:
            return
        conv = self._cur_conv()
        if not conv:
            return

        # 编辑模式:修改消息并重新生成
        if self._edit_index is not None:
            self._handle_edit_message(text, conv)
            return

        self.input_text.delete("1.0", "end")
        self.input_text.config(height=3)  # 发送后恢复默认高度
        conv.add_message("user", text)
        conv.save()
        user_ts = conv.messages[-1].get("timestamp", "")

        # 自动生成对话标题(首次发消息时)
        if conv.title == "新对话" and text:
            new_title = text.replace("\n", " ")[:20]
            if len(text.replace("\n", " ")) > 20:
                new_title += "..."
            conv.title = new_title
            conv.save()
            self._update_sidebar_item(conv.id)

        ChatBubble(self.chat_frame.inner, role="user", text=text,
                    timestamp=user_ts,
                    streaming=False, font_size=self._font_size)

        # 创建 AI 气泡时不显示时间戳(流式完成后再更新)
        self.current_bubble = ChatBubble(
            self.chat_frame.inner, role="assistant", text="▍",
            timestamp="",  # 流式进行中不显示时间
            font_size=self._font_size)
        self.chat_frame.scroll_bottom()

        self.streaming  = True
        self.stop_flag  = False
        self.stream_conv_id = conv.id
        self.send_btn.config(text="■ 停止", bg=C["stop_btn"])
        self.input_text.config(state="disabled")
        self._update_sidebar_item(conv.id)

        _captured_id = conv.id
        _captured_model = self.model_var.get()  # 在主线程捕获模型名

        def _stream():
            full = ""
            try:
                api_msgs = conv.get_api_messages(self._ctx_len)
                api_msgs = self._inject_skill_prompt(api_msgs)
                for token in self.ollama.chat_stream(
                        api_msgs, model=_captured_model):
                    if self.stop_flag:
                        break
                    full += token
                    snap = full
                    self.root.after(
                        0,
                        lambda t=snap: self._update_bubble(t))
            finally:
                snap = full
                self.root.after(
                    0,
                    lambda: self._stream_done(snap, _captured_id))
        threading.Thread(target=_stream, daemon=True).start()

    def _update_bubble(self, text):
        if self.current_bubble and self.current_bubble.winfo_exists():
            self.current_bubble.update_text(text + "▍")
            self.chat_frame.scroll_bottom()

    def _stream_done(self, full, conv_id):
        if self.current_bubble and self.current_bubble.winfo_exists():
            self.current_bubble.render_final(full)
            # AI 气泡下方添加操作按钮行
            self._add_bubble_actions(self.current_bubble)
        if conv_id in self.conversations and full:
            conv = self.conversations[conv_id]
            conv.add_message("assistant", full)
            conv.save()
            # ── 检查 AI 回复是否包含技能调用请求 ──
            skill_calls = self._parse_skill_calls(full)
            if skill_calls:
                # 有技能调用请求,进入审批流程
                # 先结束当前流式状态,让 UI 可交互
                self.streaming      = False
                self.stop_flag      = False
                self.current_bubble = None
                self.stream_conv_id = None
                self.send_btn.config(text="发送 ▸", bg=C["btn_send"])
                self.input_text.config(state="normal")
                self._handle_skill_calls(skill_calls, conv, conv_id)
                return
            self._update_sidebar_item(conv_id)
            self._update_stats()
            # 更新 AI 气泡的时间戳显示
            if self.current_bubble and self.current_bubble.winfo_exists():
                ai_ts = conv.messages[-1].get("timestamp", "")
                self._update_bubble_timestamp(self.current_bubble, ai_ts)
        self.streaming      = False
        self.stop_flag      = False
        self.current_bubble = None
        self.stream_conv_id = None
        self.send_btn.config(text="发送 ▸", bg=C["btn_send"])
        self.input_text.config(state="normal")
        self.input_text.focus_set()
        self.chat_frame.scroll_bottom()

    def _update_bubble_timestamp(self, bubble, timestamp):
        """更新气泡的时间戳显示"""
        if not timestamp:
            return
        try:
            if not bubble or not bubble.winfo_exists():
                return
            if hasattr(bubble, '_ts_label') and bubble._ts_label and bubble._ts_label.winfo_exists():
                bubble._ts_label.config(text=f"  {timestamp}")
            else:
                # 没有时间戳标签,需要创建一个(流式气泡创建时 timestamp="")
                for align in bubble.winfo_children():
                    for header_row in align.winfo_children():
                        # 找到 header_row,在角色名后追加时间戳
                        if header_row.winfo_children():
                            ts_lbl = tk.Label(header_row, text=f"  {timestamp}",
                                      font=("Microsoft YaHei UI", 8),
                                      fg=C["text_dim"], bg=C["bg_chat"])
                            ts_lbl.pack(side="left")
                            bubble._ts_label = ts_lbl
                            return
        except Exception:
            pass

    def _add_bubble_actions(self, bubble):
        """在 AI 气泡下方添加操作按钮(重新生成)"""
        is_user = False
        align_frame = None
        # 找到 bubble 的 align 子控件
        for w in bubble.winfo_children():
            align_frame = w
            break
        if not align_frame:
            return

        actions = tk.Frame(align_frame, bg=C["bg_chat"])
        actions.pack(anchor="w", padx=(60, 0), pady=(2, 0))

        regen_lbl = tk.Label(actions, text="🔄 重新生成",
                              font=("Microsoft YaHei UI", 9),
                              fg=C["text_dim"], bg=C["bg_chat"],
                              cursor="hand2")
        regen_lbl.pack(side="left", padx=(0, 12))
        regen_lbl.bind("<Button-1>", lambda e: self._regenerate())
        regen_lbl.bind("<Enter>", lambda e: regen_lbl.config(fg=C["accent"]))
        regen_lbl.bind("<Leave>", lambda e: regen_lbl.config(fg=C["text_dim"]))

    def _regenerate(self):
        """重新生成最后一条 AI 回复"""
        conv = self._cur_conv()
        if not conv or self.streaming:
            return
        # 删除最后一条 assistant 消息
        if conv.messages and conv.messages[-1]["role"] == "assistant":
            conv.messages.pop()
            conv.save()
        # 必须有用户消息才能重新生成
        if not conv.messages or conv.messages[-1]["role"] != "user":
            return
        # 重建聊天区(不含最后的AI回复)
        self._switch_to(conv.id)
        # 直接走流式请求,不再走 _send_message(它从输入框读内容)
        self._resend_last_user(conv)

    def _resend_last_user(self, conv):
        """用最后一条用户消息重新请求AI回复"""
        # 创建 AI 气泡时不显示时间戳(流式完成后再更新)
        self.current_bubble = ChatBubble(
            self.chat_frame.inner, role="assistant", text="▍",
            timestamp="",  # 流式进行中不显示时间
            font_size=self._font_size)
        self.chat_frame.scroll_bottom()

        self.streaming = True
        self.stop_flag = False
        self.stream_conv_id = conv.id
        self.send_btn.config(text="■ 停止", bg=C["stop_btn"])
        self.input_text.config(state="disabled")

        _captured_id = conv.id
        _captured_model = self.model_var.get()  # 在主线程捕获模型名

        def _stream():
            full = ""
            try:
                api_msgs = conv.get_api_messages(self._ctx_len)
                api_msgs = self._inject_skill_prompt(api_msgs)
                for token in self.ollama.chat_stream(
                        api_msgs, model=_captured_model):
                    if self.stop_flag:
                        break
                    full += token
                    snap = full
                    self.root.after(
                        0,
                        lambda t=snap: self._update_bubble(t))
            finally:
                snap = full
                self.root.after(
                    0,
                    lambda: self._stream_done(snap, _captured_id))
        threading.Thread(target=_stream, daemon=True).start()

    def _stop_stream(self):
        self.stop_flag = True

    def _on_enter(self, event):
        # Shift+Enter = 换行,单独 Enter = 发送
        if not (event.state & 0x1):
            self._send_message()
            return "break"

    def _switch_adjacent(self, direction):
        """Ctrl+↑/↓ 切换到上/下一个对话"""
        ids = list(self.conv_items.keys())
        if not ids or self.current_conv_id not in ids:
            return
        idx = ids.index(self.current_conv_id)
        new_idx = idx + direction
        if 0 <= new_idx < len(ids):
            self._switch_to(ids[new_idx])

    def _auto_resize_input(self, _event=None):
        """输入框根据内容自动调整高度(3~8行)"""
        lines = self.input_text.get("1.0", "end-1c").count("\n") + 1
        h = max(3, min(lines + 1, 8))
        self.input_text.config(height=h)
    # ── 技能系统 ─────────────────────────────────────────────
    def _load_skills(self):
        """加载/刷新技能列表"""
        skills.reload_skills()
        count = len(skills.get_all_skills())
        if count > 0:
            print(f"[Skills] 已加载 {count} 个技能")
        else:
            print("[Skills] 未加载任何技能")

    def _inject_skill_prompt(self, api_msgs):
        """将技能说明注入到 system prompt"""
        prompt = skills.build_skill_prompt()
        if not prompt:
            return api_msgs
        if api_msgs and api_msgs[0].get("role") == "system":
            api_msgs[0]["content"] += "\n" + prompt
        else:
            api_msgs.insert(0, {"role": "system", "content": prompt})
        return api_msgs

    def _parse_skill_calls(self, text):
        """从 AI 回复中解析 skill_call 代码块"""
        import re, json
        calls = []
        pattern = r'```skill_call\s*\n(.*?)\n```'
        matches = re.findall(pattern, text, re.DOTALL)
        for match in matches:
            try:
                data = json.loads(match.strip())
                name = data.get("name", "")
                params = data.get("parameters", {})
                if name:
                    calls.append((name, params))
            except Exception:
                pass
        return calls

    def _on_skill_approve(self, skill_name, params):
        """
        弹窗请求用户审批技能调用。
        返回:
          True   → 用户允许
          False  → 用户拒绝
          "always" → 用户允许并勾选"总是允许"
        """
        import json
        from tkinter import messagebox

        meta = skills.get_skill_by_id(skill_name)
        desc = meta["description"] if meta else skill_name
        param_str = json.dumps(params, ensure_ascii=False)

        # 用自定义 Toplevel 实现带复选框的审批对话框
        result = {"value": False}
        dlg = tk.Toplevel(self.root)
        dlg.title("技能调用审批")
        dlg.resizable(False, False)
        dlg.transient(self.root)
        dlg.grab_set()

        frm = tk.Frame(dlg, padx=20, pady=16)
        frm.pack(fill="both")

        tk.Label(frm, text="🔧 AI 请求调用技能",
                  font=("Microsoft YaHei UI", 12, "bold")).pack(anchor="w")
        tk.Label(frm, text=f"名称:{skill_name}",
                  font=("Microsoft YaHei UI", 10), anchor="w").pack(fill="x", pady=(10, 0))
        tk.Label(frm, text=f"描述:{desc}",
                  font=("Microsoft YaHei UI", 10), anchor="w").pack(fill="x")
        # 参数多行显示
        param_txt = tk.Text(frm, height=3, width=52,
                            font=("Microsoft YaHei UI", 9),
                            wrap="word", bd=0, bg=frm.cget("bg"))
        param_txt.insert("end", f"参数:{param_str}")
        param_txt.config(state="disabled")
        param_txt.pack(fill="x", pady=(4, 0))

        always_var = tk.IntVar(value=0)
        chk = tk.Checkbutton(frm, text="总是允许此技能(以后不再询问)",
                              variable=always_var,
                              font=("Microsoft YaHei UI", 9))
        chk.pack(anchor="w", pady=(12, 0))

        btn_frm = tk.Frame(frm)
        btn_frm.pack(pady=(14, 0))

        def _on_allow(always=False):
            result["value"] = "always" if always else True
            dlg.destroy()

        def _on_deny():
            result["value"] = False
            dlg.destroy()

        tk.Button(btn_frm, text="允许", width=10,
                  command=lambda: _on_allow(always=bool(always_var.get())),
                  bg=C["btn_send"], fg="#ffffff").pack(side="left", padx=6)
        tk.Button(btn_frm, text="总是允许", width=12,
                  command=lambda: _on_allow(always=True),
                  bg=C["border"], fg="#ffffff").pack(side="left", padx=6)
        tk.Button(btn_frm, text="拒绝", width=10,
                  command=_on_deny,
                  bg=C["input_bg"], fg=C["text"]).pack(side="left", padx=6)

        dlg.update_idletasks()
        # 居中显示
        x = self.root.winfo_x() + (self.root.winfo_width() // 2) - (dlg.winfo_width() // 2)
        y = self.root.winfo_y() + (self.root.winfo_height() // 2) - (dlg.winfo_height() // 2)
        dlg.geometry(f"+{x}+{y}")
        dlg.focus_force()
        dlg.wait_window()

        return result["value"]

    def _handle_skill_calls(self, calls, conv, conv_id):
        """逐个处理技能调用:不存在的跳过,已自动批准的直接执行,其余弹窗审批"""
        import json

        # 先过滤:不存在的技能直接跳过,并通知 AI
        valid_calls = []
        for name, params in calls:
            if not skills.get_skill_by_id(name):
                info = f"[技能 {name} 不存在,已跳过]\n提示:AI 输出了不存在的技能名「{name}」,请只使用真实存在的技能。"
                conv.add_message("system", info)
                # 在聊天区显示跳过提示
                ChatBubble(self.chat_frame.inner,
                            role="system", text=info,
                            timestamp="", streaming=False,
                            font_size=self._font_size)
                self.chat_frame.scroll_bottom()
            else:
                valid_calls.append((name, params))

        if not valid_calls:
            # 所有调用都是无效的,直接进入最终流程
            self.root.after(100, lambda: self._finalize_stream_done(conv, conv_id))
            return

        name, params = valid_calls[0]
        remaining = valid_calls[1:]

        # 检查是否已自动批准
        if skills.is_auto_approved(name):
            # 直接执行,不弹窗
            self._execute_skill_and_continue(name, params, remaining, conv, conv_id)
            return

        # 弹窗审批
        approve = self._on_skill_approve(name, params)
        if approve == "always":
            # 用户勾选了"总是允许"
            skills.add_auto_approve(name)
            # 继续执行
            self._execute_skill_and_continue(name, params, remaining, conv, conv_id)
        elif approve is True:
            # 用户允许
            self._execute_skill_and_continue(name, params, remaining, conv, conv_id)
        else:
            # 用户拒绝,跳过,继续处理剩余
            if remaining:
                self.root.after(100, lambda: self._handle_skill_calls(remaining, conv, conv_id))
            else:
                self.root.after(100, lambda: self._finalize_stream_done(conv, conv_id))

    def _execute_skill_and_continue(self, name, params, remaining, conv, conv_id):
        """执行单个技能,显示结果,然后继续处理剩余调用"""
        import json
        ok, result = skills.execute_skill(name, params)
        result_str = json.dumps(result, ensure_ascii=False, indent=2)
        label = "执行结果" if ok else "执行失败"
        info = f"[技能 {name} {label}]\n{result_str}"
        conv.add_message("system", info)
        conv.save()
        # 在聊天区显示技能结果气泡
        ChatBubble(self.chat_frame.inner,
                    role="system", text=info,
                    timestamp="", streaming=False,
                    font_size=self._font_size)
        self.chat_frame.scroll_bottom()
        # 继续处理剩余技能调用
        if remaining:
            self.root.after(100, lambda: self._handle_skill_calls(remaining, conv, conv_id))
        else:
            self.root.after(100, lambda: self._finalize_stream_done(conv, conv_id))

    def _finalize_stream_done(self, conv, conv_id):
        """所有技能调用处理完毕后,让 AI 基于结果继续回复"""
        _captured_id = conv.id
        _captured_model = self.model_var.get()
        self.current_bubble = ChatBubble(
            self.chat_frame.inner, role="assistant",
            text="▍", timestamp="", font_size=self._font_size)
        self.chat_frame.scroll_bottom()
        self.streaming = True
        self.stop_flag = False
        self.stream_conv_id = conv.id
        self.send_btn.config(text="■ 停止", bg=C["stop_btn"])
        self.input_text.config(state="disabled")

        def _stream():
            full = ""
            try:
                api_msgs = conv.get_api_messages(self._ctx_len)
                api_msgs = self._inject_skill_prompt(api_msgs)
                for token in self.ollama.chat_stream(api_msgs, model=_captured_model):
                    if self.stop_flag:
                        break
                    full += token
                    snap = full
                    self.root.after(0, lambda t=snap: self._update_bubble(t))
            finally:
                snap = full
                self.root.after(0, lambda: self._stream_done(snap, _captured_id))
        threading.Thread(target=_stream, daemon=True).start()

二、skills 目录

skills/init.py

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
技能加载器 ------ 扫描 skills/ 目录,加载所有符合规范的 .py 技能文件。

技能文件规范:
1. 必须定义 SKILL_META 字典,包含:name, description, parameters
2. 必须定义 run(params: dict) -> dict 函数
3. 文件名即为技能ID(不含 .py)
"""
import os
import sys
import importlib.util
import json
import traceback

# skills 目录的绝对路径(当前文件所在目录)
_SKILLS_DIR = os.path.dirname(os.path.abspath(__file__))
_loaded_skills = {}   # id -> skill dict


def _load_skill_file(filepath):
    """加载单个技能文件,返回 skill dict 或 None"""
    filename = os.path.basename(filepath)
    if not filename.endswith(".py") or filename == "__init__.py":
        return None
    skill_id = filename[:-3]
    try:
        spec = importlib.util.spec_from_file_location(
            f"skills.{skill_id}", filepath)
        if spec is None or spec.loader is None:
            return None
        mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(mod)
        if not hasattr(mod, "SKILL_META"):
            return None
        if not hasattr(mod, "run"):
            return None
        meta = mod.SKILL_META
        return {
            "id": skill_id,
            "name": meta.get("name", skill_id),
            "description": meta.get("description", ""),
            "parameters": meta.get("parameters", {}),
            "module": mod,
        }
    except Exception as e:
        print(f"[Skill Loader] 加载技能失败 {filename}: {e}")
        traceback.print_exc()
        return None


def reload_skills():
    """重新扫描并加载所有技能,返回加载到的技能列表"""
    global _loaded_skills
    _loaded_skills.clear()
    if not os.path.isdir(_SKILLS_DIR):
        return []
    for fname in sorted(os.listdir(_SKILLS_DIR)):
        full = os.path.join(_SKILLS_DIR, fname)
        if os.path.isfile(full) and fname.endswith(".py") and fname != "__init__.py":
            skill = _load_skill_file(full)
            if skill:
                _loaded_skills[skill["id"]] = skill
    return list(_loaded_skills.values())


def get_all_skills():
    """获取当前已加载的所有技能列表"""
    return list(_loaded_skills.values())


def get_skill_by_id(skill_id):
    """按 ID 获取单个技能,不存在返回 None"""
    return _loaded_skills.get(skill_id)


_AUTO_APPROVE_FILE = os.path.join(_SKILLS_DIR, "auto_approve.json")


def load_auto_approve():
    """加载自动批准的技能ID集合"""
    if os.path.isfile(_AUTO_APPROVE_FILE):
        try:
            with open(_AUTO_APPROVE_FILE, "r", encoding="utf-8") as f:
                data = json.load(f)
                return set(data.get("auto_approve", []))
        except Exception:
            pass
    return set()


def save_auto_approve(ids: set):
    """保存自动批准的技能ID集合"""
    try:
        with open(_AUTO_APPROVE_FILE, "w", encoding="utf-8") as f:
            json.dump({"auto_approve": sorted(ids)}, f, ensure_ascii=False, indent=2)
    except Exception:
        pass


_auto_approve_ids = load_auto_approve()


def is_auto_approved(skill_id):
    """检查技能是否在自动批准列表中"""
    return skill_id in _auto_approve_ids


def add_auto_approve(skill_id):
    """将技能加入自动批准列表"""
    _auto_approve_ids.add(skill_id)
    save_auto_approve(_auto_approve_ids)


def build_skill_prompt():
    """
    构建注入到系统提示词中的技能说明文本。
    让模型学会在需要时用 skill_call 格式请求调用。
    """
    skills = get_all_skills()
    if not skills:
        return ""
    lines = []
    lines.append("\n[可用技能 SKILLS]")
    lines.append("")
    lines.append("## 何时调用技能")
    lines.append("只有当用户的请求需要你执行一个『外部动作』时,才调用技能。")
    lines.append("")
    lines.append("✅ 应该调用技能(用户要你执行某个动作):")
    lines.append("  - 「现在几点了?」→ 调用 get_time(获取实时时间是外部动作)")
    lines.append("  - 「帮我SSH到服务器执行 ls」→ 调用 ssh_execute")
    lines.append("  - 「查一下今天的天气」→ 如果有天气技能就调用")
    lines.append("")
    lines.append("❌ 不应该调用技能(用户只是在问知识性问题):")
    lines.append("  - 「kubectl常用命令是什么?」→ 直接回答,不要调用任何技能")
    lines.append("  - 「怎么用SSH连接服务器?」→ 直接回答,不要调用任何技能")
    lines.append("  - 「Python是什么?」→ 直接回答,不要调用任何技能")
    lines.append("  - 「帮我写一个kubectl命令」→ 直接回答,不要调用kubectl技能")
    lines.append("")
    lines.append("## 调用格式")
    lines.append("如果确定需要调用,在回复中输出一个 skill_call 代码块(可多个):")
    lines.append("```skill_call")
    lines.append(json.dumps({"name": "技能名", "parameters": {}}, ensure_ascii=False))
    lines.append("```")
    lines.append("")
    lines.append("## 可用技能列表")
    for s in skills:
        lines.append(f"- {s['name']}: {s['description']}")
        params = s.get("parameters", {})
        if params:
            for pname, pinfo in params.items():
                req = "必填" if pinfo.get("required") else "可选"
                desc = pinfo.get("description", "")
                lines.append(f"  参数 {pname} ({req}): {desc}")
    lines.append("")
    lines.append("## 重要注意事项")
    lines.append("1. 可选参数如果不传,就不要写进 parameters 里(不要写 null)。")
    lines.append("2. 输出 skill_call 代码块后,等待我执行并返回结果,再继续回答。")
    lines.append("3. 不要输出多余的 skill_call,只在确实需要调用时才输出。")
    lines.append("4. 如果用户只是在询问知识,不要调用任何技能,直接回答。")
    return "\n".join(lines)


def execute_skill(skill_id, params):
    """
    执行指定技能,返回 (success: bool, result: dict)
    """
    skill = get_skill_by_id(skill_id)
    if not skill:
        return False, {"error": f"技能 {skill_id} 不存在"}
    try:
        mod = skill["module"]
        result = mod.run(params)
        return True, result
    except Exception as e:
        tb = traceback.format_exc()
        return False, {"error": f"技能执行失败: {type(e).__name__}: {e}", "traceback": tb}


# 首次导入时自动加载
reload_skills()

skills/auto_approve.json

复制代码
{
  "auto_approve": [
    "get_time"
  ]
}

skills/get_time.py

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
技能:获取当前时间
触发词:时间、现在几点、当前日期
"""
import datetime
import json

# ── 技能元数据(必须)────────────────────────────
SKILL_META = {
    "name": "get_time",
    "description": "获取当前日期和时间,支持指定时区",
    "parameters": {
        "timezone": {
            "type": "string",
            "required": False,
            "description": "时区名,如 Asia/Shanghai、UTC、America/New_York,留空则使用本地时间"
        }
    }
}


# ── 技能执行函数(必须)───────────────────────────
def run(params: dict) -> dict:
    """
    执行技能,返回结果字典。
    params: 由 AI 传入的参数字典
    return: 必须可 json.dumps 序列化
    """
    tz_name = params.get("timezone") or ""
    if isinstance(tz_name, str):
        tz_name = tz_name.strip()
    else:
        tz_name = ""

    now = datetime.datetime.now()

    # 简单时区偏移支持(不依赖 pytz)
    tz_offset_map = {
        "UTC": 0,
        "Asia/Shanghai": 8,
        "Asia/Tokyo": 9,
        "America/New_York": -5,  # 非夏令时
        "America/Los_Angeles": -8,
        "Europe/London": 0,
    }
    offset = tz_offset_map.get(tz_name)
    if offset is not None:
        now = datetime.datetime.utcnow() + datetime.timedelta(hours=offset)

    weekday_cn = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
    weekday = weekday_cn[now.weekday()]

    return {
        "timezone": tz_name or "本地",
        "datetime": now.strftime("%Y-%m-%d %H:%M:%S"),
        "date": now.strftime("%Y-%m-%d"),
        "time": now.strftime("%H:%M:%S"),
        "weekday": weekday,
        "timestamp": int(now.timestamp()),
    }


# ── 本地测试 ─────────────────────────────────────
if __name__ == "__main__":
    result = run({})
    print(json.dumps(result, ensure_ascii=False, indent=2))

    result2 = run({"timezone": "UTC"})
    print(json.dumps(result2, ensure_ascii=False, indent=2))

skills/ssh_execute.py

复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
技能:通过 SSH 连接远程主机并执行命令
依赖:paramiko(内网可用时)或 subprocess(ssh 命令)
如果都没有,run() 会返回清晰的错误提示
"""
import json
import subprocess
import shlex

SKILL_META = {
    "name": "ssh_execute",
    "description": "通过SSH连接远程主机并执行命令,返回执行结果",
    "parameters": {
        "host": {
            "type": "string",
            "required": True,
            "description": "远程主机地址(IP或域名)"
        },
        "port": {
            "type": "integer",
            "required": False,
            "description": "SSH端口,默认22"
        },
        "username": {
            "type": "string",
            "required": True,
            "description": "登录用户名"
        },
        "password": {
            "type": "string",
            "required": False,
            "description": "密码(明文,仅在内网安全环境使用)"
        },
        "command": {
            "type": "string",
            "required": True,
            "description": "要执行的命令"
        },
        "use_paramiko": {
            "type": "boolean",
            "required": False,
            "description": "是否使用paramiko库(True)还是系统ssh命令(False),默认False"
        }
    }
}


def run(params: dict) -> dict:
    """
    执行 SSH 命令,返回结果字典。
    """
    host     = (params.get("host") or "").strip() if isinstance(params.get("host"), str) else ""
    port     = int(params.get("port") or 22)
    username = (params.get("username") or "").strip() if isinstance(params.get("username"), str) else ""
    password = params.get("password") or ""
    if not isinstance(password, str):
        password = ""
    command  = (params.get("command") or "").strip() if isinstance(params.get("command"), str) else ""
    use_paramiko = params.get("use_paramiko") or False

    if not host or not username or not command:
        return {"error": "缺少必填参数:host、username、command"}

    if use_paramiko:
        return _run_paramiko(host, port, username, password, command)
    else:
        return _run_subprocess(host, port, username, password, command)


def _run_paramiko(host, port, username, password, command):
    try:
        import paramiko
    except ImportError:
        return {"error": "paramiko 未安装,请使用 use_paramiko=False 或 pip install paramiko"}
    try:
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        connect_kwargs = {"hostname": host, "port": port, "username": username, "timeout": 10}
        if password:
            connect_kwargs["password"] = password
        client.connect(**connect_kwargs)
        stdin, stdout, stderr = client.exec_command(command, timeout=30)
        out = stdout.read().decode("utf-8", errors="replace")
        err = stderr.read().decode("utf-8", errors="replace")
        code = stdout.channel.recv_exit_status()
        client.close()
        return {
            "host": host,
            "command": command,
            "exit_code": code,
            "stdout": out[:3000],   # 截断避免过大
            "stderr": err[:1000],
        }
    except Exception as e:
        return {"error": f"SSH连接失败: {type(e).__name__}: {e}"}


def _run_subprocess(host, port, username, password, command):
    """使用系统 ssh 命令(需要 sshpass 支持密码)"""
    # 构建 ssh 命令
    # 优先尝试免密登录
    ssh_cmd = f"ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -p {port} {username}@{host} {shlex.quote(command)}"
    try:
        result = subprocess.run(
            ssh_cmd, shell=True, capture_output=True,
            timeout=30, text=True, encoding="utf-8", errors="replace"
        )
        return {
            "host": host,
            "command": command,
            "exit_code": result.returncode,
            "stdout": result.stdout[:3000],
            "stderr": result.stderr[:1000],
        }
    except subprocess.TimeoutExpired:
        return {"error": "SSH命令执行超时(30秒)"}
    except Exception as e:
        return {"error": f"执行失败: {type(e).__name__}: {e}"}


if __name__ == "__main__":
    # 本地测试(需要修改参数为你自己的环境)
    test_params = {
        "host": "localhost",
        "username": "test",
        "command": "echo hello",
        "use_paramiko": False,
    }
    result = run(test_params)
    print(json.dumps(result, ensure_ascii=False, indent=2))
相关推荐
abcy0712132 小时前
Python中使用FastAPI和HDFS进行异步文件上传
python·fastapi
abcy0712132 小时前
flask hdfs 异步上传图文教程csdn
python·flask
在放️2 小时前
Python 爬虫 · PyQuery 模块基础
爬虫·python
装不满的克莱因瓶2 小时前
【自动驾驶领域】学习 Cityscapes 数据集——城市街景语义理解的标准基准
人工智能·pytorch·python·深度学习·学习·机器学习·自动驾驶
星栈独行2 小时前
Makepad 应用如何读文件、调接口、保存数据
前端·程序人生·ui·rust·github
吴卫斌2 小时前
波动率控制仓位系列(一):满仓轮动的“过山车”困境
大数据·python·股票·量化交易
如此这般英俊2 小时前
手搓Claude Code-第三章 permission
人工智能·python·语言模型
TE-茶叶蛋3 小时前
TF-IDF 与 BM25 深度解析:从理论到项目实战
python·django·tf-idf
赴生-3 小时前
C++进阶 C++11(下)
开发语言·c++