Initial commit: Air Agent Framework v2:全双工对话智能体(打断、插话、主动开口)
This commit is contained in:
+436
@@ -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 编码。
|
||||
@@ -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:]
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}]"
|
||||
@@ -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}]"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
@@ -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())
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
httpx>=0.27.0
|
||||
PyQt5>=5.15.0
|
||||
PyQt-Fluent-Widgets>=1.11.0
|
||||
Reference in New Issue
Block a user