Initial commit: Air Agent Framework v2:全双工对话智能体(打断、插话、主动开口)

This commit is contained in:
WpyQwq
2026-09-19 12:09:51 +08:00
commit fa0cb5824d
26 changed files with 2362 additions and 0 deletions
+436
View File
@@ -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 编码。
View File
+88
View File
@@ -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:]
+92
View File
@@ -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()
+120
View File
@@ -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()
+108
View File
@@ -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()
+202
View File
@@ -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()
View File
+59
View File
@@ -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)
+78
View File
@@ -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),
)
+129
View File
@@ -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
+82
View File
@@ -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
View File
+22
View File
@@ -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}]"
+51
View File
@@ -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}]"
+73
View File
@@ -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)
+46
View File
@@ -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
View File
+99
View File
@@ -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()
View File
+89
View File
@@ -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),
)
View File
+131
View File
@@ -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
+402
View File
@@ -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())
+52
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
httpx>=0.27.0
PyQt5>=5.15.0
PyQt-Fluent-Widgets>=1.11.0