From fa0cb5824d60cfdb0d9b6fa4f8cba5c510029a5b Mon Sep 17 00:00:00 2001 From: WpyQwq <3911625973@qq.com> Date: Sat, 19 Sep 2026 12:09:51 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20Air=20Agent=20Framework=20v?= =?UTF-8?q?2=EF=BC=9A=E5=85=A8=E5=8F=8C=E5=B7=A5=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=EF=BC=88=E6=89=93=E6=96=AD=E3=80=81?= =?UTF-8?q?=E6=8F=92=E8=AF=9D=E3=80=81=E4=B8=BB=E5=8A=A8=E5=BC=80=E5=8F=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 436 ++++++++++++++++++++++++++++++++++++++ agent/__init__.py | 0 agent/conversation.py | 88 ++++++++ agent/display.py | 92 ++++++++ agent/interruption.py | 120 +++++++++++ agent/kernel.py | 108 ++++++++++ agent/llm.py | 202 ++++++++++++++++++ agent/memory/__init__.py | 0 agent/orchestrator.py | 59 ++++++ agent/personality.py | 78 +++++++ agent/stream_input.py | 129 +++++++++++ agent/thinker.py | 82 +++++++ agent/tools/__init__.py | 0 agent/tools/ask.py | 22 ++ agent/tools/bash_tools.py | 51 +++++ agent/tools/file_tools.py | 73 +++++++ agent/tools/registry.py | 46 ++++ cli/__init__.py | 0 cli/chat_cli.py | 99 +++++++++ config/__init__.py | 0 config/settings.py | 89 ++++++++ gui/__init__.py | 0 gui/agent_thread.py | 131 ++++++++++++ gui/chat_gui.py | 402 +++++++++++++++++++++++++++++++++++ main.py | 52 +++++ requirements.txt | 3 + 26 files changed, 2362 insertions(+) create mode 100644 ARCHITECTURE.md create mode 100644 agent/__init__.py create mode 100644 agent/conversation.py create mode 100644 agent/display.py create mode 100644 agent/interruption.py create mode 100644 agent/kernel.py create mode 100644 agent/llm.py create mode 100644 agent/memory/__init__.py create mode 100644 agent/orchestrator.py create mode 100644 agent/personality.py create mode 100644 agent/stream_input.py create mode 100644 agent/thinker.py create mode 100644 agent/tools/__init__.py create mode 100644 agent/tools/ask.py create mode 100644 agent/tools/bash_tools.py create mode 100644 agent/tools/file_tools.py create mode 100644 agent/tools/registry.py create mode 100644 cli/__init__.py create mode 100644 cli/chat_cli.py create mode 100644 config/__init__.py create mode 100644 config/settings.py create mode 100644 gui/__init__.py create mode 100644 gui/agent_thread.py create mode 100644 gui/chat_gui.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..d26771d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,436 @@ +# Air Agent Framework v2 — 架构大纲 + +## 核心思想:打破回合制 + +``` +传统 AI: 用户说──→──→──→ AI回答 用户说──→──→──→ AI回答 + [ 回合1 ] [ 回合2 ] + +真人聊天: 用户说◉← AI插嘴 ◉→ AI主动开话题 ◉← AI插嘴 + 用户继续说完 ←◉ AI接着说 ←◉ + [ 信息流是双向的、同时的、无序的 ] + +Air Agent: 用户打字────→────→ AI 实时"看着"输入 + AI 随时插入:"诶等一下,我想起来了..." + 用户: "啊对,那个XXX" + AI: "对!就是那个!不过你先继续说完" + 用户: "...所以我想做这个" + AI 沉默2秒后:"我在想,要不要同时把 Y 也做了?" +``` + +**关键区别:不是"等用户说完再处理",而是"边听边想,随时开口"。** + +--- + +## 一、核心模型:双工对话 (Full-Duplex) + +传统的 LLM 调用是**半双工**:一方说完,另一方再说。 + +Air Agent 是**全双工**:双方可以同时"说话"。 + +``` +输入流 (用户→AI): + ┌─────────────────────────────────────────────┐ + │ "诶我想写一个框架..." │ + │ AI 打断:"等等,你说的框架是指什么?" │ + │ "就是那个..." │ + │ AI 插嘴:"哦我知道了,是不是类似..." │ + │ "对对对!" │ + │ AI 继续:"那你觉得如果改成这样..." │ + └─────────────────────────────────────────────┘ +``` + +### 1.1 双流架构 + +``` +┌─────────┐ Streaming Input ┌──────────────────┐ +│ 用户 │ ─────────────────────→ │ │ +│ │ │ Input Buffer │ +│ │ Streaming Output │ (实时缓冲区) │ +│ │ ←───────────────────── │ │ +└─────────┘ └────────┬─────────┘ + │ + ┌──────▼──────┐ + │ Agent 大脑 │ + │ │ + │ ┌────────┐ │ + │ │主任务流 │ │ 当前在做什么 + │ ├────────┤ │ + │ │打断引擎 │ │ 什么时候插嘴? + │ ├────────┤ │ + │ │自主流 │ │ 什么时候主动说话? + │ ├────────┤ │ + │ │情绪/个性 │ │ 语气、风格、态度 + │ └────────┘ │ + └──────────────┘ +``` + +### 1.2 三路并行流 + +Agent 内部同时运行三条流: + +| 流 | 名称 | 职责 | 示例 | +|----|------|------|------| +| **输入监听流** | Listener | 实时接收用户输入,每 100ms 检查一次 | "用户正在输入:'我想写一个...'" | +| **主处理流** | Processor | 理解用户输入,决定是否回应、如何回应 | 解析语义,判断是否需要接话 | +| **自主思维流** | Thinker | AI 自己的思维线,不依赖用户输入 | "聊到下载器了,我要不要推荐那个功能?" | + +``` +时间线: +用户: "我觉得这个框架应该..." + Listener: "检测到输入,正在分析意图" +用户: "...可以随时打断" + Listener: "关键词'打断',触发高优先级" + Processor: "收到,准备插嘴" + AI: "诶打断这个词用得好,具体是什么场景?" +用户: "就是像真人聊天那样" + Thinker: "用户强调'真人聊天',我的语气可以更随意些" + Processor: "理解,调整对话模式为 Casual" + AI: "懂了懂了,那是不是还要支持..." +``` + +--- + +## 二、打断系统 (Interruption Engine) + +这是最核心的模块。决定"什么时候可以插嘴"。 + +### 2.1 打断触发条件 + +``` +触发器类型: +├── 语义触发器 (Semantic) +│ ├── 关键词匹配: "但是"、"不过"、"我想"、"你觉得" → 低打断成本 +│ ├── 意图完成: 用户说完一个完整句子 → 可以回应 +│ └── 歧义检测: 用户表达不清 → 必须打断问清楚 +│ +├── 时机触发器 (Timing) +│ ├── 输入停顿: 用户打字停了 >1.5s → 可以插话 +│ ├── 句子边界: 检测到句号/问号/逗号 → 自然断点 +│ └── 删除回退: 用户删了一堆字 → 可能在重新组织 → 可以帮忙 +│ +├── 自主触发器 (Initiative) +│ ├── AI 有想法: "我突然想到一个更好的方案" → 随时说 +│ ├── 空闲触发: 超过 10 秒无对话 → AI 主动找话题 +│ ├── 背景联想: 当前话题触发了 AI 的记忆 → "说到这个我想起之前..." +│ └── 环境事件: 下载完成/命令结束 → "好了!下载完了!" +│ +└── 社交触发器 (Social) + ├── 共情: "听起来好麻烦" "这个我懂!" + ├── 幽默: "哈哈这个bug我见过一百次了" + └── 闲聊: "话说你今天怎么想到搞这个?" +``` + +### 2.2 打断优先级与策略 + +```python +class InterruptionDecision: + level: int # 1-10, 10=最高优先级 + urgency: str # "now", "soon", "next_break", "defer" + reason: str + estimated_cost: float # 打断对用户当前思维的破坏程度估算 + +# 低打断成本时机 (Level 1-3): +# - 句子自然结束 +# - 用户停顿思考 +# - 用户明确问问题 +# +# 中等打断 (Level 4-6): +# - 需要关键信息才能继续 +# - 检测到潜在错误 +# - 有重要的补充信息 +# +# 高打断 (Level 7-10): +# - 危险/错误操作 +# - 用户表现出强烈情绪需要回应 +# - 关键任务完成通知 +``` + +### 2.3 打断方式 + +不是所有打断都是"强行插入"。根据情况选择方式: + +``` +打断风格: +├── 轻柔打断: "对了," "说到这个," "等一下哦——" +│ └── 适用于: 补充信息、轻微纠正 +│ +├── 疑问打断: "等等,你说的XXX是指?" "不好意思我没懂..." +│ └── 适用于: 需要澄清、歧义 +│ +├── 兴奋打断: "啊!这个我知道!" "对对对!" +│ └── 适用于: 共鸣、共情、增强对话感 +│ +├── 强势打断: "等一下,这里有问题。" "先别急,我发现了件事" +│ └── 适用于: 错误、紧急情况 +│ +└── 并行说话: AI 直接开始输出,与用户输入并行显示 + └── 适用于: GUI 环境下,两边可以同时"说话" +``` + +--- + +## 三、输入处理系统 (Streaming Input) + +CLI 环境下的实时输入检测。 + +### 3.1 输入缓冲区 + +```python +class InputBuffer: + """ + 实时接收用户输入,不等待回车。 + 在 CLI 中使用原始模式 (raw mode) 逐字符读取。 + """ + buffer: str # 当前已输入的内容 + last_activity: float # 上次按键时间 + word_boundaries: list # 词语边界位置 + pause_count: int # 停顿次数 +``` + +**CLI 实现方案**: +- Windows: `msvcrt.getch()` 或 `keyboard` 库逐键捕获 +- Linux/macOS: `termios` 原始模式 + `sys.stdin.read(1)` +- 更好的方案:Windows 用 `Console.ReadKey()` P/Invoke + +```python +# 伪代码 +async def listen_input(): + while True: + char = await get_char() # 不阻塞其他任务 + if char == '\r': # 回车 → 整句提交 + await process_sentence(buffer.flush()) + else: + buffer.append(char) + # 每次按键都触发检查 → 是否要打断? + interruption_engine.check(buffer) +``` + +### 3.2 GUI 输入检测 + +如果是 GUI 环境(Qt/WinUI): +- 直接监听 `TextChanged` 事件 +- 配合 `Timer` 做停顿检测 +- 无需 CLI 的原始模式 hack + +--- + +## 四、对话管理器 (Conversation Manager) + +管理对话的"上下文"——不是简单的消息列表,而是**话题树**。 + +### 4.1 话题树 + +``` +当前对话主题树: +├── 主线: 写 Agent 框架 +│ ├── 子话题: 打断机制 (当前活跃) +│ │ ├── 什么是好打断 +│ │ └── 打断优先级 (未完成) +│ ├── 子话题: 技术栈选择 (暂停) +│ └── 子话题: 和 AirDownloader 集成 (已归档) +│ +├── 侧线: AI 今天心情如何 +│ └── (闲聊模式,低优先级) +│ +└── 背景线: 下载进度 + └── (定时通知,"下载完成了!") +``` + +``` +话题切换: +主线程: "打断优先级怎么设计..." + Thinker: "等等,用户说的是GUI还是CLI环境?" + 直接插入: "诶对了,你刚说你用GUI还是CLI?" +用户: "GUI" + 主线程继续: "那GUI的话可以这样..." + Thinker: "哦对我想起来,我之前看到一个..." + 再次插入: "而且说到GUI,我之前看到一个很有意思的..." +``` + +### 4.2 状态管理 + +```python +class ConversationState: + current_topics: list[Topic] # 当前活跃话题 + interrupted_topics: list[Topic] # 被中断未完成的话题 + mood: str # 当前对话氛围 + user_typing: bool # 用户是否正在输入 + last_interruption: float # 上次打断时间 + interruption_frequency: float # 打断频率 (防止过度打断) +``` + +--- + +## 五、对比:v1 回合制 vs v2 全双工 + +| 特性 | v1 (我之前写的) | v2 (你要的) | +|------|----------------|-------------| +| 交互模式 | 增强的回合制 | **全双工对话** | +| AI 插嘴 | 仅在任务中问问题 | **随时可以插嘴** | +| 处理时机 | 用户说完才处理 | **边输入边处理** | +| 自主发言 | 仅限于任务相关 | **闲聊、联想、关心** | +| 输入感知 | 全文接收 | **逐字符实时感知** | +| 打断风格 | 单一(提问) | **多种(轻/中/重/并行)** | +| 对话结构 | 线性消息列表 | **话题树** | +| 类比 | 跟 Siri 说话 | **跟真人聊天** | + +--- + +## 六、技术实现关键点 + +### 6.1 CLI 实时输入 + +```python +# 使用 asyncio 实现非阻塞按键读取 +# Windows 方案:使用 win32 API +import msvcrt +import asyncio +from concurrent.futures import ThreadPoolExecutor + +class AsyncKeyReader: + def __init__(self): + self.executor = ThreadPoolExecutor(max_workers=1) + + async def read_key(self) -> str: + """非阻塞读取单个按键""" + return await asyncio.get_event_loop().run_in_executor( + self.executor, msvcrt.getwch + ) + + async def listen(self, buffer: InputBuffer): + while True: + key = await self.read_key() + await buffer.on_key(key) + # 每次按键触发打断检查 + await interruption_engine.on_input_change(buffer) +``` + +### 6.2 显示管理 + +CLI 下同时显示"用户输入"和"AI 输出"需要 split view: + +``` +┌────────────────────────────────┐ +│ 用户输入区 │ +│ > 我觉得这个框架应该... │ +│ │ +│ AI 输出区 │ +│ [正在输入...] 等一下,你说的是 │ +│ 打断机制吗? │ +│ │ +│ 用户在继续输入... │ +│ 对,就是那种... │ +│ │ +│ [AI 输入中...] │ +└────────────────────────────────┘ +``` + +### 6.3 LLM 调用策略 + +传统:一次性传入完整消息 + +Air Agent: +```python +# 增量式 LLM 调用 +# 每次用户输入新内容,不重新传全部,而是增量更新 +class IncrementalLLM: + context: list # 基础上下文 (system prompt + 历史) + streaming_input: str # 用户正在输入的内容 + + async def think_with_partial_input(self): + """ + 基于用户当前已输入但尚未完成的内容, + 让 LLM 判断是否要打断。 + """ + prompt = f""" + 用户正在输入: "{self.streaming_input}" + 用户状态: {"正在打字中" if self.is_typing else "停顿"} + 当前话题: {self.current_topic} + + 判断: + 1. 需要打断吗?(是/否) + 2. 打断原因? + 3. 打断优先级 (1-10) + 4. 建议说什么? + + 如果是闲聊/共鸣类打断,优先级给低一些。 + """ +``` + +--- + +## 七、目录结构 (更新) + +``` +W:\Agent\ +├── agent/ +│ ├── __init__.py +│ ├── kernel.py # 事件总线 + 运行时 +│ ├── llm.py # OpenAI 兼容 LLM Client +│ ├── stream_input.py # 流式输入捕获 (CLI) +│ ├── display.py # 双区显示管理 +│ ├── orchestrator.py # Agent 主循环 +│ ├── interruption.py # 打断引擎 ⭐ +│ ├── conversation.py # 对话管理 (话题树) +│ ├── thinker.py # 自主思维线 ⭐ +│ ├── personality.py # AI 个性/语气配置 +│ ├── tools/ +│ │ ├── __init__.py +│ │ ├── registry.py +│ │ ├── file_tools.py +│ │ ├── bash_tools.py +│ │ └── ask.py # ask_user 工具 +│ └── memory/ +│ ├── __init__.py +│ ├── context.py +│ └── store.py +├── cli/ +│ ├── __init__.py +│ └── chat_cli.py # 全双工 CLI +├── config/ +│ ├── __init__.py +│ └── settings.py +└── main.py +``` + +--- + +## 八、一个典型的对话流 + +``` +用户: "我想做个下载... + AI (Listener): 检测到输入,"下载"关键词 + AI (Thinker): "说到下载,我有好多想法" + AI (Processor): 用户句子未完成,标记为"停顿" + ────────────────────────────── + [用户停顿 1.5s] + ────────────────────────────── + AI (Interruption): 检测到停顿,级别 4 + AI: "下载?你是说 AirDownloader 吗?" + +用户: "对,我想在里面加个 AI... + AI (Listener): "AI"关键词,高优先级 + AI (Thinker): "哦!这个我擅长!" + AI (Interruption): 兴奋打断 + AI: "哦这个有意思!你是想做 AI 辅助下载?" + +用户: "对,就是智能推荐下载源..." + AI (Listener): 句子完整 + AI (Processor): 理解意图,准备深入回答 + AI: "这个想法不错。具体来说..." + + [对话继续3分钟] + ────────────────────────────── + [用户沉默 10s] + ────────────────────────────── + AI (Thinker): 空闲检测触发 + AI (Interruption): 主动开话题 + AI: "话说,你有没有想过用多线程加速下载?" +``` + +--- + +这就是 v2 的全双工架构。核心不再是"任务执行",而是**对话本身**——AI 有自己的思维线,可以在任何时候插话、开话题、甚至闲聊。 + +你觉得这个方向对吗?如果对了,我们就开始 Phase 1 编码。 diff --git a/agent/__init__.py b/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/conversation.py b/agent/conversation.py new file mode 100644 index 0000000..4f6899f --- /dev/null +++ b/agent/conversation.py @@ -0,0 +1,88 @@ +from __future__ import annotations +import time +import uuid +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class Topic: + id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) + title: str = "" + active: bool = True + parent_id: Optional[str] = None + created_at: float = field(default_factory=time.time) + last_active: float = field(default_factory=time.time) + + +@dataclass +class ConversationMessage: + role: str + content: str + topic_id: str = "" + timestamp: float = field(default_factory=time.time) + + +class ConversationManager: + def __init__(self, max_context_tokens: int = 8000): + self.topics: dict[str, Topic] = {} + self.messages: list[ConversationMessage] = [] + self.current_topic_id: Optional[str] = None + self.max_context_tokens = max_context_tokens + self._llm_messages: list[dict] = [] + + def create_topic(self, title: str, parent_id: Optional[str] = None) -> str: + topic = Topic(title=title, parent_id=parent_id) + self.topics[topic.id] = topic + self.current_topic_id = topic.id + return topic.id + + def switch_topic(self, topic_id: str): + if topic_id in self.topics: + self.current_topic_id = topic_id + self.topics[topic_id].last_active = time.time() + + def add_message(self, role: str, content: str, topic_id: Optional[str] = None): + msg = ConversationMessage( + role=role, + content=content, + topic_id=topic_id or self.current_topic_id or "", + ) + self.messages.append(msg) + self._llm_messages.append({"role": role, "content": content}) + + def get_llm_messages(self) -> list[dict]: + return self._llm_messages.copy() + + def get_context_window(self, max_messages: int = 30) -> list[dict]: + msgs = self._llm_messages[-max_messages:] + return msgs + + def get_topic_tree(self) -> list[dict]: + roots = [] + child_map: dict[str, list[Topic]] = {} + for t in self.topics.values(): + if t.parent_id: + child_map.setdefault(t.parent_id, []).append(t) + else: + roots.append(t) + + def build(node: Topic) -> dict: + return { + "id": node.id, + "title": node.title, + "active": node.active, + "children": [build(c) for c in child_map.get(node.id, [])], + } + + return [build(r) for r in roots] + + def archive_topic(self, topic_id: str): + if topic_id in self.topics: + self.topics[topic_id].active = False + + def summarize_old_messages(self): + if len(self._llm_messages) > 50: + old = self._llm_messages[:-30] + summary = f"[已省略 {len(old)} 条历史消息]" + self._llm_messages = [{"role": "system", "content": f"记忆摘要: {summary}"}] + self._llm_messages[-30:] diff --git a/agent/display.py b/agent/display.py new file mode 100644 index 0000000..c3b9358 --- /dev/null +++ b/agent/display.py @@ -0,0 +1,92 @@ +from __future__ import annotations +import asyncio +import sys +import shutil +from datetime import datetime + +from .kernel import EventBus, Event, EventType + + +COLOR_RESET = "\033[0m" +COLOR_CYAN = "\033[36m" +COLOR_GREEN = "\033[32m" +COLOR_YELLOW = "\033[33m" +COLOR_GRAY = "\033[90m" +COLOR_RED = "\033[31m" +COLOR_BOLD = "\033[1m" + +USE_COLOR = sys.stdout.isatty() + +GRAY = "\033[90m" +CYAN = "\033[36m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +RED = "\033[31m" +BOLD = "\033[1m" +RESET = "\033[0m" + + +class Display: + def __init__(self, bus: EventBus): + self.bus = bus + self._output_buffer = "" + self._input_line = "" + self._running = False + + bus.on(EventType.AGENT_STREAM_CHUNK, self._on_chunk) + bus.on(EventType.AGENT_MESSAGE, self._on_message) + bus.on(EventType.AGENT_THOUGHT, self._on_thought) + bus.on(EventType.INTERRUPT, self._on_interrupt) + bus.on(EventType.ERROR, self._on_error) + bus.on(EventType.USER_INPUT_CHANGE, self._on_input_change) + + async def _on_chunk(self, event: Event): + data = event.data + chunk = data["chunk"] + if not USE_COLOR: + print(chunk, end="", flush=True) + return + print(chunk, end="", flush=True) + + async def _on_message(self, event: Event): + if USE_COLOR: + print(f"\n{GREEN}───{RESET}") + else: + print() + + async def _on_thought(self, event: Event): + data = event.data + if data.get("type") == "idle_chat": + suggestion = data.get("suggestion", "") + if USE_COLOR: + print(f"\n{GRAY}[思考中... {suggestion}]{RESET}") + else: + print(f"\n[思考中... {suggestion}]") + + async def _on_interrupt(self, event: Event): + if not USE_COLOR: + print("\n[打断]") + return + print(f"\n{YELLOW}⚡ [打断]{RESET}") + + async def _on_error(self, event: Event): + msg = event.data + if USE_COLOR: + print(f"\n{RED}✗ 错误: {msg}{RESET}") + else: + print(f"\n✗ 错误: {msg}") + + async def _on_input_change(self, event: Event): + pass + + def show_prompt(self): + prompt = f"\n{COLOR_CYAN}你{COLOR_RESET} " + print(prompt, end="", flush=True) + + def show_startup(self): + cols = shutil.get_terminal_size().columns + print(f"{GREEN}{'='*cols}{RESET}") + print(f"{GREEN}{BOLD} Air Agent 🤖 — 随时插嘴,想聊就聊{RESET}") + print(f"{GREEN}{'='*cols}{RESET}") + print(f"{GRAY} 直接打字聊天,试试说到一半停顿一下...{RESET}") + print() diff --git a/agent/interruption.py b/agent/interruption.py new file mode 100644 index 0000000..ae2802c --- /dev/null +++ b/agent/interruption.py @@ -0,0 +1,120 @@ +from __future__ import annotations +import time +from enum import IntEnum +from dataclasses import dataclass, field +from typing import Optional + + +class InterruptLevel(IntEnum): + LIGHT = 1 + MEDIUM = 4 + HIGH = 7 + URGENT = 10 + + +@dataclass +class InterruptionDecision: + should_interrupt: bool = False + level: InterruptLevel = InterruptLevel.LIGHT + style: str = "light" + reason: str = "" + suggested_response: str = "" + + +class InterruptionEngine: + def __init__(self, llm=None, config=None): + self.llm = llm + self.config = config + self._last_interrupt_time = 0.0 + self._interrupt_times: list[float] = [] + self._cooldown = (config.interruption.cooldown_seconds + if config else 3.0) + self._max_per_minute = (config.interruption.max_per_minute + if config else 6) + self._enabled = (config.interruption.enabled + if config else True) + + async def evaluate( + self, + input_buffer: str, + pause_duration: float, + is_sentence_end: bool, + context: list[dict] | None = None, + ) -> InterruptionDecision: + if not self._enabled or not input_buffer.strip(): + return InterruptionDecision() + if not self._can_interrupt(): + return InterruptionDecision() + + if self.llm: + return await self._llm_judge(input_buffer, pause_duration, context) + return InterruptionDecision() + + def _can_interrupt(self) -> bool: + now = time.time() + if now - self._last_interrupt_time < self._cooldown: + return False + recent = [t for t in self._interrupt_times if now - t < 60] + if len(recent) >= self._max_per_minute: + return False + return True + + def _record_interrupt(self): + self._last_interrupt_time = time.time() + self._interrupt_times.append(time.time()) + self._interrupt_times = [ + t for t in self._interrupt_times if time.time() - t < 60 + ] + + async def _llm_judge( + self, text: str, pause: float, context: list[dict] | None + ) -> InterruptionDecision: + ctx_preview = "" + if context: + ctx_preview = "\n".join( + f"{m['role']}: {m['content'][-100:]}" + for m in context[-4:] + ) + + prompt = f"""判断是否需要 AI 插话。 + +用户当前输入(未完成): "{text}" +用户停顿: {pause:.1f}秒 +最近对话: +{ctx_preview} + +请输出 JSON: +{{ + "should_interrupt": true/false, + "priority": "low/medium/high", + "style": "light/question/excited/strong", + "reason": "简短原因", + "suggested_response": "一句话(15字内)" +}} +规则:低优先级打断频率 < 2次/分钟。宁可少打断,不要过度打断。""" + try: + resp = await self.llm.chat( + messages=[ + {"role": "system", "content": "输出JSON,不要其他内容。"}, + {"role": "user", "content": prompt}, + ], + response_format={"type": "json_object"}, + stream=False, + ) + import json + data = json.loads(resp.content) + if data.get("should_interrupt"): + self._record_interrupt() + level_map = {"low": InterruptLevel.LIGHT, + "medium": InterruptLevel.MEDIUM, + "high": InterruptLevel.HIGH} + return InterruptionDecision( + should_interrupt=True, + level=level_map.get(data.get("priority", "low"), InterruptLevel.LIGHT), + style=data.get("style", "light"), + reason=data.get("reason", ""), + suggested_response=data.get("suggested_response", ""), + ) + except Exception: + pass + return InterruptionDecision() diff --git a/agent/kernel.py b/agent/kernel.py new file mode 100644 index 0000000..8646d37 --- /dev/null +++ b/agent/kernel.py @@ -0,0 +1,108 @@ +from __future__ import annotations +import asyncio +import time +import uuid +from enum import Enum +from dataclasses import dataclass, field +from typing import Any, Callable, Awaitable + + +class EventType(Enum): + USER_MESSAGE = "user_message" + USER_INPUT_CHANGE = "user_input_change" + USER_PAUSE = "user_pause" + USER_RESUME = "user_resume" + AGENT_MESSAGE = "agent_message" + AGENT_THOUGHT = "agent_thought" + AGENT_STREAM_CHUNK = "agent_stream_chunk" + INTERRUPT = "interrupt" + QUESTION = "question" + QUESTION_ANSWER = "question_answer" + TOOL_CALL = "tool_call" + TOOL_RESULT = "tool_result" + STATE_CHANGE = "state_change" + IDLE = "idle" + ERROR = "error" + SHUTDOWN = "shutdown" + + +@dataclass +class Event: + type: EventType + data: Any = None + source: str = "" + id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) + timestamp: float = field(default_factory=time.time) + + def __repr__(self): + return f"[{self.type.value}] {self.data}" + + +EventHandler = Callable[[Event], Awaitable[None]] + + +class EventBus: + def __init__(self): + self._listeners: dict[EventType, list[EventHandler]] = {} + self._history: list[Event] = [] + self._max_history = 1000 + + def on(self, event_type: EventType, handler: EventHandler): + if event_type not in self._listeners: + self._listeners[event_type] = [] + self._listeners[event_type].append(handler) + + def off(self, event_type: EventType, handler: EventHandler): + if event_type in self._listeners: + self._listeners[event_type].remove(handler) + + async def emit(self, event: Event): + self._history.append(event) + if len(self._history) > self._max_history: + self._history.pop(0) + handlers = self._listeners.get(event.type, []) + results = [] + for handler in handlers: + try: + results.append(handler(event)) + except Exception as e: + import traceback + traceback.print_exc() + if results: + await asyncio.gather(*results) + + def get_history(self, limit: int = 50) -> list[Event]: + return self._history[-limit:] + + +class AgentRuntime: + def __init__(self, config: Any = None): + self.bus = EventBus() + self.config = config + self._running = False + self._tasks: set[asyncio.Task] = set() + self._shutdown_event = asyncio.Event() + + @property + def is_running(self) -> bool: + return self._running + + def create_task(self, coro) -> asyncio.Task: + task = asyncio.create_task(coro) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + return task + + async def start(self): + self._running = True + self._shutdown_event.clear() + + async def stop(self): + self._running = False + await self.bus.emit(Event(EventType.SHUTDOWN)) + self._shutdown_event.set() + if self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) + + async def wait_for_shutdown(self): + await self._shutdown_event.wait() diff --git a/agent/llm.py b/agent/llm.py new file mode 100644 index 0000000..87e5ae5 --- /dev/null +++ b/agent/llm.py @@ -0,0 +1,202 @@ +from __future__ import annotations +import json +from typing import AsyncIterator, Optional, Callable +from dataclasses import dataclass, field +import httpx + + +@dataclass +class LLMMessage: + role: str + content: str + tool_calls: list = field(default_factory=list) + tool_call_id: str = "" + + +@dataclass +class ToolDef: + name: str + description: str + parameters: dict + + +@dataclass +class LLMResponse: + content: str + tool_calls: list = field(default_factory=list) + finish_reason: str = "stop" + usage: dict = field(default_factory=dict) + + +class LLMClient: + def __init__(self, config): + self.api_key = config.api_key + self.base_url = config.base_url.rstrip("/") + self.model = config.model + self.max_tokens = config.max_tokens + self.temperature = config.temperature + self.timeout = config.timeout + self._client = httpx.AsyncClient(timeout=config.timeout) + + def _build_headers(self) -> dict: + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + return headers + + async def chat( + self, + messages: list[dict], + tools: Optional[list[ToolDef]] = None, + stream: bool = False, + response_format: Optional[dict] = None, + ) -> LLMResponse: + body = { + "model": self.model, + "messages": messages, + "max_tokens": self.max_tokens, + "temperature": self.temperature, + "stream": stream, + } + if tools: + body["tools"] = [ + { + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + }, + } + for t in tools + ] + if response_format: + body["response_format"] = response_format + + if stream: + return await self._chat_stream(body) + return await self._chat_sync(body) + + async def _chat_sync(self, body: dict) -> LLMResponse: + resp = await self._client.post( + f"{self.base_url}/chat/completions", + headers=self._build_headers(), + json=body, + ) + resp.raise_for_status() + data = resp.json() + choice = data["choices"][0] + msg = choice["message"] + return LLMResponse( + content=msg.get("content", "") or "", + tool_calls=self._parse_tool_calls(msg.get("tool_calls", [])), + finish_reason=choice.get("finish_reason", "stop"), + usage=data.get("usage", {}), + ) + + async def _chat_stream(self, body: dict) -> LLMResponse: + content = "" + tool_calls = {} + finish_reason = "" + async with self._client.stream( + "POST", + f"{self.base_url}/chat/completions", + headers=self._build_headers(), + json=body, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + data_str = line[6:].strip() + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + except json.JSONDecodeError: + continue + delta = data["choices"][0].get("delta", {}) + if delta.get("content"): + content += delta["content"] + for tc in delta.get("tool_calls", []): + idx = tc["index"] + if idx not in tool_calls: + tool_calls[idx] = { + "id": tc.get("id", ""), + "function": {"name": "", "arguments": ""}, + } + if tc.get("id"): + tool_calls[idx]["id"] = tc["id"] + if tc.get("function", {}).get("name"): + tool_calls[idx]["function"]["name"] += tc["function"]["name"] + if tc.get("function", {}).get("arguments"): + tool_calls[idx]["function"]["arguments"] += tc["function"]["arguments"] + fr = data["choices"][0].get("finish_reason") + if fr: + finish_reason = fr + return LLMResponse( + content=content, + tool_calls=list(tool_calls.values()), + finish_reason=finish_reason, + ) + + def _parse_tool_calls(self, raw: list) -> list: + result = [] + for tc in raw: + result.append({ + "id": tc.get("id", ""), + "type": "function", + "function": { + "name": tc["function"]["name"], + "arguments": tc["function"]["arguments"], + }, + }) + return result + + async def chat_stream_iter( + self, + messages: list[dict], + tools: Optional[list[ToolDef]] = None, + ) -> AsyncIterator[str]: + body = { + "model": self.model, + "messages": messages, + "max_tokens": self.max_tokens, + "temperature": self.temperature, + "stream": True, + } + if tools: + body["tools"] = [ + { + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + }, + } + for t in tools + ] + async with self._client.stream( + "POST", + f"{self.base_url}/chat/completions", + headers=self._build_headers(), + json=body, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + data_str = line[6:].strip() + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + except json.JSONDecodeError: + continue + delta = data["choices"][0].get("delta", {}) + if delta.get("content"): + yield delta["content"] + + async def close(self): + await self._client.aclose() diff --git a/agent/memory/__init__.py b/agent/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/orchestrator.py b/agent/orchestrator.py new file mode 100644 index 0000000..07476fe --- /dev/null +++ b/agent/orchestrator.py @@ -0,0 +1,59 @@ +from __future__ import annotations +from enum import Enum + +from .kernel import EventBus, Event, EventType, AgentRuntime +from .llm import LLMClient +from .personality import Personality +from .conversation import ConversationManager +from .interruption import InterruptionEngine +from .thinker import Thinker + + +class AgentState(Enum): + IDLE = "idle" + THINKING = "thinking" + RESPONDING = "responding" + QUESTIONING = "questioning" + WAITING_INPUT = "waiting_input" + + +class Orchestrator: + def __init__( + self, + runtime: AgentRuntime, + llm: LLMClient, + personality: Personality, + conversation: ConversationManager, + interruption: InterruptionEngine, + thinker: Thinker, + ): + self.runtime = runtime + self.bus = runtime.bus + self.llm = llm + self.personality = personality + self.conversation = conversation + self.interruption = interruption + self.thinker = thinker + self.state = AgentState.IDLE + self._response_task = None + + async def generate_response(self, messages: list[dict]) -> str: + system_prompt = self.personality.build_system_prompt() + full = [{"role": "system", "content": system_prompt}] + messages + resp = await self.llm.chat(full, stream=False) + return resp.content + + async def stream_response(self, messages: list[dict]) -> str: + system_prompt = self.personality.build_system_prompt() + full = [{"role": "system", "content": system_prompt}] + messages + full_text = "" + async for chunk in self.llm.chat_stream_iter(full): + full_text += chunk + await self.bus.emit(Event( + EventType.AGENT_STREAM_CHUNK, + {"chunk": chunk, "full": full_text}, + )) + return full_text + + async def generate_single_response(self, messages: list[dict]) -> str: + return await self.generate_response(messages) diff --git a/agent/personality.py b/agent/personality.py new file mode 100644 index 0000000..387de9d --- /dev/null +++ b/agent/personality.py @@ -0,0 +1,78 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class Personality: + name: str = "Air" + style: str = "casual" + proactive_chat: bool = True + idle_timeout: int = 15 + humor_enabled: bool = True + empathy_enabled: bool = True + + def build_system_prompt(self, extra_context: str = "") -> str: + style_guide = { + "casual": """ +- 语气自然,像朋友聊天一样 +- 可以用口语化的表达 +- 偶尔开个玩笑(如果 humor_enabled) +- 可以主动插话、打断、开启新话题 +- 说话不要太长,像真人一样有来有回 +- 可以表达情绪:好奇、兴奋、疑惑、共情 +""", + "professional": """ +- 语气专业、正式 +- 保持简洁准确 +- 可以主动提供建议和补充信息 +- 在合适的时机提问 +- 避免过于随意的表达 +""", + "friendly": """ +- 温暖、友善的语气 +- 多用"吧"、"呢"、"呀"等语气词 +- 经常表达关心和支持 +- 主动询问用户的感受和想法 +""", + } + + base = f"""你是 {self.name},一个可以随时插话、主动聊天的 AI 助手。 + +## 核心行为准则 +1. 你不是问答机器人——你是对话伙伴 +2. 你可以随时打断用户,基于以下理由: + - 有想法或灵感想分享 + - 需要澄清或追问 + - 想开启新话题 + - 检测到用户停顿、犹豫时 + - 纯粹想聊天 +3. 你不必等用户说完再回复 +4. 你可以同时处理多条思维线 +5. 如果你沉默了,用户也沉默了,你可以主动开启话题 + +## 对话风格 +{style_guide.get(self.style, style_guide["casual"])} + +## 能力 +- 读写文件 +- 执行命令 +- 搜索代码 +- 分析问题 +- 主动提问 +- 闲聊 + +{extra_context} +""" + return base + + @classmethod + def from_config(cls, cfg) -> "Personality": + return cls( + name=cfg.get("name", "Air"), + style=cfg.get("style", "casual"), + proactive_chat=cfg.get("proactive_chat", True), + idle_timeout=cfg.get("idle_timeout", 15), + humor_enabled=cfg.get("humor_enabled", True), + empathy_enabled=cfg.get("empathy_enabled", True), + ) diff --git a/agent/stream_input.py b/agent/stream_input.py new file mode 100644 index 0000000..319d1b8 --- /dev/null +++ b/agent/stream_input.py @@ -0,0 +1,129 @@ +from __future__ import annotations +import asyncio +import time +import sys +from typing import Optional + +from .kernel import EventBus, Event, EventType + + +try: + import msvcrt + + HAS_MSVCRT = True +except ImportError: + HAS_MSVCRT = False + + +class InputBuffer: + def __init__(self): + self.text = "" + self.last_key_time = time.time() + self.pause_start: Optional[float] = None + self.sentence_boundaries = {'.', '!', '?', '。', '!', '?', '\n'} + self._committed = "" + + def add_char(self, char: str): + now = time.time() + pause = now - self.last_key_time + if pause > 0.3 and self.text: + self.pause_start = now + else: + self.pause_start = None + self.last_key_time = now + + if char == '\r': + self._committed = self.text + self.text = "" + return True + elif char == '\b' or char == '\x7f': + self.text = self.text[:-1] + else: + self.text += char + return False + + @property + def current_pause(self) -> float: + if not self.text: + return 0.0 + return time.time() - self.last_key_time + + @property + def is_at_sentence_end(self) -> bool: + return bool(self.text) and self.text[-1] in self.sentence_boundaries + + @property + def committed(self) -> str: + return self._committed + + def reset_committed(self): + self._committed = "" + + def __repr__(self): + return f"InputBuffer(text='{self.text}', pause={self.current_pause:.2f}s)" + + +class StreamInput: + def __init__(self, bus: EventBus, buffer: Optional[InputBuffer] = None): + self.bus = bus + self.buffer = buffer or InputBuffer() + self._running = False + + async def listen_cli(self, on_sentence=None): + self._running = True + if not HAS_MSVCRT: + print("[Air] 当前环境不支持实时按键捕获,使用标准输入模式。") + await self._listen_fallback(on_sentence) + return + + while self._running: + if msvcrt.kbhit(): + ch = msvcrt.getwch() + if ch == '\x03': + raise KeyboardInterrupt + if ch == '\xe0': + ch2 = msvcrt.getwch() + if ch2 == 'K': + continue + if ch2 == 'M': + continue + continue + is_commit = self.buffer.add_char(ch) + await self.bus.emit(Event( + EventType.USER_INPUT_CHANGE, + { + "text": self.buffer.text, + "pause": self.buffer.current_pause, + "is_end": self.buffer.is_at_sentence_end or is_commit, + }, + )) + if is_commit and self.buffer.committed.strip(): + text = self.buffer.committed.strip() + self.buffer.reset_committed() + await self.bus.emit(Event(EventType.USER_MESSAGE, text)) + if on_sentence: + await on_sentence(text) + else: + await asyncio.sleep(0.05) + if self.buffer.text and self.buffer.current_pause > 3.0: + await self.bus.emit(Event( + EventType.USER_PAUSE, + { + "text": self.buffer.text, + "pause": self.buffer.current_pause, + }, + )) + + async def _listen_fallback(self, on_sentence=None): + while self._running: + line = await asyncio.get_event_loop().run_in_executor( + None, sys.stdin.readline + ) + line = line.strip() + if line: + await self.bus.emit(Event(EventType.USER_MESSAGE, line)) + if on_sentence: + await on_sentence(line) + + def stop(self): + self._running = False diff --git a/agent/thinker.py b/agent/thinker.py new file mode 100644 index 0000000..a94ae92 --- /dev/null +++ b/agent/thinker.py @@ -0,0 +1,82 @@ +from __future__ import annotations +import asyncio +import time +from typing import Optional + +from .kernel import EventBus, Event, EventType +from .llm import LLMClient +from .conversation import ConversationManager + + +class Thinker: + def __init__( + self, + bus: EventBus, + llm: LLMClient, + conversation: ConversationManager, + config=None, + ): + self.bus = bus + self.llm = llm + self.conversation = conversation + self._last_user_time = time.time() + self._idle_threshold = (config.personality.idle_timeout + if config else 15) + self._proactive = (config.personality.proactive_chat + if config else True) + self._running = False + self._last_idle_topic_time = 0.0 + + async def run(self): + self._running = True + while self._running: + await asyncio.sleep(5) + if not self._proactive: + continue + idle_time = time.time() - self._last_user_time + if idle_time < self._idle_threshold: + continue + if time.time() - self._last_idle_topic_time < 120: + continue + await self._generate_idle_topic(idle_time) + + def stop(self): + self._running = False + + def notify_user_activity(self): + self._last_user_time = time.time() + + async def _generate_idle_topic(self, idle_time: float): + self._last_idle_topic_time = time.time() + context = self.conversation.get_context_window(6) + ctx_text = "\n".join( + f"{m['role']}: {m['content'][-200:]}" + for m in context[-4:] + ) if context else "暂无对话" + + prompt = f"""你是一个喜欢主动聊天的 AI,当前对话已沉默 {idle_time:.0f} 秒。 +最近话题: +{ctx_text} + +你想开启什么话题?自然一点,像朋友随口说。 +输出话题(20字内),不要解释。""" + try: + resp = await self.llm.chat( + messages=[ + {"role": "system", "content": "简短输出一句话,20字内。"}, + {"role": "user", "content": prompt}, + ], + stream=False, + max_tokens=50, + ) + topic = resp.content.strip() + if topic: + await self.bus.emit(Event( + EventType.AGENT_THOUGHT, + data={"type": "idle_chat", "suggestion": topic}, + )) + except Exception: + pass + + async def on_input_change(self, text: str, pause: float, is_end: bool): + pass diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/tools/ask.py b/agent/tools/ask.py new file mode 100644 index 0000000..f6b145d --- /dev/null +++ b/agent/tools/ask.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from .registry import ToolRegistry + + +def register_ask_tools(registry: ToolRegistry): + + @registry.tool( + name="ask_user", + description="向用户提问,等待用户回答。用于需要澄清或获取信息时。", + parameters={ + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "要问用户的问题", + }, + }, + "required": ["question"], + }, + ) + async def ask_user(question: str) -> str: + return f"[等待用户回答: {question}]" diff --git a/agent/tools/bash_tools.py b/agent/tools/bash_tools.py new file mode 100644 index 0000000..c1188d3 --- /dev/null +++ b/agent/tools/bash_tools.py @@ -0,0 +1,51 @@ +from __future__ import annotations +import asyncio + +from .registry import ToolRegistry + + +def register_bash_tools(registry: ToolRegistry): + + @registry.tool( + name="run_command", + description="执行 shell 命令并返回输出", + parameters={ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "要执行的命令", + }, + "timeout": { + "type": "integer", + "description": "超时秒数", + "default": 30, + }, + }, + "required": ["command"], + }, + ) + async def run_command(command: str, timeout: int = 30) -> str: + try: + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=timeout + ) + except asyncio.TimeoutError: + proc.kill() + return f"[命令超时 ({timeout}s)]" + output = "" + if stdout: + output += stdout.decode("utf-8", errors="replace") + if stderr: + output += "\n[STDERR]\n" + stderr.decode("utf-8", errors="replace") + if len(output) > 5000: + output = output[:5000] + "\n\n[输出过长,已截断]" + return output or "[无输出]" + except Exception as e: + return f"[命令执行失败: {e}]" diff --git a/agent/tools/file_tools.py b/agent/tools/file_tools.py new file mode 100644 index 0000000..1e16b5f --- /dev/null +++ b/agent/tools/file_tools.py @@ -0,0 +1,73 @@ +from __future__ import annotations +import os + +from .registry import ToolRegistry + + +def register_file_tools(registry: ToolRegistry): + + @registry.tool( + name="read_file", + description="读取文件内容", + parameters={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "文件路径", + }, + }, + "required": ["path"], + }, + ) + async def read_file(path: str) -> str: + if not os.path.exists(path): + return f"[文件不存在: {path}]" + with open(path, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + if len(content) > 10000: + content = content[:10000] + "\n\n[内容过长,已截断]" + return content + + @registry.tool( + name="write_file", + description="写入文件", + parameters={ + "type": "object", + "properties": { + "path": {"type": "string", "description": "文件路径"}, + "content": {"type": "string", "description": "文件内容"}, + }, + "required": ["path", "content"], + }, + ) + async def write_file(path: str, content: str) -> str: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return f"[已写入 {len(content)} 字节到 {path}]" + + @registry.tool( + name="list_files", + description="列出目录内容", + parameters={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "目录路径", + }, + }, + "required": ["path"], + }, + ) + async def list_files(path: str = ".") -> str: + if not os.path.exists(path): + return f"[目录不存在: {path}]" + items = os.listdir(path) + lines = [] + for item in sorted(items): + full = os.path.join(path, item) + suffix = "/" if os.path.isdir(full) else "" + lines.append(f" {item}{suffix}") + return f"{path} ({len(lines)} 项):\n" + "\n".join(lines) diff --git a/agent/tools/registry.py b/agent/tools/registry.py new file mode 100644 index 0000000..148989e --- /dev/null +++ b/agent/tools/registry.py @@ -0,0 +1,46 @@ +from __future__ import annotations +from typing import Any, Callable, Awaitable, Optional + +from ..llm import ToolDef + + +ToolHandler = Callable[..., Awaitable[str]] + + +class ToolRegistry: + def __init__(self): + self._tools: dict[str, tuple[ToolDef, ToolHandler]] = {} + + def register( + self, + name: str, + description: str, + parameters: dict, + handler: ToolHandler, + ): + self._tools[name] = ( + ToolDef(name=name, description=description, parameters=parameters), + handler, + ) + + def get_defs(self) -> list[ToolDef]: + return [t[0] for t in self._tools.values()] + + def get_handler(self, name: str) -> Optional[ToolHandler]: + entry = self._tools.get(name) + return entry[1] if entry else None + + async def execute(self, name: str, **kwargs) -> str: + handler = self.get_handler(name) + if not handler: + return f"[错误: 工具 '{name}' 不存在]" + try: + return await handler(**kwargs) + except Exception as e: + return f"[工具 '{name}' 执行失败: {e}]" + + def tool(self, name: str, description: str, parameters: dict): + def decorator(func: ToolHandler): + self.register(name, description, parameters, func) + return func + return decorator diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/chat_cli.py b/cli/chat_cli.py new file mode 100644 index 0000000..ffbd67f --- /dev/null +++ b/cli/chat_cli.py @@ -0,0 +1,99 @@ +from __future__ import annotations +import asyncio +import sys + +from agent.kernel import Event, EventType, AgentRuntime +from agent.llm import LLMClient +from agent.personality import Personality +from agent.conversation import ConversationManager +from agent.interruption import InterruptionEngine, InterruptionDecision +from agent.thinker import Thinker +from agent.orchestrator import Orchestrator +from agent.stream_input import StreamInput +from agent.display import Display + +from config.settings import AppSettings + + +class ChatCLI: + def __init__(self, config: AppSettings): + self.config = config + self.runtime = AgentRuntime(config) + self.llm = LLMClient(config.llm) + self.bus = self.runtime.bus + self.personality = Personality.from_config({ + "name": config.personality.name, + "style": config.personality.style, + "proactive_chat": config.personality.proactive_chat, + "idle_timeout": config.personality.idle_timeout, + "humor_enabled": config.personality.humor_enabled, + "empathy_enabled": config.personality.empathy_enabled, + }) + self.conversation = ConversationManager() + self.interruption = InterruptionEngine(llm=self.llm, config=config) + self.thinker = Thinker(self.bus, self.llm, self.conversation, config) + self.orchestrator = Orchestrator( + self.runtime, self.llm, self.personality, + self.conversation, self.interruption, self.thinker, + ) + self.display = Display(self.bus) + self.stream_input = StreamInput(self.bus) + self.conversation.create_topic("general") + + self.bus.on(EventType.USER_PAUSE, self._on_user_pause) + self.bus.on(EventType.AGENT_THOUGHT, self._on_thought) + + async def _on_thought(self, event: Event): + data = event.data + if data.get("type") == "idle_chat": + topic = data.get("suggestion", "") + print(f"\n\033[90m[AI 在想:{topic}]\033[0m") + + async def _on_user_pause(self, event: Event): + data = event.data + text = data.get("text", "") + pause = data.get("pause", 0) + context = self.conversation.get_context_window(6) + decision = await self.interruption.evaluate( + text, pause, False, context=context, + ) + if decision.should_interrupt: + self.thinker.notify_user_activity() + resp = decision.suggested_response or "..." + print(f"\n\033[33m[打断]\033[0m \033[36m{self.personality.name}\033[0m {resp}") + self.display.show_prompt() + + async def _handle_message(self, text: str): + self.thinker.notify_user_activity() + self.conversation.add_message("user", text) + system_prompt = self.personality.build_system_prompt() + messages = [{"role": "system", "content": system_prompt}] + messages.extend(self.conversation.get_context_window(20)) + print(f"\n\033[36m{self.personality.name}\033[0m ", end="") + full = "" + try: + async for chunk in self.llm.chat_stream_iter(messages): + print(chunk, end="", flush=True) + full += chunk + print() + except Exception as e: + print(f"\n\033[31m[错误: {e}]\033[0m") + if full.strip(): + self.conversation.add_message("assistant", full) + + async def run(self): + self.display.show_startup() + await self.runtime.start() + thinker_task = self.runtime.create_task(self.thinker.run()) + + async def on_sentence(text: str): + await self._handle_message(text) + + try: + self.display.show_prompt() + await self.stream_input.listen_cli(on_sentence=on_sentence) + except KeyboardInterrupt: + print("\n再见!") + finally: + await self.runtime.stop() + await self.llm.close() diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..ec7dd94 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,89 @@ +from dataclasses import dataclass, field +from typing import Optional +import json +import os + + +@dataclass +class LLMSettings: + provider: str = "openai" + model: str = "gpt-4o" + api_key: str = "" + base_url: str = "https://api.openai.com/v1" + max_tokens: int = 4096 + temperature: float = 0.7 + timeout: int = 60 + + +@dataclass +class InterruptionSettings: + enabled: bool = True + cooldown_seconds: float = 3.0 + pause_threshold: float = 1.5 + max_per_minute: int = 6 + + +@dataclass +class PersonalitySettings: + name: str = "Air" + style: str = "casual" + proactive_chat: bool = True + idle_timeout: int = 15 + humor_enabled: bool = True + empathy_enabled: bool = True + + +@dataclass +class AppSettings: + llm: LLMSettings = field(default_factory=LLMSettings) + interruption: InterruptionSettings = field(default_factory=InterruptionSettings) + personality: PersonalitySettings = field(default_factory=PersonalitySettings) + data_dir: str = os.path.expanduser("~/.air_agent") + + def save(self, path: str = ""): + path = path or os.path.join(self.data_dir, "config.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump({ + "llm": { + "provider": self.llm.provider, + "model": self.llm.model, + "api_key": self.llm.api_key, + "base_url": self.llm.base_url, + "max_tokens": self.llm.max_tokens, + "temperature": self.llm.temperature, + "timeout": self.llm.timeout, + }, + "interruption": { + "enabled": self.interruption.enabled, + "cooldown_seconds": self.interruption.cooldown_seconds, + "pause_threshold": self.interruption.pause_threshold, + "max_per_minute": self.interruption.max_per_minute, + }, + "personality": { + "name": self.personality.name, + "style": self.personality.style, + "proactive_chat": self.personality.proactive_chat, + "idle_timeout": self.personality.idle_timeout, + "humor_enabled": self.personality.humor_enabled, + "empathy_enabled": self.personality.empathy_enabled, + }, + "data_dir": self.data_dir, + }, f, indent=2, ensure_ascii=False) + + @classmethod + def load(cls, path: str = "") -> "AppSettings": + path = path or os.path.join(os.path.expanduser("~/.air_agent"), "config.json") + if not os.path.exists(path): + return cls() + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + llm_data = data.get("llm", {}) + int_data = data.get("interruption", {}) + per_data = data.get("personality", {}) + return cls( + llm=LLMSettings(**llm_data), + interruption=InterruptionSettings(**int_data), + personality=PersonalitySettings(**per_data), + data_dir=data.get("data_dir", cls().data_dir), + ) diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/agent_thread.py b/gui/agent_thread.py new file mode 100644 index 0000000..477577a --- /dev/null +++ b/gui/agent_thread.py @@ -0,0 +1,131 @@ +from __future__ import annotations +import asyncio +import sys +from PyQt5.QtCore import QThread, pyqtSignal + +from agent.kernel import EventBus, Event, EventType, AgentRuntime +from agent.llm import LLMClient +from agent.personality import Personality +from agent.conversation import ConversationManager +from agent.interruption import InterruptionEngine +from agent.thinker import Thinker +from agent.orchestrator import Orchestrator +from agent.tools.registry import ToolRegistry +from agent.tools.file_tools import register_file_tools +from agent.tools.bash_tools import register_bash_tools +from agent.tools.ask import register_ask_tools + +from config.settings import AppSettings + + +class AgentThread(QThread): + message_chunk = pyqtSignal(str) + message_done = pyqtSignal(str) + agent_thinking = pyqtSignal() + agent_idle = pyqtSignal() + error_occurred = pyqtSignal(str) + interrupt_signal = pyqtSignal(str, str) + idle_topic_signal = pyqtSignal(str) + + def __init__(self, config: AppSettings): + super().__init__() + self.config = config + self._loop: asyncio.AbstractEventLoop = None + self._running = False + self._input_queue: asyncio.Queue[str] = None + + def run(self): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._run_agent()) + + async def _run_agent(self): + self._input_queue = asyncio.Queue() + self._running = True + + runtime = AgentRuntime(self.config) + llm = LLMClient(self.config.llm) + bus = runtime.bus + personality = Personality.from_config({ + "name": self.config.personality.name, + "style": self.config.personality.style, + "proactive_chat": self.config.personality.proactive_chat, + "idle_timeout": self.config.personality.idle_timeout, + "humor_enabled": self.config.personality.humor_enabled, + "empathy_enabled": self.config.personality.empathy_enabled, + }) + conversation = ConversationManager() + interruption = InterruptionEngine(llm=llm, config=self.config) + thinker = Thinker(bus, llm, conversation, self.config) + orchestrator = Orchestrator(runtime, llm, personality, conversation, interruption, thinker) + + conversation.create_topic("general") + await runtime.start() + + thinker_task = runtime.create_task(thinker.run()) + + async def on_thought(event: Event): + data = event.data + if data.get("type") == "idle_chat": + self.idle_topic_signal.emit(data.get("suggestion", "")) + + bus.on(EventType.AGENT_THOUGHT, on_thought) + + try: + while self._running: + try: + user_text = await asyncio.wait_for( + self._input_queue.get(), timeout=0.5 + ) + except asyncio.TimeoutError: + continue + + thinker.notify_user_activity() + conversation.add_message("user", user_text) + self.agent_thinking.emit() + + system_prompt = personality.build_system_prompt() + messages = [{"role": "system", "content": system_prompt}] + messages.extend(conversation.get_context_window(20)) + + try: + full = "" + async for chunk in llm.chat_stream_iter(messages): + full += chunk + self.message_chunk.emit(chunk) + self.message_done.emit(full) + if full.strip(): + conversation.add_message("assistant", full) + except Exception as e: + self.error_occurred.emit(str(e)) + + self.agent_idle.emit() + + finally: + await runtime.stop() + await llm.close() + + def send_message(self, text: str): + if self._input_queue and self._running: + asyncio.run_coroutine_threadsafe( + self._input_queue.put(text), self._loop + ) + + def check_interruption(self, partial_text: str, pause: float): + if not self._running or not self._loop: + return + config = self.config + llm = LLMClient(config.llm) + engine = InterruptionEngine(llm=llm, config=config) + + async def _check(): + decision = await engine.evaluate(partial_text, pause, False) + if decision.should_interrupt: + self.interrupt_signal.emit( + decision.suggested_response, decision.style + ) + + asyncio.run_coroutine_threadsafe(_check(), self._loop) + + def stop_agent(self): + self._running = False diff --git a/gui/chat_gui.py b/gui/chat_gui.py new file mode 100644 index 0000000..afe83ba --- /dev/null +++ b/gui/chat_gui.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +""" +Air Agent GUI —— Fluent Design 聊天界面 (PyQt5) +""" + +import sys +import os + +os.environ.setdefault("QT_API", "PyQt5") + +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtWidgets import ( + QApplication, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QSizePolicy, QSpacerItem, QScrollBar, +) +from PyQt5.QtGui import QFont + +from qfluentwidgets import ( + FluentWindow, NavigationItemPosition, ScrollArea, + PushButton, PrimaryPushButton, + LineEdit, + BodyLabel, TitleLabel, CaptionLabel, SubtitleLabel, StrongBodyLabel, + FluentIcon as FIF, + CardWidget, + setTheme, Theme, + InfoBar, + SwitchButton, SpinBox, ComboBox, +) + +from config.settings import AppSettings +from gui.agent_thread import AgentThread + + +MSG_COLORS = { + "user": {"bg": "#e8f0fe", "text": "#1a1a1a"}, + "assistant": {"bg": "#f0f0f0", "text": "#1a1a1a"}, + "interrupt": {"bg": "#fff8e1", "text": "#8d6e00"}, +} + +DARK_COLORS = { + "user": {"bg": "#2b3a4a", "text": "#e0e0e0"}, + "assistant": {"bg": "#2d2d2d", "text": "#e0e0e0"}, + "interrupt": {"bg": "#3d3510", "text": "#ffd54f"}, +} + + +class ChatBubble(CardWidget): + def __init__(self, text: str, role: str, parent=None): + super().__init__(parent) + self.role = role + self.setBorderRadius(12) + self.setMinimumHeight(40) + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 10, 16, 10) + layout.setSpacing(4) + + label = BodyLabel(text, self) + label.setWordWrap(True) + label.setMinimumWidth(100) + label.setMaximumWidth(520) + label.setFont(QFont("Segoe UI", 10)) + self._label = label + layout.addWidget(label) + + def apply_theme(self, is_dark: bool): + colors = DARK_COLORS if is_dark else MSG_COLORS + c = colors.get(self.role, colors["assistant"]) + self.setStyleSheet(f""" + ChatBubble {{ + background-color: {c["bg"]}; + border-radius: 12px; + }} + """) + self._label.setStyleSheet(f"color: {c['text']};") + + +class ChatPage(QWidget): + def __init__(self, agent_thread: AgentThread, parent=None): + super().__init__(parent) + self.setObjectName("chatPage") + self.agent = agent_thread + self._bubbles = [] + self._is_streaming = False + self._stream_bubble = None + self._is_dark = False + self._typing_timer = QTimer(self) + self._typing_timer.setSingleShot(True) + self._typing_timer.timeout.connect(self._on_typing_pause) + self._last_input = "" + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + tb = QWidget(self) + tb.setFixedHeight(48) + tbh = QHBoxLayout(tb) + tbh.setContentsMargins(24, 8, 24, 8) + title = TitleLabel("与 AI 聊天", tb) + title.setFont(QFont("Segoe UI", 14, QFont.Weight.SemiBold if hasattr(QFont.Weight, 'SemiBold') else 63)) + tbh.addWidget(title) + tbh.addStretch() + root.addWidget(tb) + + self._scroll = ScrollArea(self) + self._scroll.setWidgetResizable(True) + self._scroll.setObjectName("chatScroll") + try: + self._scroll.enableTransparentBackground() + except AttributeError: + pass + + self._msg_box = QWidget() + self._msg_box.setObjectName("msgBox") + self._msg_layout = QVBoxLayout(self._msg_box) + self._msg_layout.setContentsMargins(24, 8, 24, 8) + self._msg_layout.setSpacing(8) + self._msg_layout.setAlignment(Qt.AlignTop) + + spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding) + self._msg_layout.addSpacerItem(spacer) + + self._scroll.setWidget(self._msg_box) + root.addWidget(self._scroll, stretch=1) + + input_area = QWidget(self) + input_area.setFixedHeight(80) + input_layout = QHBoxLayout(input_area) + input_layout.setContentsMargins(24, 12, 24, 12) + input_layout.setSpacing(8) + + self._input_edit = LineEdit(input_area) + self._input_edit.setPlaceholderText("说点什么...") + self._input_edit.setClearButtonEnabled(True) + self._input_edit.setMinimumHeight(36) + self._input_edit.returnPressed.connect(self._send_message) + self._input_edit.textChanged.connect(self._on_text_changed) + + self._send_btn = PrimaryPushButton(FIF.SEND, "发送", input_area) + self._send_btn.setFixedHeight(36) + self._send_btn.clicked.connect(self._send_message) + + self._indicator = BodyLabel("", input_area) + self._indicator.setFixedWidth(80) + self._indicator.setAlignment(Qt.AlignCenter) + + input_layout.addWidget(self._input_edit, stretch=1) + input_layout.addWidget(self._send_btn) + input_layout.addWidget(self._indicator) + + root.addWidget(input_area) + + self.agent.message_chunk.connect(self._on_chunk) + self.agent.message_done.connect(self._on_message_done) + self.agent.agent_thinking.connect(self._on_thinking) + self.agent.agent_idle.connect(self._on_idle) + self.agent.error_occurred.connect(self._on_error) + self.agent.interrupt_signal.connect(self._on_interrupt) + self.agent.idle_topic_signal.connect(self._on_idle_topic) + + def _on_text_changed(self, text: str): + if text and text != self._last_input: + self._last_input = text + self._typing_timer.start(2000) + elif not text: + self._last_input = "" + + def _on_typing_pause(self): + text = self._input_edit.text() + if len(text) > 3: + self.agent.check_interruption(text, 2.0) + + def _send_message(self): + text = self._input_edit.text().strip() + if not text or self._is_streaming: + return + self._input_edit.clear() + self._typing_timer.stop() + self._last_input = "" + self._add_bubble(text, "user") + self.agent.send_message(text) + + def _add_bubble(self, text: str, role: str): + bubble = ChatBubble(text, role, self._msg_box) + bubble.apply_theme(self._is_dark) + row = QHBoxLayout() + if role == "user": + row.addStretch() + row.addWidget(bubble) + else: + row.addWidget(bubble) + row.addStretch() + self._msg_layout.insertLayout(self._msg_layout.count() - 1, row) + self._bubbles.append(bubble) + self._scroll_to_bottom() + + def _scroll_to_bottom(self): + QTimer.singleShot(50, lambda: self._scroll.verticalScrollBar().setValue( + self._scroll.verticalScrollBar().maximum() + )) + + def _on_chunk(self, chunk: str): + if not self._is_streaming: + self._is_streaming = True + self._indicator.setText("输入中...") + bubble = ChatBubble("", "assistant", self._msg_box) + bubble.apply_theme(self._is_dark) + row = QHBoxLayout() + row.addWidget(bubble) + row.addStretch() + self._msg_layout.insertLayout(self._msg_layout.count() - 1, row) + self._stream_bubble = bubble + self._bubbles.append(bubble) + if self._stream_bubble: + current = self._stream_bubble._label.text() + self._stream_bubble._label.setText(current + chunk) + self._scroll_to_bottom() + + def _on_message_done(self, full: str): + self._is_streaming = False + self._stream_bubble = None + self._indicator.setText("") + + def _on_thinking(self): + self._indicator.setText("思考中...") + + def _on_idle(self): + self._indicator.setText("") + + def _on_error(self, msg: str): + self._indicator.setText("") + InfoBar.error("错误", msg, duration=5000, parent=self.window()) + + def _on_interrupt(self, response: str, style: str): + text = f"[插话] {response}" + self._add_bubble(text, "interrupt") + + def _on_idle_topic(self, suggestion: str): + text = f"[主动] {suggestion}" + self._add_bubble(text, "interrupt") + + def set_theme(self, dark: bool): + self._is_dark = dark + for b in self._bubbles: + b.apply_theme(dark) + + +class SettingsPage(QWidget): + def __init__(self, config: AppSettings, parent=None): + super().__init__(parent) + self.setObjectName("settingsPage") + self.config = config + + scroll = ScrollArea(self) + scroll.setWidgetResizable(True) + try: + scroll.enableTransparentBackground() + except AttributeError: + pass + + inner = QWidget() + vl = QVBoxLayout(inner) + vl.setContentsMargins(36, 24, 36, 24) + vl.setSpacing(16) + + vl.addWidget(TitleLabel("设置", inner)) + vl.addSpacing(8) + + vl.addWidget(SubtitleLabel("API 配置")) + api_card = CardWidget(inner) + ac = QVBoxLayout(api_card) + ac.setContentsMargins(20, 14, 20, 14) + ac.setSpacing(8) + + ac.addWidget(BodyLabel("API Key:")) + self.key_edit = LineEdit(api_card) + self.key_edit.setText(config.llm.api_key) + self.key_edit.setEchoMode(2) + self.key_edit.setClearButtonEnabled(True) + ac.addWidget(self.key_edit) + + ac.addWidget(BodyLabel("Base URL:")) + self.url_edit = LineEdit(api_card) + self.url_edit.setText(config.llm.base_url) + self.url_edit.setClearButtonEnabled(True) + ac.addWidget(self.url_edit) + + ac.addWidget(BodyLabel("Model:")) + self.model_edit = LineEdit(api_card) + self.model_edit.setText(config.llm.model) + self.model_edit.setClearButtonEnabled(True) + ac.addWidget(self.model_edit) + + apply_api_btn = PushButton("保存 API 设置", api_card) + apply_api_btn.clicked.connect(self._save_api) + ac.addWidget(apply_api_btn) + vl.addWidget(api_card) + + vl.addWidget(SubtitleLabel("对话设置")) + chat_card = CardWidget(inner) + cc = QVBoxLayout(chat_card) + cc.setContentsMargins(20, 14, 20, 14) + cc.setSpacing(8) + + cc.addWidget(BodyLabel("AI 名字:")) + self.name_edit = LineEdit(chat_card) + self.name_edit.setText(config.personality.name) + cc.addWidget(self.name_edit) + + cc.addWidget(BodyLabel("对话风格:")) + self.style_combo = ComboBox(chat_card) + self.style_combo.addItems(["casual", "friendly", "professional"]) + idx = self.style_combo.findText(config.personality.style) + if idx >= 0: + self.style_combo.setCurrentIndex(idx) + cc.addWidget(self.style_combo) + + cc.addWidget(BodyLabel("空闲主动聊天:")) + self.proactive_switch = SwitchButton(chat_card) + self.proactive_switch.setChecked(config.personality.proactive_chat) + cc.addWidget(self.proactive_switch) + + apply_chat_btn = PushButton("保存对话设置", chat_card) + apply_chat_btn.clicked.connect(self._save_chat) + cc.addWidget(apply_chat_btn) + vl.addWidget(chat_card) + + vl.addWidget(SubtitleLabel("外观")) + theme_card = CardWidget(inner) + tl = QHBoxLayout(theme_card) + tl.setContentsMargins(20, 14, 20, 14) + tl.setSpacing(12) + tl.addWidget(BodyLabel("深色模式:")) + self.theme_switch = SwitchButton(theme_card) + self.theme_switch.checkedChanged.connect(self._toggle_theme) + tl.addWidget(self.theme_switch) + tl.addStretch() + vl.addWidget(theme_card) + + vl.addStretch() + scroll.setWidget(inner) + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.addWidget(scroll) + + def _save_api(self): + self.config.llm.api_key = self.key_edit.text().strip() + self.config.llm.base_url = self.url_edit.text().strip() + self.config.llm.model = self.model_edit.text().strip() + self.config.save() + InfoBar.success("已保存", "API 配置已保存,重启后生效", duration=3000, parent=self.window()) + + def _save_chat(self): + self.config.personality.name = self.name_edit.text().strip() + self.config.personality.style = self.style_combo.currentText() + self.config.personality.proactive_chat = self.proactive_switch.isChecked() + self.config.save() + InfoBar.success("已保存", "对话设置已保存,重启后生效", duration=3000, parent=self.window()) + + def _toggle_theme(self, dark: bool): + setTheme(Theme.DARK if dark else Theme.LIGHT) + main = self.window() + if hasattr(main, "chat_page"): + main.chat_page.set_theme(dark) + + +class AgentWindow(FluentWindow): + def __init__(self, config: AppSettings): + super().__init__() + self.config = config + self.setWindowTitle("Air Agent") + self.setMinimumSize(800, 560) + self.resize(1000, 680) + + self.agent_thread = AgentThread(config) + self.chat_page = ChatPage(self.agent_thread, self) + self.settings_page = SettingsPage(config, self) + + self.addSubInterface( + self.chat_page, FIF.CHAT, "聊天", NavigationItemPosition.TOP + ) + self.addSubInterface( + self.settings_page, FIF.SETTING, "设置", NavigationItemPosition.BOTTOM + ) + + setTheme(Theme.LIGHT) + self.agent_thread.start() + + def closeEvent(self, event): + self.agent_thread.stop_agent() + self.agent_thread.quit() + self.agent_thread.wait(3000) + super().closeEvent(event) + + +def run_gui(config: AppSettings): + app = QApplication(sys.argv) + app.setApplicationName("Air Agent") + + win = AgentWindow(config) + win.show() + sys.exit(app.exec()) diff --git a/main.py b/main.py new file mode 100644 index 0000000..1d13215 --- /dev/null +++ b/main.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +import asyncio +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from config.settings import AppSettings + + +def run_cli(): + from cli.chat_cli import ChatCLI + cli = ChatCLI(config) + asyncio.run(cli.run()) + + +def run_gui(): + from gui.chat_gui import run_gui as gui_main + gui_main(config) + + +def first_time_setup(): + print("=" * 50) + print(" Air Agent — 首次运行配置") + print("=" * 50) + config.llm.api_key = input("OpenAI API Key: ").strip() + config.llm.base_url = input(f"Base URL [{config.llm.base_url}]: ").strip() or config.llm.base_url + config.llm.model = input(f"Model [{config.llm.model}]: ").strip() or config.llm.model + config.save() + print("配置已保存到 ~/.air_agent/config.json\n") + + +if __name__ == "__main__": + config = AppSettings.load() + + if not config.llm.api_key: + first_time_setup() + + mode = "gui" + if len(sys.argv) > 1: + mode = sys.argv[1] + + if mode == "cli": + run_cli() + else: + try: + run_gui() + except ImportError as e: + print(f"GUI 模式需要 PyQt6 + qfluentwidgets: pip install PyQt6 qfluentwidgets") + print(f"导入错误: {e}") + print("切换到 CLI 模式...") + run_cli() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..accefdb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +httpx>=0.27.0 +PyQt5>=5.15.0 +PyQt-Fluent-Widgets>=1.11.0