一、项目结构
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 启动脚本(双击运行)
#!/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()
#!/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)
#!/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("(选择模板)")
#!/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
#!/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
#!/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))