Initial commit: Air Agent Framework v2:全双工对话智能体(打断、插话、主动开口)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user