yaping-aether 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aether/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Aether — 1+N 多模态 Agent 框架"""
2
+
3
+ __version__ = "0.9.0"
File without changes
@@ -0,0 +1,28 @@
1
+ """内容创作专家 Agent"""
2
+
3
+ from smolagents import CodeAgent, LiteLLMModel
4
+
5
+ from ..config import WorkerConfig
6
+ from ..tools.create_tools import write_article, generate_code, summarize_text
7
+ from ..tools.file_tools import write_file
8
+ from ..tools.project_tools import edit_file
9
+
10
+
11
+ def create_create_agent(config: WorkerConfig) -> CodeAgent:
12
+ """创建内容创作专家 Agent。支持独立模型(CREATE_MODEL),否则用默认。"""
13
+ mc = config.create_model or config.model
14
+ model = LiteLLMModel(model_id=mc.model_id, api_key=mc.api_key, temperature=mc.temperature)
15
+
16
+ agent = CodeAgent(
17
+ name="create_agent",
18
+ description=(
19
+ "内容创作专家。能写文章、生成代码、摘要文本。"
20
+ "当任务涉及创作内容(写文章、写代码、做摘要、生成文档)时调用我。"
21
+ ),
22
+ tools=[write_article, generate_code, summarize_text, write_file, edit_file],
23
+ model=model,
24
+ provide_run_summary=True,
25
+ max_steps=12,
26
+ )
27
+
28
+ return agent
@@ -0,0 +1,27 @@
1
+ """文件管理专家 Agent"""
2
+
3
+ from smolagents import CodeAgent, LiteLLMModel
4
+
5
+ from ..config import WorkerConfig
6
+ from ..tools.file_tools import read_file, write_file, list_directory, search_files
7
+ from ..tools.project_tools import project_map
8
+
9
+
10
+ def create_file_agent(config: WorkerConfig) -> CodeAgent:
11
+ """创建文件管理专家 Agent。支持独立模型(FILE_MODEL),否则用默认。"""
12
+ mc = config.file_model or config.model
13
+ model = LiteLLMModel(model_id=mc.model_id, api_key=mc.api_key, temperature=mc.temperature)
14
+
15
+ agent = CodeAgent(
16
+ name="file_agent",
17
+ description=(
18
+ "文件管理专家。能搜索、读取、写入、整理本地文件和目录,扫描项目结构。"
19
+ "当任务涉及文件操作(读文件、写文件、整理文件夹、搜索文件、了解项目结构)时调用我。"
20
+ ),
21
+ tools=[read_file, write_file, list_directory, search_files, project_map],
22
+ model=model,
23
+ provide_run_summary=True,
24
+ max_steps=10,
25
+ )
26
+
27
+ return agent
@@ -0,0 +1,26 @@
1
+ """搜索专家 Agent"""
2
+
3
+ from smolagents import CodeAgent, LiteLLMModel
4
+
5
+ from ..config import WorkerConfig
6
+ from ..tools.search_tools import web_search, fetch_url
7
+
8
+
9
+ def create_search_agent(config: WorkerConfig) -> CodeAgent:
10
+ """创建搜索专家 Agent。支持独立模型(SEARCH_MODEL),否则用默认。"""
11
+ mc = config.search_model or config.model
12
+ model = LiteLLMModel(model_id=mc.model_id, api_key=mc.api_key, temperature=mc.temperature)
13
+
14
+ agent = CodeAgent(
15
+ name="search_agent",
16
+ description=(
17
+ "搜索专家。能搜索网页、抓取 URL 内容、聚合资讯。"
18
+ "当任务涉及在网上查找信息、搜索资料、获取最新资讯时调用我。"
19
+ ),
20
+ tools=[web_search, fetch_url],
21
+ model=model,
22
+ provide_run_summary=True,
23
+ max_steps=8,
24
+ )
25
+
26
+ return agent
File without changes
@@ -0,0 +1,140 @@
1
+ """语义缓存层
2
+
3
+ 对相似的任务/查询进行缓存,避免重复调用 LLM。
4
+
5
+ 核心思路:
6
+ 1. 将用户任务文本做 embedding
7
+ 2. 与缓存中的历史任务做余弦相似度匹配
8
+ 3. 相似度超过阈值 → 直接返回缓存结果
9
+ 4. 否则执行任务 → 结果存入缓存
10
+
11
+ MVP 阶段使用简单的文本 hash + 模糊匹配,
12
+ 后续可升级为向量数据库方案。
13
+ """
14
+
15
+ import hashlib
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+
21
+ @dataclass
22
+ class CacheEntry:
23
+ """单条缓存记录"""
24
+ task_hash: str
25
+ task: str
26
+ result: Any
27
+ timestamp: float = field(default_factory=time.time)
28
+ hit_count: int = 0
29
+
30
+
31
+ class SemanticCache:
32
+ """语义缓存管理器。
33
+
34
+ MVP 版本使用以下策略:
35
+ - 精确匹配:相同任务文本 → 直接命中
36
+ - 规范化匹配:去空白、小写后相同 → 命中
37
+ - 前缀匹配:新任务是缓存任务的子串 → 命中
38
+
39
+ 未来可升级为 embedding + 向量相似度匹配。
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ max_entries: int = 100,
45
+ ttl_seconds: int = 3600,
46
+ similarity_threshold: float = 0.92,
47
+ ):
48
+ self._cache: dict[str, CacheEntry] = {}
49
+ self._max_entries = max_entries
50
+ self._ttl_seconds = ttl_seconds
51
+ self._similarity_threshold = similarity_threshold
52
+
53
+ def _normalize(self, text: str) -> str:
54
+ """规范化文本:去除所有空白、转小写。
55
+
56
+ 中英文混排场景下,空格不是可靠的词边界("Python协程" vs "Python 协程"),
57
+ 因此直接去掉所有空白后做哈希,确保语义等价文本命中同一缓存。
58
+ """
59
+ # 去掉所有空白字符(空格、换行、制表等)
60
+ import re
61
+ return re.sub(r"\s+", "", text.lower())
62
+
63
+ def _hash(self, task: str) -> str:
64
+ """计算任务的哈希"""
65
+ return hashlib.md5(self._normalize(task).encode()).hexdigest()
66
+
67
+ def get(self, task: str) -> Any | None:
68
+ """查找缓存。返回结果或 None。"""
69
+ # 1. 精确匹配
70
+ task_hash = self._hash(task)
71
+ entry = self._cache.get(task_hash)
72
+ if entry:
73
+ # 检查是否过期
74
+ if time.time() - entry.timestamp > self._ttl_seconds:
75
+ del self._cache[task_hash]
76
+ return None
77
+ entry.hit_count += 1
78
+ return entry.result
79
+
80
+ # 2. 规范化匹配(遍历缓存查找)
81
+ normalized = self._normalize(task)
82
+ for cached_hash, cached_entry in self._cache.items():
83
+ cached_normalized = self._normalize(cached_entry.task)
84
+ # 完全相同
85
+ if normalized == cached_normalized:
86
+ if time.time() - cached_entry.timestamp < self._ttl_seconds:
87
+ cached_entry.hit_count += 1
88
+ # 将新 hash 也指向同一结果
89
+ self._cache[task_hash] = cached_entry
90
+ return cached_entry.result
91
+ # 新任务是缓存任务的子串(更具体的追问可能命中之前的大任务)
92
+ if normalized in cached_normalized and len(normalized) > 20:
93
+ if time.time() - cached_entry.timestamp < self._ttl_seconds:
94
+ cached_entry.hit_count += 1
95
+ return cached_entry.result
96
+
97
+ return None
98
+
99
+ def set(self, task: str, result: Any) -> None:
100
+ """存入缓存"""
101
+ # 清理过期条目
102
+ self._evict_expired()
103
+
104
+ # 超出容量时淘汰命中次数最少的
105
+ if len(self._cache) >= self._max_entries:
106
+ min_entry = min(self._cache.values(), key=lambda e: e.hit_count)
107
+ del self._cache[min_entry.task_hash]
108
+
109
+ task_hash = self._hash(task)
110
+ self._cache[task_hash] = CacheEntry(
111
+ task_hash=task_hash,
112
+ task=task,
113
+ result=result,
114
+ )
115
+
116
+ def _evict_expired(self) -> None:
117
+ """清理过期缓存"""
118
+ now = time.time()
119
+ expired = [
120
+ h for h, e in self._cache.items()
121
+ if now - e.timestamp > self._ttl_seconds
122
+ ]
123
+ for h in expired:
124
+ del self._cache[h]
125
+
126
+ def clear(self) -> None:
127
+ """清空所有缓存"""
128
+ self._cache.clear()
129
+
130
+ @property
131
+ def stats(self) -> dict:
132
+ """缓存统计信息"""
133
+ total = len(self._cache)
134
+ hits = sum(e.hit_count for e in self._cache.values())
135
+ return {
136
+ "entries": total,
137
+ "total_hits": hits,
138
+ "max_entries": self._max_entries,
139
+ "ttl_seconds": self._ttl_seconds,
140
+ }
aether/checkpoint.py ADDED
@@ -0,0 +1,103 @@
1
+ """Checkpoint 轻量快照
2
+
3
+ 工具调用前自动备份文件 → /rewind 秒级回滚,不依赖 Git。
4
+
5
+ 结构:
6
+ .aether/checkpoints/
7
+ 2026-07-02-15-30-01/
8
+ src/aether/main.py ← 修改前的副本
9
+ src/aether/config.py
10
+ """
11
+
12
+ import shutil
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+
17
+ class CheckpointStore:
18
+ """管理文件快照的创建和回滚"""
19
+
20
+ def __init__(self, base_dir: Path | None = None):
21
+ self._base = base_dir or Path(".aether") / "checkpoints"
22
+ self._base.mkdir(parents=True, exist_ok=True)
23
+
24
+ def snapshot(self, files: list[str]) -> str | None:
25
+ """备份指定文件,返回快照 ID(时间戳)"""
26
+ ts = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
27
+ snap_dir = self._base / ts
28
+ snap_dir.mkdir(parents=True)
29
+
30
+ backed = 0
31
+ for f in files:
32
+ src = Path(f)
33
+ if not src.exists():
34
+ continue
35
+ dst = snap_dir / f
36
+ dst.parent.mkdir(parents=True, exist_ok=True)
37
+ shutil.copy2(src, dst)
38
+ backed += 1
39
+
40
+ return ts if backed > 0 else None
41
+
42
+ def rewind(self, checkpoint_id: str | None = None) -> str:
43
+ """回滚到指定快照(默认回滚到最新)"""
44
+ snap_dirs = sorted(self._base.iterdir(), key=lambda p: p.name, reverse=True)
45
+
46
+ if checkpoint_id:
47
+ target = self._base / checkpoint_id
48
+ if not target.exists():
49
+ return f"[错误] 快照不存在: {checkpoint_id}"
50
+ elif snap_dirs:
51
+ target = snap_dirs[0]
52
+ else:
53
+ return "没有可用的快照。"
54
+
55
+ # 还原所有文件
56
+ restored = 0
57
+ for bak in target.rglob("*"):
58
+ if bak.is_dir():
59
+ continue
60
+ rel = bak.relative_to(target)
61
+ src = Path(rel)
62
+ src.parent.mkdir(parents=True, exist_ok=True)
63
+ shutil.copy2(bak, src)
64
+ restored += 1
65
+
66
+ return f"✅ 已回滚到 {target.name},还原 {restored} 个文件。"
67
+
68
+ def list_checkpoints(self) -> list[str]:
69
+ """列出所有快照"""
70
+ if not self._base.exists():
71
+ return []
72
+ return sorted(
73
+ [d.name for d in self._base.iterdir() if d.is_dir()],
74
+ reverse=True,
75
+ )
76
+
77
+ def cleanup(self, keep: int = 10) -> int:
78
+ """清理旧快照,保留最近 N 个"""
79
+ all_dirs = sorted(
80
+ [d for d in self._base.iterdir() if d.is_dir()],
81
+ key=lambda p: p.name,
82
+ )
83
+ deleted = 0
84
+ for d in all_dirs[:-keep]:
85
+ shutil.rmtree(d)
86
+ deleted += 1
87
+ return deleted
88
+
89
+
90
+ # 全局实例
91
+ _checkpoint: CheckpointStore | None = None
92
+
93
+
94
+ def get_checkpoint() -> CheckpointStore:
95
+ global _checkpoint
96
+ if _checkpoint is None:
97
+ _checkpoint = CheckpointStore()
98
+ return _checkpoint
99
+
100
+
101
+ def auto_snapshot(file_path: str) -> str | None:
102
+ """工具写文件前自动快照"""
103
+ return get_checkpoint().snapshot([file_path])
aether/compact.py ADDED
@@ -0,0 +1,112 @@
1
+ """上下文压缩
2
+
3
+ 长对话自动压缩,避免 Token 消耗线性增长。
4
+
5
+ 策略:
6
+ 1. 每次执行后检查上下文长度
7
+ 2. 超过阈值时,用便宜模型摘要历史对话
8
+ 3. 摘要注入系统提示词,释放上下文空间
9
+ """
10
+
11
+ from smolagents import CodeAgent
12
+
13
+
14
+ def estimate_tokens(text: str) -> int:
15
+ """粗略估算 Token 数(中文 ~1.5 字/token,英文 ~0.75 词/token)"""
16
+ chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
17
+ other_chars = len(text) - chinese_chars
18
+ return int(chinese_chars / 1.5 + other_chars / 4)
19
+
20
+
21
+ # 常见模型的上下文窗口大小(Token)
22
+ MODEL_CONTEXT_SIZES = {
23
+ "qwen-max": 32_768,
24
+ "qwen-plus": 131_072,
25
+ "qwen-turbo": 1_000_000,
26
+ "deepseek-chat": 128_000,
27
+ "deepseek-v4": 128_000,
28
+ "deepseek-reasoner": 64_000,
29
+ "glm-4": 128_000,
30
+ "glm-4v": 128_000,
31
+ "moonshot": 128_000,
32
+ "doubao-pro": 128_000,
33
+ "doubao-2.0": 256_000,
34
+ "gpt-4o": 128_000,
35
+ "claude": 200_000,
36
+ }
37
+ DEFAULT_CONTEXT_SIZE = 128_000 # 2026 年主流模型默认 128K
38
+
39
+
40
+ def get_model_context_size(model_id: str) -> int:
41
+ """根据 model_id 猜测上下文窗口大小"""
42
+ model_lower = model_id.lower()
43
+ for key, size in MODEL_CONTEXT_SIZES.items():
44
+ if key in model_lower:
45
+ return size
46
+ return DEFAULT_CONTEXT_SIZE
47
+
48
+
49
+ def compact_agent_memory(agent: CodeAgent, ratio: float = 0.8) -> bool:
50
+ """压缩 Agent 的对话记忆。
51
+
52
+ 如果记忆中总 Token 超过 模型上下文窗口 × ratio,压缩早期对话。
53
+
54
+ ratio=0.8 意味着用满 80% 的上下文窗口后才触发压缩。
55
+ ratio=1.0 意味着永不自动压缩。
56
+
57
+ Returns:
58
+ True 如果执行了压缩
59
+ """
60
+ if ratio >= 1.0:
61
+ return False # 永不压缩
62
+
63
+ steps = agent.memory.steps
64
+ if len(steps) < 6:
65
+ return False # 太少不必压
66
+
67
+ # 估算当前上下文
68
+ total_text = ""
69
+ for step in steps:
70
+ if hasattr(step, "task") and step.task:
71
+ total_text += str(step.task) + "\n"
72
+ if hasattr(step, "action_output") and step.action_output:
73
+ total_text += str(step.action_output) + "\n"
74
+
75
+ estimated = estimate_tokens(total_text)
76
+
77
+ # 根据模型上下文窗口计算阈值
78
+ model_id = ""
79
+ if hasattr(agent, "model") and hasattr(agent.model, "model_id"):
80
+ model_id = agent.model.model_id or ""
81
+ context_size = get_model_context_size(model_id)
82
+ threshold = int(context_size * ratio)
83
+
84
+ if estimated < threshold:
85
+ return False
86
+
87
+ # 需要压缩:保留最近 2 步,其余摘要
88
+ # smolagents 的 memory 支持 write_memory_to_messages 和 summary_mode
89
+ # 这里简单标记——实际压缩依赖 smolagents 内置机制
90
+ try:
91
+ # 触发 smolagents 的 summary_mode
92
+ agent.memory.steps = agent.memory.steps[-3:] # 只保留最近 3 步
93
+ return True
94
+ except Exception:
95
+ return False
96
+
97
+
98
+ def get_context_stats(agent: CodeAgent) -> dict:
99
+ """获取当前上下文的使用统计"""
100
+ steps = agent.memory.steps
101
+ total_text = ""
102
+ for step in steps:
103
+ if hasattr(step, "task") and step.task:
104
+ total_text += str(step.task) + "\n"
105
+ if hasattr(step, "action_output") and step.action_output:
106
+ total_text += str(step.action_output) + "\n"
107
+
108
+ return {
109
+ "steps": len(steps),
110
+ "estimated_tokens": estimate_tokens(total_text),
111
+ "estimated_chars": len(total_text),
112
+ }
aether/config.py ADDED
@@ -0,0 +1,244 @@
1
+ """
2
+ Aether 配置管理 — 三层加载:全局 > 项目 > 命令行
3
+
4
+ 加载优先级(越高越优先):
5
+ 1. 系统环境变量(命令行 set/export)
6
+ 2. 项目级 .env(当前目录)
7
+ 3. 全局 ~/.aether/config.env(用户级默认)
8
+
9
+ 用法:
10
+ config = load_config()
11
+ config = load_config(overrides={"supervisor_model": "deepseek/chat"})
12
+ """
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ from dotenv import load_dotenv
19
+
20
+ # 全局配置目录
21
+ _AETHER_HOME = Path.home() / ".aether"
22
+ _GLOBAL_ENV = _AETHER_HOME / "config.env"
23
+
24
+ # 加载全局配置(优先级最低)
25
+ if _GLOBAL_ENV.exists():
26
+ load_dotenv(_GLOBAL_ENV)
27
+
28
+ # 项目根目录
29
+ _project_root = Path(__file__).resolve().parent.parent.parent
30
+ _env_file = _project_root / ".env"
31
+ if _env_file.exists():
32
+ load_dotenv(_env_file, override=True) # 项目覆盖全局
33
+
34
+
35
+ # ============================================================
36
+ # 数据类
37
+ # ============================================================
38
+
39
+ @dataclass
40
+ class ModelConfig:
41
+ """单个模型的配置"""
42
+ model_id: str
43
+ api_key: str = ""
44
+ temperature: float = 0.2
45
+ max_tokens: int = 4096
46
+
47
+
48
+ @dataclass
49
+ class SupervisorConfig:
50
+ """主管 Agent 配置"""
51
+ model: ModelConfig # 执行模型
52
+ plan_model: ModelConfig | None = None # 计划模型(可选,不设则用 model)
53
+ max_steps: int = 20
54
+
55
+
56
+ @dataclass
57
+ class WorkerConfig:
58
+ """专家 Agent 配置"""
59
+ model: ModelConfig # 默认模型
60
+ file_model: ModelConfig | None = None # 文件专家专用
61
+ search_model: ModelConfig | None = None # 搜索专家专用
62
+ create_model: ModelConfig | None = None # 创作专家专用
63
+
64
+
65
+ @dataclass
66
+ class MemoryConfig:
67
+ """记忆系统配置"""
68
+ enabled: bool = True
69
+ max_entries: int = 50
70
+
71
+
72
+ @dataclass
73
+ class CacheConfig:
74
+ """缓存配置"""
75
+ enabled: bool = False
76
+ ttl_seconds: int = 3600
77
+ similarity_threshold: float = 0.92
78
+
79
+
80
+ @dataclass
81
+ class AetherConfig:
82
+ """Aether 全局配置"""
83
+ supervisor: SupervisorConfig
84
+ worker: WorkerConfig
85
+ memory: MemoryConfig
86
+ cache: CacheConfig
87
+ log_level: str = "INFO"
88
+ default_mode: str = "auto"
89
+
90
+
91
+ # ============================================================
92
+ # 加载逻辑
93
+ # ============================================================
94
+
95
+ def load_config(overrides: dict | None = None) -> AetherConfig:
96
+ """
97
+ 加载 Aether 配置。
98
+
99
+ 参数:
100
+ overrides: 命令行覆盖项,如 {"supervisor_model": "...", "api_key": "..."}
101
+ """
102
+ ov = overrides or {}
103
+
104
+ def _get(key: str, default: str = "") -> str:
105
+ """按优先级读取: 命令行 > 环境变量 > 默认"""
106
+ return ov.get(key, os.getenv(key, default))
107
+
108
+ # 主管模型
109
+ supervisor_model_id = _get("SUPERVISOR_MODEL", _get("DEFAULT_MODEL", ""))
110
+ supervisor_api_key = _get("SUPERVISOR_API_KEY", _get("DEFAULT_API_KEY", ""))
111
+
112
+ if not supervisor_model_id:
113
+ raise ValueError(
114
+ "未配置模型!请在 .env 或 ~/.aether/config.env 中设置 "
115
+ "SUPERVISOR_MODEL 和 SUPERVISOR_API_KEY。"
116
+ )
117
+
118
+ # 计划模型(可选,不设则共用主管模型)
119
+ plan_model_id = _get("PLAN_MODEL", "")
120
+ plan_api_key = _get("PLAN_API_KEY", supervisor_api_key)
121
+
122
+ # 专家模型(可按专家分别配置)
123
+ worker_model_id = _get("WORKER_MODEL", supervisor_model_id)
124
+ worker_api_key = _get("WORKER_API_KEY", supervisor_api_key)
125
+
126
+ file_model_id = _get("FILE_MODEL", "")
127
+ search_model_id = _get("SEARCH_MODEL", "")
128
+ create_model_id = _get("CREATE_MODEL", "")
129
+
130
+ # 缓存
131
+ cache_enabled = _get("CACHE_ENABLED", "false").lower() in ("true", "1", "yes")
132
+
133
+ return AetherConfig(
134
+ supervisor=SupervisorConfig(
135
+ model=ModelConfig(
136
+ model_id=supervisor_model_id,
137
+ api_key=supervisor_api_key,
138
+ temperature=0.2,
139
+ ),
140
+ plan_model=ModelConfig(
141
+ model_id=plan_model_id,
142
+ api_key=plan_api_key,
143
+ temperature=0.1,
144
+ ) if plan_model_id else None,
145
+ max_steps=int(_get("MAX_STEPS", "20")),
146
+ ),
147
+ worker=WorkerConfig(
148
+ model=ModelConfig(
149
+ model_id=worker_model_id,
150
+ api_key=worker_api_key,
151
+ temperature=0.0,
152
+ ),
153
+ file_model=ModelConfig(model_id=file_model_id, api_key=worker_api_key, temperature=0.0) if file_model_id else None,
154
+ search_model=ModelConfig(model_id=search_model_id, api_key=worker_api_key, temperature=0.0) if search_model_id else None,
155
+ create_model=ModelConfig(model_id=create_model_id, api_key=worker_api_key, temperature=0.0) if create_model_id else None,
156
+ ),
157
+ memory=MemoryConfig(
158
+ enabled=_get("MEMORY_ENABLED", "true").lower() in ("true", "1", "yes"),
159
+ ),
160
+ cache=CacheConfig(enabled=cache_enabled),
161
+ log_level=_get("LOG_LEVEL", "INFO").upper(),
162
+ default_mode=_get("DEFAULT_MODE", "auto").lower(),
163
+ )
164
+
165
+
166
+ def validate_config(config: AetherConfig) -> list[str]:
167
+ """验证配置,返回警告列表"""
168
+ warnings = []
169
+ if "your_" in config.supervisor.model.api_key.lower():
170
+ warnings.append("⚠️ API Key 看起来是占位符,请在 .env 中填入真实 Key")
171
+ if config.supervisor.plan_model is None:
172
+ warnings.append("💡 未配置 PLAN_MODEL,计划阶段将共用主管模型。设置 PLAN_MODEL 可指定更便宜的模型做计划。")
173
+ return warnings
174
+
175
+
176
+ def config_exists() -> bool:
177
+ """检查是否有有效配置"""
178
+ if _env_file.exists() or _GLOBAL_ENV.exists():
179
+ try:
180
+ config = load_config()
181
+ return "your_" not in config.supervisor.model.api_key.lower()
182
+ except ValueError:
183
+ pass
184
+ return False
185
+
186
+
187
+ # ============================================================
188
+ # 配置向导 + 写入
189
+ # ============================================================
190
+
191
+ KNOWN_PROVIDERS = {
192
+ "1": {"name": "DeepSeek (V3/R1/V4)", "model": "deepseek/deepseek-chat", "url": "https://platform.deepseek.com"},
193
+ "2": {"name": "通义千问 Qwen (Max/Plus/Turbo)", "model": "dashscope/qwen-max", "url": "https://dashscope.console.aliyun.com"},
194
+ "3": {"name": "智谱 GLM (4/5.x)", "model": "zhipuai/glm-4", "url": "https://open.bigmodel.cn"},
195
+ "4": {"name": "Moonshot Kimi", "model": "moonshot/moonshot-v1-8k", "url": "https://platform.moonshot.cn"},
196
+ "5": {"name": "豆包 Doubao (字节)", "model": "volcengine/doubao-pro-32k", "url": "https://console.volcengine.com"},
197
+ "6": {"name": "本地 Ollama", "model": "ollama/qwen2.5:7b", "url": "http://localhost:11434"},
198
+ "0": {"name": "自定义", "model": "", "url": "手动输入 model_id(如 deepseek/deepseek-v4-pro)"},
199
+ }
200
+
201
+ # LiteLLM 支持的常见 model_id 格式参考
202
+ MODEL_ID_EXAMPLES = {
203
+ "deepseek-v3": "deepseek/deepseek-chat",
204
+ "deepseek-r1": "deepseek/deepseek-reasoner",
205
+ "deepseek-v4": "deepseek/deepseek-v4-pro",
206
+ "qwen-max": "dashscope/qwen-max",
207
+ "qwen-plus": "dashscope/qwen-plus",
208
+ "qwen-turbo": "dashscope/qwen-turbo",
209
+ "glm-4": "zhipuai/glm-4",
210
+ "glm-4v": "zhipuai/glm-4v",
211
+ "kimi": "moonshot/moonshot-v1-8k",
212
+ "doubao-pro": "volcengine/doubao-pro-32k",
213
+ "doubao-2.0": "volcengine/doubao-2.0-pro",
214
+ "openai": "openai/gpt-4o",
215
+ "ollama": "ollama/qwen2.5:7b",
216
+ "vllm": "openai/your-model-name",
217
+ }
218
+
219
+
220
+ def write_env(provider_id: str, api_key: str, default_mode: str = "plan",
221
+ custom_model: str = "") -> None:
222
+ """写入项目 .env 配置文件"""
223
+ provider = KNOWN_PROVIDERS.get(provider_id)
224
+ if not provider:
225
+ raise ValueError(f"未知提供商: {provider_id}")
226
+
227
+ model_id = custom_model or provider["model"]
228
+
229
+ content = f"""# Aether 配置 — {provider['name']}
230
+ SUPERVISOR_MODEL={model_id}
231
+ SUPERVISOR_API_KEY={api_key}
232
+ WORKER_MODEL={model_id}
233
+ WORKER_API_KEY={api_key}
234
+ # PLAN_MODEL=dashscope/qwen-turbo ← 可选:计划用便宜模型,省 Token
235
+ DEFAULT_MODE={default_mode}
236
+ MAX_STEPS=20
237
+ LOG_LEVEL=INFO
238
+ CACHE_ENABLED=false
239
+ MEMORY_ENABLED=true
240
+ """
241
+ _AETHER_HOME.mkdir(parents=True, exist_ok=True)
242
+ env_path = _project_root / ".env"
243
+ env_path.write_text(content, encoding="utf-8")
244
+ load_dotenv(env_path, override=True)